Blog-Inhalte
May 30, 2026

Shopify Plus Checkout Extension: Kollektionsbasierte Produktempfehlung von Grund auf aufbauen

Die Empfehlung von Produkten einer bestimmten Kollektion unter der Bestellübersicht im Shopify Plus Checkout wird als Checkout UI Extension bezeichnet. Die Installationsdokumentation umfasst über 80 Seiten, aber diese Dokumente verraten nicht, wo die eigentliche Herausforderung liegt. Dieser Artikel beschreibt 6 Fallstricke, die bei 18 Deployments während einer echten Implementierung gelernt wurden, komplett mit Codebeispielen.

Eine Checkout UI Extension ist die Entwicklungsschnittstelle von Shopify, mit der eine App eigene Oberflächenkomponenten in den Checkout einfügt. Extensions in den Schritten Information, Versand und Zahlung funktionieren nur in Shopify Plus und Partner-Entwicklungsshops; Extensions auf der Dankeseite und der Bestellstatusseite laufen in allen Tarifen außer Starter. Dieser Leitfaden baut mit Preact und Storefront API eine Extension für kollektionsbasierte Produktempfehlungen unter der Bestellübersicht.

Wer in Shopify Plus unter der Bestellübersicht im Checkout Produkte aus einer bestimmten Kollektion empfehlen will, braucht dafür eine Checkout UI Extension. Die Dokumentation zur Einrichtung umfasst über 80 Seiten, verrät aber nicht, wo die eigentliche Schwierigkeit liegt. Dieser Artikel beschreibt 6 Fallstricke, die wir bei einer echten Implementierung über 18 Deployments hinweg kennengelernt haben, jeweils mit vollständigen Codebeispielen.

Am Ende haben Sie eine funktionierende Extension: Sie lädt direkt unter der Summenzeile der Bestellübersicht dynamisch Produkte aus einer Storefront-Kollektion, wechselt zwischen „Hinzufügen“ und einem Mengen-Stepper und ist gegen Race Conditions bei Doppelklicks geschützt. Welche Kollektion verwendet wird, legt der Händler im Checkout-Editor von Shopify fest, ohne den Code zu ändern.

Was wir bauen werden

Die Checkout-Upsell-Extension nutzt eine API-Oberfläche, die Shopify Plus vorbehalten ist: Checkout UI Extensions. In den Standardtarifen von Shopify lässt sich kein Code in den Checkout einschleusen. Die Entwicklung von Extensions bleibt daher auf Shopify Plus oder die Shopify Developers Preview beschränkt.

Die Komponente, die wir bauen werden, sieht so aus:

+------------------------------------------------------------+
| Das könnte Ihnen auch gefallen |
| |
| [ 64px ] Produkttitel [ Hinzufügen ] |
| [ img ] 24,90 € |
| |
| [ 64px ] Produkttitel [ - ] 2 [ + ] |
| [ img ] 18,90 € |
+------------------------------------------------------------+

Drei Spalten: ein 64 Pixel großes Thumbnail, ein flexibler Block für Titel und Preis sowie ein „Hinzufügen“-Button für Produkte, die noch nicht im Warenkorb liegen, bzw. ein Mengen-Stepper für Produkte im Warenkorb. Stack: Preact + JSX, Shopify-Checkout-Webkomponenten (<s-grid>, <s-stack>, <s-button>), Storefront API über shopify.query. Kein externes CSS, keine externe State-Bibliothek. ~330 Zeilen, eine Datei.

Anforderungen und Einrichtung

Um eine Checkout UI Extension zu entwickeln:

  • Shopify Plus oder Partner-Entwicklungs-Shop
  • Shopify CLI (npm install -g @shopify/cli)
  • Node.js 18+

Projekt-Grundgerüst:

shopify app generate extension --template checkout_ui --name checkout-upsell

Dieser Befehl erstellt shopify.extension.toml und src/Checkout.jsx. Die anfängliche Konfiguration:

# uid, handle und name erzeugt die Shopify CLI
api_version = "2026-07"

[[extensions]]
name = "Checkout Upsell"
handle = "checkout-upsell"
type = "ui_extension"
uid = ""

[[extensions.targeting]]
target = "purchase.checkout.block.render"
module = "./src/Checkout.jsx"

# Pflicht für Aufrufe der Storefront API (shopify.query)
[extensions.capabilities]
api_access = true
Kritisch: Ohne die Zeile api_access = true schlägt jeder Aufruf der Storefront API stillschweigend fehl. Diesen Schritt übersieht man bei der Einrichtung am leichtesten, und er sorgt am längsten für Rätselraten.

Falle 1: Die api_access-Capability steht zweimal in der Dokumentation

Wenn Sie den ersten Aufruf an die Storefront API mit shopify.query(...) tätigen, sehen Sie in der Browserkonsole diesen Fehler:

Uncaught (in promise) TypeError: shopify.query is not a function

Im Shopify Partners Dashboard gibt es einen Schalter namens „Network access“. Er ist für Anfragen an externe URLs gedacht; für die Storefront API brauchen Sie eine andere Capability. Die beiden sind nicht austauschbar.

Was ohne Fehlermeldung passiert: useEffect schlägt stillschweigend fehl, die Extension wird mit null Produkten gerendert, und auf dem Bildschirm erscheint kein Fehler. Das einzige Signal ist die Meldung in der Konsole; in der Oberfläche sieht alles intakt aus.

Die Lösung besteht darin, eine einzige Zeile zu shopify.extension.toml hinzuzufügen:

# Capability für die Storefront API (shopify.query)
[extensions.capabilities]
api_access = true
# network_access gilt nur für Anfragen an externe URLs; für die Storefront API ist es nicht nötig

Diese Zeile schaltet den Aufruf shopify.query und damit alle GraphQL-Abfragen an die Storefront API frei. Wenn Sie eine neue Extension anlegen, fügen Sie zuerst diese Zeile hinzu und beginnen erst dann mit dem Programmieren.

Falle 2: Die Wahl des Render-Ziels beeinflusst die Conversion Rate direkt

Checkout-Extensions erhalten keine Pixelkoordinaten; sie werden in vordefinierte Ziele im DOM eingefügt. Im Bereich der Bestellübersicht stehen folgende Ziele zur Verfügung:

Target Rendering-Position Für Upsell geeignet?
purchase.checkout.cart-line-list.render-after Unterhalb der Produktliste, vor der Summenzeile Nein - Verschiebt die Summenzeile an das untere Bildschirmende.
purchase.checkout.reductions.render-before Oberhalb des Rabattcode-Feldes Nein - Falscher Kontext, vermischt sich mit dem Rabattbereich.
purchase.checkout.reductions.render-after Unterhalb des Rabattcode-Feldes Teilweise - Der Gesamtbetrag bleibt sichtbar, aber die Position auf der Seite ist variabel.
purchase.checkout.block.render Dynamische Position, die der Händler im Checkout-Editor festlegt Ja - Bietet volle Kontrolle, kann genau unterhalb des Summenbereichs platziert werden.

Die meisten Shopify-Tutorials verwenden cart-line-list.render-after. In der Praxis schafft dieses Ziel ein Problem: Der Upsell-Streifen schiebt die Bestellsumme aus dem sichtbaren Bereich. Der Kunde will sehen, was er bezahlt, bevor er auf „Hinzufügen“ klickt; steht die Summe am unteren Bildschirmrand, reißt dieser Zusammenhang ab.

purchase.checkout.block.render stellt die Extension im Checkout-Editor als eigenständigen App-Block bereit. Der Händler zieht diesen Block aus dem linken Bereich und legt ihn unter der „Kostenübersicht“ ab. Die Einrichtung ist ein einziger Drag-and-Drop-Schritt; dafür erhalten Sie volle Kontrolle über die Platzierung und bleiben bei späteren Änderungen flexibel.

In shopify.extension.toml wird das Targeting wie folgt definiert:

[[extensions.targeting]]
target = "purchase.checkout.block.render"
module = "./src/Checkout.jsx"
# Empfohlene Erstplatzierung im Editor: unter den Summenzeilen der Bestellübersicht.
# Auf Mobilgeräten erscheint diese Platzierung unter dem Bezahlen-Button; der Händler kann den Block im Editor verschieben.
default_placement = "ORDER_SUMMARY4"

Falle 3: camelCase-Pflicht (stiller Fehler)

Shopify-Checkout-Extensions liefern benutzerdefinierte Elemente: <s-grid>, <s-stack>, <s-button>, <s-image>. In Preact JSX sieht es so aus, als könnten Sie die Attribute dieser Elemente in kebab-case oder in camelCase schreiben. Es funktioniert aber nur camelCase.

Der @shopify/ui-extensions/preact-Registrator bindet JSX-Prop-Namen direkt an die Property-Setter der Komponente. Die Setter reagieren nur auf camelCase-Namen; kebab-case-Attribute werden nicht gelesen und die Komponente fällt auf ihren Standardwert zurück.

// Falsch: kebab-case-Props werden nicht gelesen, die Komponente fällt auf den Standardwert zurück


{/* Titel + Preis, Button */}


// Richtig: camelCase-Props werden direkt an den Property-Setter gebunden


{/* Titel + Preis, Button */}

Keine Fehlermeldung. Auf dem Bildschirm wird nur ein falsches Layout angezeigt. Betroffene Props: gridTemplateColumns, alignItems, inlineSize, borderRadius, paddingBlock, paddingInline, labelAccessibilityVisibility, accessibilityLabel. Wenn eine Checkout-Komponente „ihre Styles nicht übernimmt“, prüfen Sie zuerst diesen Punkt.

Faustregel: Prüfen Sie bei jeder neuen <s-Komponente zuerst, ob Sie die Props in camelCase geschrieben haben. Bei Props aus einem Wort (gap, border, background) gibt es keinen Unterschied; für jedes Prop aus zwei Wörtern gilt diese Regel.

Falle 4: s-button unterstützt die icon-Prop auf der Checkout-Oberfläche nicht

Für kompakte −/+-Stepper-Buttons liegt es nahe, in s-button icon="minus" und icon="plus" zu schreiben. Das Ergebnis ist ein leeres, umrandetes Quadrat.

Der s-button erbt auf der Checkout-Oberfläche nur bestimmte Props vom Basistyp:

// @shopify/ui-extensions, surfaces/checkout/components/Button.d.ts (2026-07)
// icon ist im Basistyp (ButtonProps$1) definiert, fehlt aber in dieser Liste
export interface ButtonElementProps
extends Pick<
ButtonProps$1,
| 'accessibilityLabel'
| 'command'
| 'commandFor'
| 'disabled'
| 'href'
| 'id'
| 'inlineSize'
| 'interestFor'
| 'loading'
| 'target'
| 'tone'
| 'type'
| 'variant'
> {
// tone, type, variant und target werden hier mit eingeschränkten Typen neu deklariert
}

// Als Event ist nur onClick freigegeben
export interface ButtonEvents extends Pick {}

`icon` ist in diesem `Pick<>` nicht enthalten. Sie schreiben es in JSX, die Komponente liest es nicht, und nichts wird gerendert. Dieses Muster gilt für viele Komponenten auf der Checkout-Oberfläche: Props, die im Basistyp vorhanden, auf Elementebene aber nicht freigegeben sind, werden stillschweigend verschluckt.

Die Lösung besteht darin, den Stepper-Button mit s-clickable + s-icon zu erstellen:

function StepButton({icon, label, disabled, onClick}) {
return (
onClick={onClick}
disabled={disabled}
accessibilityLabel={label}
// border="base base": Stärke + Farbe. Nur "base" zeichnet einen unsichtbaren Rahmen
border="base base"
borderRadius="base"
paddingBlock="small-200"
paddingInline="small-100"
>


);
}


Zwei weitere Details:

In `border="base base"` ist das zweite base der Farbwert. Schreiben Sie nur `border="base"`, wird nur die Stärke gesetzt: Gerendert wird ein transparenter 1px-Rahmen, sichtbar ist nichts. Ein Farbwert muss also unbedingt dazu.

`paddingBlock` und `paddingInline` steuern die vertikale und die horizontale Achse unabhängig voneinander. Das ist praktisch für Buttons, die quadratisch aussehen, aber rechteckigen Inhalt haben.

Falle 5: s-number-field lässt sich nicht schmaler machen

Die s-number-field-Komponente von Shopify sieht mit der Eigenschaft `controls="stepper"` wirklich nach Polaris-Qualität aus: − und + Steuerelemente in einem einzelnen umrandeten Feld, Tastaturunterstützung, alle Barrierefreiheitsfunktionen:

  label="Menge"
labelAccessibilityVisibility="exclusive"
controls="stepper"
value={String(line.quantity)}
min={0}
max={99}
step={1}
onChange={(event) => onQuantityChange(Number(event.currentTarget.value))}
/>
{/* inlineSize ist bei diesem Element nicht freigegeben; das Feld füllt die ganze Zeile */}

Das Problem: Diese Komponente hat eine minimale Inline-Breite und lässt sich nicht schmaler machen. Die Prop `inlineSize="fit-content"` greift bei s-number-field nicht. Steht die Komponente in derselben Zeile wie ein 64px-Thumbnail und der Produkttitel, dehnt sich das Steuerelement über die ganze Zeile aus und zerstört das Layout.

Ansatz Aussehen Funktioniert in kompakter Zeile?
s-number-field controls="stepper" Natives Polaris, verbundenes Input-Feld + Buttons Nein - Nicht reduzierbar, Layout bricht um.
Benutzerdefinierter Stepper mit s-clickable + s-icon Drei separate Elemente, keine verbundene Input-Optik Ja - Bietet volle Kontrolle, funktioniert problemlos im kompakten Layout.

Verwenden Sie für Layouts, bei denen Thumbnail + Titel + Mengensteuerung in derselben Zeile sind, einen benutzerdefinierten Stepper:

// 64px-Thumbnail | flexibler Titel + Preis | Stepper



{offer.title}
{price}

quantity={line.quantity}
isBusy={isBusy}
disabled={disabledOthers}
onDecrease={() => onQuantityChange(line.quantity - 1)}
onIncrease={() => onQuantityChange(line.quantity + 1)}
/>


function QuantityStepper({quantity, isBusy, disabled, onDecrease, onIncrease}) {
const locked = isBusy || disabled;
return (


{isBusy ? (

) : (
{quantity}
)}


);
}

`s-number-field` eignet sich für breite Oberflächen, in denen die Mengeneingabe eine eigene Zeile bekommt. Überall, wo sie sich eine Zeile teilen muss, ist ein eigener Stepper die bessere Wahl.

Falle 6: Asynchrone Mutations-Race-Condition und globale busyId

`applyCartLinesChange` ist ein asynchroner Vorgang. Klickt der Kunde schnell zweimal auf +, überschneiden sich zwei `updateCartLine`-Aufrufe, und am Ende kann eine falsche Menge stehen. Werden im Streifen mehrere Produkte kurz nacheinander angeklickt, können sich die Antworten gegenseitig in die Quere kommen.

Das Muster, das Nodus Works einsetzt: ein einziger `busyId`-State ganz oben in der Komponente. Er speichert nur die `variantId` des Produkts, das gerade verarbeitet wird.

// Eine Sperre für alle Zeilen im Streifen: die gerade verarbeitete variantId oder null
const [busyId, setBusyId] = useState(null);
// Ein zweiter Klick im selben Frame scheitert am Ref, bevor der State aktualisiert ist
const busyRef = useRef(null);

async function runCartChange(variantId, change) {
if (busyRef.current) return;
busyRef.current = variantId;
setBusyId(variantId);
try {
const result = await shopify.applyCartLinesChange(change);
// result.message wird dem Kunden nicht angezeigt; nur zur Fehlersuche
if (result.type === 'error') console.warn(result.message);
} catch (error) {
console.error(error);
} finally {
busyRef.current = null;
setBusyId(null);
}
}

Jede Angebotskarte nimmt zwei Props entgegen:

  key={offer.variantId}
offer={offer}
line={lineByVariant.get(offer.variantId)}
// Diese Zeile wird verarbeitet: der CTA zeigt loading
isBusy={busyId === offer.variantId}
// Eine andere Zeile wird verarbeitet: diese Zeile ist deaktiviert
disabledOthers={busyId !== null && busyId !== offer.variantId}
onAdd={() => addOffer(offer)}
onQuantityChange={(next) => changeQuantity(offer, next)}
/>

In der Zeile mit `isBusy` zeigt der CTA `loading={true}`. Alle anderen Zeilen sind deaktiviert. Der Kunde sieht, welche Zeile gerade verarbeitet wird, und es lassen sich nicht zwei Mutationen gleichzeitig auslösen.

Überspringen Sie dieses Muster nicht mit dem Gedanken „Es gibt nur einen Button, das brauche ich nicht“. Ein zweiter Button für einen Bestandshinweis, ein Kampagnen-Button oder ein Bundle-Vorschlag kann schon in einer Woche dazukommen, und dann kehrt die Race Condition als Fehler zurück, dessen Ursache schwer zu finden ist.

Die Kollektionsabfrage richtig schreiben

Die Abfrage an die Storefront API läuft über `shopify.query` und erfordert `api_access = true`. Kollektions-Handle und maximale Produktanzahl werden aus den Händlereinstellungen gelesen:

const COLLECTION_QUERY = `
query UpsellCollection($handle: String!, $first: Int!, $country: CountryCode)
@inContext(country: $country) {
collection(handle: $handle) {
products(first: $first, sortKey: BEST_SELLING) {
nodes {
id
title
featuredImage {
url
altText
}
variants(first: 1) {
nodes {
id
availableForSale
image {
url
altText
}
price {
amount
currencyCode
}
}
}
}
}
}
}
`;

// Der Händler setzt diese Werte im Checkout-Editor; ohne Wert gilt der Standard
const settings = shopify.settings.value;
const collectionHandle = String(settings.collection_handle ?? '').trim();
const maxProducts = clamp(
Number(settings.max_products) || DEFAULT_MAX_PRODUCTS,
1,
MAX_PRODUCTS_LIMIT,
);
// Preise kommen in der Währung des Landes des Kunden zurück
const country = shopify.localization.country.value?.isoCode ?? null;

useEffect(() => {
if (!collectionHandle) {
setOffers([]);
setLoading(false);
return undefined;
}

let cancelled = false;

async function loadOffers() {
setLoading(true);
try {
const {data, errors} = await shopify.query(COLLECTION_QUERY, {
variables: {handle: collectionHandle, first: maxProducts, country},
});
if (errors?.length) console.warn('Storefront API', errors);
if (!cancelled) setOffers(toOffers(data));
} catch (error) {
// Ohne api_access landet der Fehler hier und erscheint nur in der Konsole
console.error(error);
if (!cancelled) setOffers([]);
} finally {
if (!cancelled) setLoading(false);
}
}

loadOffers();
return () => {
cancelled = true;
};
}, [collectionHandle, maxProducts, country]);

sortKey: BEST_SELLING bringt die meistverkauften Artikel der Kollektion nach oben. Wenn die Kollektion im Shopify-Adminbereich als "manuell sortiert" eingestellt ist, können Sie diesen Sortierschlüssel ändern.

Die Einstellungen legen Sie in shopify.extension.toml so an:

[extensions.settings]
[[extensions.settings.fields]]
key = "collection_handle"
type = "single_line_text_field"
name = "Collection handle"
description = "Handle der Kollektion, aus der die empfohlenen Produkte stammen"

[[extensions.settings.fields]]
key = "max_products"
type = "number_integer"
name = "Maximale Produktanzahl"
description = "Anzahl der Produkte im Streifen (1-12, ohne Angabe 4)"

[[extensions.settings.fields.validations]]
name = "min"
value = "1"

[[extensions.settings.fields.validations]]
name = "max"
value = "12"

Der Händler aktualisiert diese Einstellungen über den Shopify Checkout-Editor, ohne Code ändern zu müssen.

Vollständiger Code der Extension

Die folgende Checkout.jsx-Datei löst alle sechs oben genannten Fallstricke:

import '@shopify/ui-extensions/preact';
import {render} from 'preact';
import {useEffect, useMemo, useRef, useState} from 'preact/hooks';

// Sichtbare Texte; in mehrsprachigen Shops locales/*.json + shopify.i18n.translate verwenden
const TEXT = {
heading: 'Das könnte Ihnen auch gefallen',
add: 'Hinzufügen',
addLabel: 'in den Warenkorb legen',
decrease: 'Menge verringern',
increase: 'Menge erhöhen',
updating: 'Warenkorb wird aktualisiert',
};

const DEFAULT_MAX_PRODUCTS = 4;
const MAX_PRODUCTS_LIMIT = 12;

const COLLECTION_QUERY = `
query UpsellCollection($handle: String!, $first: Int!, $country: CountryCode)
@inContext(country: $country) {
collection(handle: $handle) {
products(first: $first, sortKey: BEST_SELLING) {
nodes {
id
title
featuredImage {
url
altText
}
variants(first: 1) {
nodes {
id
availableForSale
image {
url
altText
}
price {
amount
currencyCode
}
}
}
}
}
}
}
`;

export default function extension() {
render(, document.body);
}

function Extension() {
// Der Händler setzt diese Werte im Checkout-Editor; ohne Wert gilt der Standard
const settings = shopify.settings.value;
const collectionHandle = String(settings.collection_handle ?? '').trim();
const maxProducts = clamp(
Number(settings.max_products) || DEFAULT_MAX_PRODUCTS,
1,
MAX_PRODUCTS_LIMIT,
);
// Preise kommen in der Währung des Landes des Kunden zurück
const country = shopify.localization.country.value?.isoCode ?? null;

// lines ist ein Signal: die Komponente rendert neu, wenn sich der Warenkorb ändert
const cartLines = shopify.lines?.value ?? [];
const lineInstructions = shopify.instructions.value.lines;

const [offers, setOffers] = useState([]);
const [loading, setLoading] = useState(true);
// Eine Sperre für alle Zeilen im Streifen: die gerade verarbeitete variantId oder null
const [busyId, setBusyId] = useState(null);
// Ein zweiter Klick im selben Frame scheitert am Ref, bevor der State aktualisiert ist
const busyRef = useRef(null);

useEffect(() => {
if (!collectionHandle) {
setOffers([]);
setLoading(false);
return undefined;
}

let cancelled = false;

async function loadOffers() {
setLoading(true);
try {
const {data, errors} = await shopify.query(COLLECTION_QUERY, {
variables: {handle: collectionHandle, first: maxProducts, country},
});
if (errors?.length) console.warn('Storefront API', errors);
if (!cancelled) setOffers(toOffers(data));
} catch (error) {
// Ohne api_access landet der Fehler hier und erscheint nur in der Konsole
console.error(error);
if (!cancelled) setOffers([]);
} finally {
if (!cancelled) setLoading(false);
}
}

loadOffers();
return () => {
cancelled = true;
};
}, [collectionHandle, maxProducts, country]);

const lineByVariant = useMemo(() => {
const map = new Map();
for (const line of cartLines) map.set(line.merchandise.id, line);
return map;
}, [cartLines]);

async function runCartChange(variantId, change) {
if (busyRef.current) return;
busyRef.current = variantId;
setBusyId(variantId);
try {
const result = await shopify.applyCartLinesChange(change);
// result.message wird dem Kunden nicht angezeigt; nur zur Fehlersuche
if (result.type === 'error') console.warn(result.message);
} catch (error) {
console.error(error);
} finally {
busyRef.current = null;
setBusyId(null);
}
}

function addOffer(offer) {
return runCartChange(offer.variantId, {
type: 'addCartLine',
merchandiseId: offer.variantId,
quantity: 1,
});
}

function changeQuantity(offer, nextQuantity) {
// Zeilen-IDs sind nicht stabil; direkt vor der Mutation aus den aktuellen lines lesen
const line = shopify.lines.value.find(
(item) => item.merchandise.id === offer.variantId,
);
if (!line) return undefined;

if (nextQuantity <= 0) {
return runCartChange(offer.variantId, {
type: 'removeCartLine',
id: line.id,
quantity: line.quantity,
});
}

return runCartChange(offer.variantId, {
type: 'updateCartLine',
id: line.id,
quantity: nextQuantity,
});
}

// Im Editor sind die Settings leer, bis der Händler sie ausfüllt: nichts rendern
if (!collectionHandle) return null;
if (loading) return ;
if (offers.length === 0) return null;

return (


{offers.map((offer) => (
key={offer.variantId}
offer={offer}
line={lineByVariant.get(offer.variantId)}
isBusy={busyId === offer.variantId}
disabledOthers={busyId !== null && busyId !== offer.variantId}
canAdd={lineInstructions.canAddCartLine}
canUpdate={lineInstructions.canUpdateCartLine}
canRemove={lineInstructions.canRemoveCartLine}
onAdd={() => addOffer(offer)}
onQuantityChange={(next) => changeQuantity(offer, next)}
/>
))}


);
}

function OfferCard({
offer,
line,
isBusy,
disabledOthers,
canAdd,
canUpdate,
canRemove,
onAdd,
onQuantityChange,
}) {
const price = shopify.i18n.formatCurrency(Number(offer.price.amount), {
currency: offer.price.currencyCode,
});

return (

{offer.image ? (
src={offer.image.url}
alt={offer.image.altText || offer.title}
aspectRatio="1"
objectFit="cover"
borderRadius="base"
inlineSize="fill"
loading="lazy"
/>
) : (

)}

{offer.title}
{price}

{line ? (
quantity={line.quantity}
isBusy={isBusy}
disabled={disabledOthers}
canDecrease={line.quantity > 1 ? canUpdate : canRemove}
canIncrease={canUpdate}
onDecrease={() => onQuantityChange(line.quantity - 1)}
onIncrease={() => onQuantityChange(line.quantity + 1)}
/>
) : (
variant="secondary"
loading={isBusy}
disabled={disabledOthers || !canAdd}
accessibilityLabel={`${offer.title}: ${TEXT.addLabel}`}
onClick={onAdd}
>
{TEXT.add}

)}

);
}

function QuantityStepper({
quantity,
isBusy,
disabled,
canDecrease,
canIncrease,
onDecrease,
onIncrease,
}) {
const locked = isBusy || disabled;
return (

icon="minus"
label={TEXT.decrease}
disabled={locked || !canDecrease}
onClick={onDecrease}
/>
{isBusy ? (

) : (
{quantity}
)}
icon="plus"
label={TEXT.increase}
disabled={locked || !canIncrease}
onClick={onIncrease}
/>

);
}

function StepButton({icon, label, disabled, onClick}) {
return (
onClick={onClick}
disabled={disabled}
accessibilityLabel={label}
// border="base base": Stärke + Farbe. Nur "base" zeichnet einen unsichtbaren Rahmen
border="base base"
borderRadius="base"
paddingBlock="small-200"
paddingInline="small-100"
>


);
}

function toOffers(data) {
const products = data?.collection?.products?.nodes ?? [];
return products
.map((product) => {
// Die erste verkaufbare Variante anbieten; Produkte ohne Bestand überspringen
const variant = product.variants.nodes[0];
if (!variant?.availableForSale) return null;
return {
variantId: variant.id,
title: product.title,
price: variant.price,
image: variant.image ?? product.featuredImage,
};
})
.filter(Boolean);
}

function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}

Deployment und Einrichtung durch den Händler

So deployen Sie die Extension:

Platzierung des App-Blocks Checkout Upsell unter Cost summary innerhalb von Order summary im Shopify-Checkout-Editor
# Vorschau im Entwicklungsshop (zum Testen im Checkout-Editor)
shopify app dev

# Erstellt und veröffentlicht eine neue App-Version
shopify app deploy

Nach dem Deployment führt der Händler folgende Schritte aus:

  1. Shopify Admin → Einstellungen → Checkout → Anpassen (öffnet den Checkout- und Kundenkonto-Editor)
  2. Checkout-Seite auswählen
  3. Im linken Bereich unter „App-Blöcke“ den Block „Checkout Upsell“ suchen
  4. In den Bereich „Bestellübersicht“ ziehen und unter „Kostenübersicht“ ablegen
  5. Im Feld „Collection handle“ den Handle der Upsell-Kollektion eingeben
  6. Speichern

Den Kollektions-Handle finden Sie unter Shopify Admin → Produkte → Kollektionen → [Kollektion] im letzten Teil der URL.

Häufig gestellte Fragen

Funktioniert die Checkout UI Extension ohne Shopify Plus? In den Checkout-Schritten nicht: Extensions in den Schritten Information, Versand und Zahlung sind nur mit Shopify Plus und in Entwicklungsshops von Shopify Partners möglich. Extensions auf der Dankeseite und der Bestellstatusseite funktionieren dagegen in allen Tarifen außer Starter. In Standardtarifen lässt sich der Code der Checkout-Schritte nicht ändern; das ist eine bewusste Sicherheitsbeschränkung von Shopify.

Kann ich Produkte aus mehreren Kollektionen anzeigen? Ja. Sie können settings.fields ein zweites Feld collection_handle hinzufügen, zwei Abfragen ausführen und die Ergebnisse zusammenführen. Weil Sie dann zwei separate API-Aufrufe machen, müssen Sie das Dependency-Array von useEffect und den Ladezustand entsprechend anpassen.

Sind Aufrufe der Storefront API kostenpflichtig? Die Storefront API ist im Preis von Shopify Plus enthalten. Pro Checkout-Extension fallen keine zusätzlichen API-Kosten an. Wer das Rate Limit allerdings massiv überschreitet (Tausende Anfragen pro Minute), kann ein Throttling auslösen. Es reicht, maxProducts unter 12 zu halten.

Die Extension taucht in Analytics nicht auf. Warum? Damit eine Checkout-Extension das Ziel purchase.checkout.block.render nutzen kann, muss der Händler den App-Block im Editor aktiviert haben. Ein Deployment allein reicht nicht; ohne Platzierung im Editor wird die Extension nicht gerendert und erscheint nicht in Analytics.

‍Warum funktioniert onClick bei s-button nicht? Auf der Checkout-Oberfläche schreiben Sie onClick bei s-button direkt als Prop, nicht innerhalb von buttonProps. Außerdem löst onClick bei Buttons mit disabled={true} nicht aus; das ist erwartetes Verhalten. Prüfen Sie den State disabledOthers.

Das Upsell-Produkt liegt im Warenkorb, erscheint aber nicht sofort? lines?.value hängt von der Reaktivität des Warenkorb-States in Shopify ab. Sobald sich cartLines im Abhängigkeits-Array von useMemo ändert, wird die Map lineByVariant neu berechnet. Kommt es zu einer Verzögerung, liegt das am Aktualisierungszyklus des Warenkorb-States im Shopify-Checkout selbst; auf Seiten der Extension können Sie nichts tun.

Was muss ich für das türkische Preisformat tun? Sie können i18n.formatCurrency(amount, { currency: "TRY" }) verwenden. Die Währung kommt jedoch aus den Shop-Einstellungen; wenn Sie price.currencyCode durch ein festes "TRY" ersetzen, gibt es in Shops mit mehreren Währungen Probleme. Sicherer ist es, price.currencyCode zu verwenden.

Checkliste: Vor dem Deployment

Wenn Sie diese sieben Punkte vor jedem neuen Deployment einer Checkout-Extension prüfen, kommen Sie am schnellsten zu Code, der schon beim ersten Deployment funktioniert:

  1. Ist api_access = true in shopify.extension.toml vorhanden?
  2. Wurde purchase.checkout.block.render als Ziel verwendet?
  3. Sind alle JSX-Props aus mehreren Wörtern in camelCase geschrieben?
  4. Wurde bei s-button auf die icon-Prop verzichtet?
  5. Wird in kompakten Zeilenlayouts ein benutzerdefinierter Stepper anstelle von s-number-field verwendet?
  6. Laufen alle Warenkorb-Mutationen über eine einzige busyId?
  7. Enthalten border-Werte eine Farbe (z. B. "base base")?

Diese Liste fasst die Erfahrung aus 18 Deployments zusammen, die Nodus Works bei der Entwicklung von Checkout-Extensions für Shopify-Plus-Shops gesammelt hat. Jeder Punkt steht für mindestens ein gescheitertes Deployment.

Wenn Sie ein Projekt zur Anpassung Ihres Shopify-Plus-Checkouts planen und mit dem technischen Team von Nodus Works sprechen möchten, sehen Sie sich unsere Seite zu Shopify-Plus-Lösungen an.