
Shopify Plus checkout'ta belirli bir koleksiyonun ürünlerini sipariş özeti altında önermenin adı Checkout UI Extension'dır. Kurulum belgesi 80 sayfayı aşıyor, ama asıl zorluğun nerede gizlendiğini o belgeler söylemiyor. Bu yazı, gerçek bir implementation sırasında 18 deploy'da öğrenilen 6 tuzağı tam kod örnekleriyle birlikte aktarıyor.
Sonunda elinizde şu çalışacak: sipariş özetinin toplam satırının hemen altında, Storefront koleksiyonundan dinamik ürünler çeken, "Ekle" ile adet stepper'ı arasında geçiş yapan ve çift tıklama yarış koşuluna karşı korunan bir extension. Merchant, Shopify checkout editöründen hangi koleksiyonun kullanılacağını kod değiştirmeden ayarlayabilir.
Ne İnşa Edeceğiz
Checkout upsell extension'ı, Shopify Plus'a özel bir API yüzeyini kullanır: Checkout UI Extensions. Standart Shopify planlarında checkout'a kod enjekte etmek mümkün değil. Bu nedenle extension geliştirme Shopify Plus veya Shopify Developers Preview kapsamında kalır.
İnşa edeceğimiz bileşen şuna benzer:
┌────────────────────────────────────────────────────────────┐
│ [thumb] Ürün Adı │
│ ₺299,00 [−] 2 [+] │
│ Kaldır │
└────────────────────────────────────────────────────────────┘Üç kolon: 64 piksel thumbnail, esnek başlık + fiyat bloğu, ve sepete eklenmemiş ürün için "Ekle" butonu, sepetteyse miktar stepper'ı. Stack: Preact + JSX, Shopify checkout web component'leri (<s-grid>, <s-stack>, <s-button>), shopify.query üzerinden Storefront API. Harici CSS yok, harici state library yok. ~330 satır, tek dosya.
Gereksinimler ve Kurulum
Checkout UI Extension geliştirmek için:
- Shopify Plus veya Partner geliştirme mağazası
- Shopify CLI (npm install -g @shopify/cli)
- Node.js 18+
Proje iskeleti:
shopify app generate extension --type ui_extension --name checkout-upsell
Bu komut shopify.extension.toml ve src/Checkout.jsx oluşturur. Başlangıç yapılandırması:
api_version = "2026-01"
type = "ui_extension"
name = "Checkout Upsell"
handle = "checkout-upsell"
[[targeting]]
module = "./src/Checkout.jsx"
target = "purchase.checkout.block.render"
[capabilities]
api_access = trueKritik: api_access = true satırı olmadan Storefront API'ye yapılan her çağrı sessizce başarısız olur. Bu, kurulumun en kolay atlanabilen ve en uzun süre kafa karıştıran adımı.
Tuzak 1: api_access Capability - Dokümanda İki Kez Geçiyor
shopify.query(...) ile Storefront API'ye ilk çağrıyı yaptığınızda browser console'unda şu hatayı görürsünüz:
ExtensionUsageError: Extension is not allowed to use the Storefront API;
permission to use the Storefront API must be specified under [capabilities]
with: "api_access = true"Shopify Partners dashboard'unda "Network access" adında bir toggle var. Bu toggle harici URL'lere istek atmak içindir; Storefront API için farklı bir capability gerekiyor. İkisi birbirinin yerine geçmez.
Hata mesajı olmadan ne olur: useEffect sessizce başarısız olur, extension sıfır ürünle render olur ve ekranda hiçbir hata görünmez. Tek sinyal console mesajıdır UI düzeyinde hiçbir şey bozulmuş gibi görünmez.
Çözüm shopify.extension.toml'a tek satır eklemektir:
[capabilities]
api_access = trueBu satır shopify.query çağrısını ve dolayısıyla tüm GraphQL Storefront API sorgularını açar. Yeni extension oluşturduğunuzda ilk iş bu satırı eklemek, sonra kodlamaya başlamaktır.
Tuzak 2: Render Hedefi Seçimi Conversion Rate'i Doğrudan Etkiler
Checkout extension'ları piksel koordinat almaz; DOM'da önceden tanımlanmış hedeflere enjekte edilir. Sipariş özeti panelinde kullanılabilir hedefler:
Çoğu Shopify tutorial'ı cart-line-list.render-after kullanır. Pratikte bu hedef bir sorun yaratır: upsell şeridi sipariş toplamını görünür alanın dışına iter. Müşteri "Ekle" butonuna tıklamadan önce ne ödediğini görmek ister; toplam ekranın altındaysa bu bağlantı kopar.
purchase.checkout.block.render, extension'ı checkout editöründe bağımsız bir App block olarak sunar. Merchant bu bloğu sol panelden sürükleyerek "Cost summary'nin altı"na yerleştirir. Kurulum adımı tek bir drag-drop'tur; karşılığında tam yerleştirme kontrolü ve gelecekteki değişikliklerde esneklik kazanırsınız.
shopify.extension.toml'da targeting bu şekilde tanımlanır:
[[targeting]]
module = "./src/Checkout.jsx"
target = "purchase.checkout.block.render"Tuzak 3: camelCase Zorunluluğu - Sessiz Hata
Shopify checkout extension'ları custom element olarak gelir: <s-grid>, <s-stack>, <s-button>, <s-image>. Preact JSX'te bu element'lerin attribute'ları hem kebab-case hem camelCase yazılabilir görünür. Sadece camelCase çalışır.
@shopify/ui-extensions/preact register'ı, JSX prop adlarını doğrudan component property setter'larına bağlar. Setter'lar yalnızca camelCase isimlerine yanıt verir; kebab-case attribute'lar okunmaz ve component default'una düşer.
// YANLIŞ — s-grid tek-kolon dikey block stack olarak render olur
<s-grid grid-template-columns="64px 1fr auto" align-items="center" gap="base">
...
</s-grid>
// DOĞRU — üç kolon, dikey ortalanmış
<s-grid gridTemplateColumns="64px 1fr auto" alignItems="center" gap="base">
...
</s-grid>Hata mesajı yok. Ekranda sadece yanlış layout görünür. Etkilenen prop'lar: gridTemplateColumns, alignItems, inlineSize, borderRadius, paddingBlock, paddingInline, labelAccessibilityVisibility, accessibilityLabel. Checkout component'inin "stilini almıyorsa" kontrol edilecek ilk nokta budur.
Pratik kural: Her yeni <s- component'ine prop yazarken önce camelCase olduğunu kontrol edin. Tek kelimeli prop'larda (gap, border, background) fark yok; iki kelimeli her prop'ta bu kural geçerli.
Tuzak 4: s-button Checkout Surface'inde icon Prop'unu Açmıyor
Kompakt − / + stepper butonları için s-button'a icon="minus" ve icon="plus" yazmak mantıklı görünür. Sonuç boş, kenarlıklı bir kare olur.
s-button checkout surface'inde temel tipten yalnızca belirli prop'ları miras alır:
// node_modules/@shopify/ui-extensions/.../checkout/components/Button.d.ts
export interface ButtonElementProps extends Pick<
ButtonProps$1,
| "accessibilityLabel"
| "disabled"
| "href"
| "id"
| "inlineSize"
| "loading"
| "target"
| "tone"
| "type"
| "variant"
> {}icon bu Pick<> içinde yok. JSX'te yazarsınız, component okumaz, hiçbir şey render olmaz. Bu pattern checkout surface'indeki birçok component için geçerli: base type'ta var ama element seviyesinde açılmamış prop'lar sessizce yutulur.
Çözüm stepper butonunu s-clickable + s-icon ile inşa etmektir:
function StepperButton({ icon, onClick, disabled, accessibilityLabel }) {
return (
<s-clickable
onClick={onClick}
disabled={disabled}
accessibilityLabel={accessibilityLabel}
border="base base"
borderRadius="small"
paddingBlock="small-200"
paddingInline="small-300"
background="base"
>
<s-icon type={icon} size="small" />
</s-clickable>
);
}İki detay daha:
border="base base" ikinci base renk değeridir. Sadece border="base" yazarsanız yalnızca kalınlık atanır, transparent 1px border render olur, hiçbir şey görünmez. Mutlaka renk değeri eklenmelidir.
paddingBlock ve paddingInline dikey/yatay eksenlerini bağımsız olarak kontrol eder. Kare görünümlü ama dikdörtgen içerikli butonlarda kullanışlıdır.
Tuzak 5: s-number-field Daraltılamaz
Shopify'ın s-number-field component'i controls="stepper" özelliğiyle gerçekten Polaris kalitesinde görünür: tek bordered kutu içinde − ve + kontrolleri, klavye desteği, tüm erişilebilirlik özellikleri:
<s-number-field
label="Adet"
labelAccessibilityVisibility="exclusive"
controls="stepper"
min={1}
step={1}
value={String(quantity)}
onChange={handleChange}
/>Sorun: bu component'in minimum inline genişliği var ve daraltılamıyor. inlineSize="fit-content" prop'u s-number-field'da çalışmaz. 64px thumbnail ve ürün başlığıyla aynı satırda kullandığınızda kontrol, satırın tamamına yayılır ve layout bozulur.
Thumbnail + başlık + miktar kontrolünün aynı satırda olduğu layout için özel stepper kullanın:
<s-grid gap="small-100" gridTemplateColumns="auto auto auto" alignItems="center">
<StepperButton icon="minus" onClick={onDecrement} ... />
<s-text type="strong">{String(quantity)}</s-text>
<StepperButton icon="plus" onClick={onIncrement} ... />
</s-grid>s-number-field, quantity input'unun kendi satırını aldığı geniş arayüzlerde kullanışlıdır. Satırı paylaşması gereken her yerleşimde özel stepper tercih edin.
Tuzak 6: Async Mutasyon Yarış Koşulu ve Global busyId
applyCartLinesChange async bir işlemdir. Müşteri + butonuna hızlıca iki kez tıkladığında iki updateCartLine çağrısı birbirinin üzerine biner ve sonuç yanlış adet olabilir. Şerit boyunca birden fazla ürüne arka arkaya tıklandığında response'lar araya girebilir.
Nodus Works'ün uyguladığı pattern: component'in tepesinde tek bir busyId state'i. Sadece variantId tutar hangi ürün üzerinde işlem yapıldığını.
const [busyId, setBusyId] = useState(null);
async function runMutation(variantId, change) {
setBusyId(variantId);
setErrorMessage("");
try {
const result = await applyCartLinesChange(change);
if (result?.type === "error") setErrorMessage(result.message);
} catch {
setErrorMessage("Sepet güncellenemedi. Lütfen tekrar deneyin.");
} finally {
setBusyId(null);
}
}Her offer card iki prop alır:
const anyBusy = busyId !== null;
offers.map(({ product, variant }) => (
<OfferRow
isBusy={busyId === variant.id}
disabledOthers={anyBusy && busyId !== variant.id}
...
/>
));isBusy olan satırın CTA'sında loading={true} gösterilir. Diğer tüm satırlar disabled olur. Müşteri hangi satırın işlem yaptığını görür ve aynı anda iki mutasyon fire edilemez.
"Tek buton var, gerek yok" gibi bir yargıyla bu pattern atlanmamalı. İkinci bir buton stok uyarısı, kampanya butonu, bundle önerisi bir hafta içinde eklenebilir ve yarış koşulu o noktada kaynağa izlenmesi güç bir bug olarak geri döner.
Koleksiyon Sorgusunu Doğru Yazmak
Storefront API sorgusu shopify.query üzerinden yapılır ve api_access = true gerektirir. Koleksiyon handle ve maksimum ürün sayısı merchant settings'ten okunur:
const PRODUCTS_QUERY = `
query CheckoutUpsell($handle: String!, $first: Int!) {
collection(handle: $handle) {
products(first: $first, sortKey: BEST_SELLING) {
nodes {
id title availableForSale
featuredImage { url altText }
variants(first: 1) {
nodes {
id availableForSale
price { amount currencyCode }
}
}
}
}
}
}
`;sortKey: BEST_SELLING koleksiyonun en çok satılanlarını üstte getirir. Koleksiyon Shopify admin'de "el ile sıralanmış" olarak ayarlanmışsa bu sort key'i değiştirebilirsiniz.
Settings yapılandırması shopify.extension.toml'da şöyle tanımlanır:
[settings]
[[settings.fields]]
key = "collection_handle"
type = "single_line_text_field"
name = "Koleksiyon handle"
description = "Upsell ürünleri çekilecek koleksiyonun handle'ı."
[[settings.fields]]
key = "max_products"
type = "number_integer"
name = "Maksimum ürün sayısı"
description = "1-12 arasında bir değer girin."Merchant bu ayarları Shopify checkout editöründen, kod değiştirmeden günceller.
Tam Extension Kodu
Aşağıdaki Checkout.jsx, yukarıdaki altı tuzağın tamamını çözmüş halidir:
/** @jsxImportSource preact */
import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { useEffect, useMemo, useState } from "preact/hooks";
const PRODUCTS_QUERY = `
query CheckoutUpsell($handle: String!, $first: Int!) {
collection(handle: $handle) {
products(first: $first, sortKey: BEST_SELLING) {
nodes {
id title availableForSale
featuredImage { url altText }
variants(first: 1) {
nodes { id availableForSale price { amount currencyCode } }
}
}
}
}
}
`;
export default function () {
render(<Extension />, document.body);
}
function Extension() {
const { applyCartLinesChange, query, i18n, lines, settings } = shopify;
const config = settings?.current || {};
const collectionHandle = (config.collection_handle || "upsell").toString();
const maxProducts = Math.min(Math.max(Number(config.max_products) || 6, 1), 12);
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState(null);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
try {
const result = await query(PRODUCTS_QUERY, {
variables: { handle: collectionHandle, first: maxProducts },
});
if (!cancelled)
setProducts(result?.data?.collection?.products?.nodes ?? []);
} catch {
if (!cancelled)
setErrorMessage("Ürünler yüklenemedi.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [collectionHandle, maxProducts]);
const cartLines = lines?.value || [];
const lineByVariant = useMemo(() => {
const m = new Map();
for (const l of cartLines) m.set(l.merchandise.id, l);
return m;
}, [cartLines]);
const offers = useMemo(
() =>
products
.map((p) => ({ product: p, variant: p?.variants?.nodes?.[0] }))
.filter(
({ product, variant }) =>
product?.availableForSale !== false &&
variant?.availableForSale !== false,
),
[products],
);
async function runMutation(variantId, change) {
setBusyId(variantId);
setErrorMessage("");
try {
const result = await applyCartLinesChange(change);
if (result?.type === "error") setErrorMessage(result.message);
} catch {
setErrorMessage("Sepet güncellenemedi. Lütfen tekrar deneyin.");
} finally {
setBusyId(null);
}
}
if (loading) return <s-skeleton-text />;
if (offers.length === 0) return null;
const anyBusy = busyId !== null;
return (
<s-stack gap="large-200">
<s-divider />
<s-text type="strong">Siparişinize ekleyin</s-text>
{errorMessage ? (
<s-banner tone="critical">{errorMessage}</s-banner>
) : null}
<s-stack gap="base">
{offers.map(({ product, variant }) => {
const line = lineByVariant.get(variant.id) || null;
return (
<OfferRow
key={variant.id}
product={product}
variant={variant}
line={line}
isBusy={busyId === variant.id}
anyBusy={anyBusy}
i18n={i18n}
onAdd={() =>
runMutation(variant.id, {
type: "addCartLine",
merchandiseId: variant.id,
quantity: 1,
})
}
onSetQuantity={(next) => {
if (!line) return;
if (next <= 0) {
return runMutation(variant.id, {
type: "removeCartLine",
id: line.id,
quantity: line.quantity,
});
}
if (next === line.quantity) return;
return runMutation(variant.id, {
type: "updateCartLine",
id: line.id,
quantity: next,
});
}}
/>
);
})}
</s-stack>
</s-stack>
);
}
function OfferRow({ product, variant, line, isBusy, anyBusy, i18n, onAdd, onSetQuantity }) {
const price = variant?.price;
const formattedPrice = price?.amount
? i18n.formatCurrency(Number(price.amount), { currency: price.currencyCode })
: "";
const inCartQty = line?.quantity || 0;
const disabledOthers = anyBusy && !isBusy;
return (
<s-grid gap="base" gridTemplateColumns="64px 1fr auto" alignItems="center">
<s-image
borderWidth="base"
borderRadius="large-100"
src={product.featuredImage?.url}
alt={product.featuredImage?.altText || product.title}
aspectRatio="1"
/>
<s-stack gap="none">
<s-text type="strong">{product.title}</s-text>
{formattedPrice ? (
<s-text color="subdued">{formattedPrice}</s-text>
) : null}
</s-stack>
{line ? (
<QuantityActions
quantity={inCartQty}
isBusy={isBusy}
disabled={disabledOthers}
onIncrement={() => onSetQuantity(inCartQty + 1)}
onDecrement={() => onSetQuantity(inCartQty - 1)}
onRemove={() => onSetQuantity(0)}
/>
) : (
<s-button
variant="secondary"
loading={isBusy}
disabled={disabledOthers}
onClick={onAdd}
accessibilityLabel={`${product.title} ekle`}
>
Ekle
</s-button>
)}
</s-grid>
);
}
function QuantityActions({ quantity, isBusy, disabled, onIncrement, onDecrement, onRemove }) {
const stepDisabled = isBusy || disabled;
return (
<s-stack gap="small-100" alignItems="end">
<s-grid gap="small-100" gridTemplateColumns="auto auto auto" alignItems="center">
<StepperButton
icon="minus"
onClick={onDecrement}
disabled={stepDisabled}
accessibilityLabel="Adeti azalt"
/>
<s-text type="strong">{String(quantity)}</s-text>
<StepperButton
icon="plus"
onClick={onIncrement}
disabled={stepDisabled}
accessibilityLabel="Adeti artır"
/>
</s-grid>
<s-clickable
onClick={onRemove}
disabled={stepDisabled}
accessibilityLabel="Sepetten kaldır"
>
<s-text tone="critical">Kaldır</s-text>
</s-clickable>
</s-stack>
);
}
function StepperButton({ icon, onClick, disabled, accessibilityLabel }) {
return (
<s-clickable
onClick={onClick}
disabled={disabled}
accessibilityLabel={accessibilityLabel}
border="base base"
borderRadius="small"
paddingBlock="small-200"
paddingInline="small-300"
background="base"
>
<s-icon type={icon} size="small" />
</s-clickable>
);
}Deployment ve Merchant Kurulumu
Extension'ı deploy etmek için
shopify app deployDeploy tamamlandıktan sonra merchant şu adımları izler:
- Shopify admin → Online Store → Themes → Customize
- Checkout sayfasını seç
- Sol paneldeki "App blocks" bölümünde "Checkout Upsell"i bul
- "Order summary" bölümüne sürükle, "Cost summary"nin altına yerleştir
- "Collection handle" alanına upsell koleksiyonunun handle'ını gir
- Kaydet
Koleksiyon handle, Shopify admin → Products → Collections → [Koleksiyon] → URL'nin son kısmından okunur.
Sıkça Sorulan Sorular
Checkout UI Extension Shopify Plus olmadan çalışır mı? Hayır. Checkout'a extension enjekte etmek yalnızca Shopify Plus ve Shopify Partner geliştirme mağazalarında mümkündür. Standart planlarda checkout kodu değiştirilemez; bu Shopify'ın bilinçli bir güvenlik kısıtlamasıdır.
Birden fazla koleksiyondan ürün gösterebilir miyim? Evet. settings.fields'a ikinci bir collection_handle alanı ekleyebilir, iki ayrı sorgu yapabilir ve sonuçları birleştirebilirsiniz. Ancak iki ayrı API çağrısı yaptığınız için useEffect dependency array'ini ve loading state'ini buna göre düzenlemeniz gerekir.
Storefront API çağrıları ücretli mi? Storefront API, Shopify Plus fiyatlandırmasına dahildir. Checkout extension başına ek API maliyet yoktur. Ancak aşırı yüksek rate limit ihlali (dakikada binlerce istek) throttle tetikleyebilir. maxProducts değerini 12'nin altında tutmak yeterlidir.
Extension Analytics'te görünmüyor, neden? Checkout extension'ların purchase.checkout.block.render hedefini kullanması için merchant'ın editörde App block'u aktif etmiş olması gerekir. Sadece deploy etmek yetmez; editörde yerleştirilmeden extension render olmaz ve Analytics'te görünmez.
s-button'da neden onClick çalışmıyor? Checkout surface'inde s-button'un onClick prop'u buttonProps değil doğrudan prop olarak yazılır. Ayrıca disabled={true} olan butonlarda onClick tetiklenmez bu beklenen davranıştır. disabledOthers state'ini kontrol edin.
Upsell ürünü sepete eklendi ama anlık yansımıyor gibi görünüyor? lines?.value Shopify'ın cart state reaktivitesine bağlıdır. useMemo bağımlılık array'indeki cartLines değeri güncellenince lineByVariant map'i yeniden hesaplanır. Gecikme varsa Shopify checkout'un kendi cart state güncelleme döngüsünden kaynaklanır; extension tarafında yapılacak bir şey yoktur.
Türkçe fiyat formatı için ne yapmalıyım? i18n.formatCurrency(amount, { currency: "TRY" }) kullanabilirsiniz. Ancak para birimi mağaza ayarlarından gelir; price.currencyCode değerini sabit "TRY" ile değiştirmek çok dövizli mağazalarda sorun yaratır. price.currencyCode değerini kullanmak daha güvenlidir.
Kontrol Listesi: Deploy Öncesi
Her yeni checkout extension deploy'undan önce şu yedi noktayı kontrol etmek, ilk deploy'da çalışan kod yazmanın kısayoludur:
- shopify.extension.toml'da api_access = true var mı?
- Target olarak purchase.checkout.block.render kullanıldı mı?
- Tüm çok-kelimeli JSX prop'ları camelCase mi?
- s-button kullanıldığı yerlerde icon prop'u yazılmadı mı?
- Kompakt satır layout'larında s-number-field yerine özel stepper kullanılıyor mu?
- Tüm cart mutasyonları tek busyId üzerinden mi geçiyor?
- border değerleri renk içeriyor mu ("base base" gibi)?
Bu liste, Nodus Works'ün Shopify Plus mağazalarında checkout extension geliştirirken biriken 18 deploy deneyiminin distile edilmiş halidir. Listedeki her madde en az bir boşa giden deploy'a karşılık gelir.
Shopify Plus checkout özelleştirme projenizi planlarken Nodus Works teknik ekibiyle görüşmek için Shopify Plus çözümleri sayfamızı inceleyebilirsiniz.




