Beschikbaar voor nieuwe Shopify-projecten

CheckoutBuild

Een upsell in de Shopify-checkout zonder upsell-app. De code geven we weg.

Diek ThunnissenDiek Thunnissen9 sep 202611 min leestijd

Elke Shopify-shop heeft al een lijst met producten die bij elkaar horen, in de gratis app Search & Discovery. Eén Checkout UI Extension leest die lijst en toont hem als kaart met een knop in de checkout en als link op de bedankpagina. Geen backend, geen scopes, geen abonnement. Open source onder MIT.

Bij bijna elke Shopify-store waar we binnenkomen staat dezelfde upsell-app. Wat je ervoor krijgt is een blok in de checkout dat zegt: dit past erbij, wil je hem erbij? Dat werkt. Wat je ervoor betaalt is een abonnement per order die je shop draait, niet per upsell die iets oplevert. Bij de bekendste app in dit segment begint dat rond de 45 dollar per maand bij 500 orders en loopt het op tot ruim 600 dollar bij 7.500 orders. Elke maand, of het blok nou iets verkoopt of niet.

Het rare is dat de data waar zo'n blok op draait al in je Shopify-admin staat. Elke shop heeft de gratis app Search & Discovery, en daarin vul je per product in welke producten erbij horen. Veel shops hebben dat allang gedaan voor de productpagina. De upsell-app leest het niet, die wil dat je alles nog een keer in zijn eigen dashboard invult.

Dus bouwden we het blok zelf, op precies die lijst. Eén Checkout UI Extension, geen backend, geen database, geen access scopes. Leg een coltrui in je mandje en in de checkout staat de sjaal, met een knop. Eén tik en hij ligt erbij. De code staat open op GitHub onder MIT, met een prompt erbij zodat een AI-agent de installatie voor je doet.

Upsell-kaart in de Shopify-checkout: De Coltrui in het mandje, De Sjaal als aanbod met een Toevoegen-knop
Op onze demo-store: de coltrui ligt in het mandje, de checkout biedt de sjaal aan. De lijst komt uit Search & Discovery.

Bekijk de code op GitHub

Waarom een upsell-app meer kost dan het abonnement

Het prijsmodel is het eerste probleem. Per order betekent dat een shop die groeit meer betaalt voor hetzelfde kaartje, en dat POS-orders en abonnementsverlengingen ook meetellen. Een upsell-blok dat op tien procent van de orders iets verkoopt, kost je op de andere negentig procent net zo veel.

Het tweede probleem zit in je thema. Zo'n app laadt een script op elke pagina, meestal met een eigen carrousel en een eigen jQuery erbij. In een speed-audit van dit voorjaar over ruim duizend shops blokkeerde de bekendste upsell-app gemiddeld 172 milliseconden de main thread, meer dan alle analytics-scripts bij elkaar. En als je hem verwijdert, blijft er vaak code in het thema achter. Dat lezen we terug in de reviews: kapotte carts na het opzeggen, support die niet reageert.

Het derde probleem is de data. Welk product bij welk product hoort, staat in de database van de leverancier. Zeg je op, dan is dat weg. Dat is precies andersom van hoe Shopify het zelf heeft ingericht.

En dan is er nog het scherm dat de apps het hardst verkopen: de post-purchase-upsell, het aanbod tussen betalen en de bedankpagina. Dat scherm werkt op Shopify niet bij iDEAL, Klarna, PayPal, Apple Pay of Mollie. Dat is een platformbeperking, geen app-fout. Maar in Nederland ziet dus bijna niemand het, en je betaalt er wel voor.

De data staat er al: Search & Discovery

Search & Discovery is de gratis app van Shopify voor zoeken, filters en aanbevelingen. Onder Products, Search & Discovery, Recommendations kun je per product complementaire producten kiezen: bij de coltrui hoort de sjaal, bij de bureaustoel de vloerbeschermer. Shopify slaat dat op als een metafield op het product, shopify--discovery--product_recommendation.complementary_products, en toont het op de productpagina in thema's die het ondersteunen.

Diezelfde lijst is opvraagbaar via de Storefront API. Eén query, productRecommendations met intent COMPLEMENTARY, geeft de producten terug in de volgorde die je in de admin koos. In je thema kun je hetzelfde ophalen via /recommendations/products.json met intent=complementary, bijvoorbeeld voor een cart-drawer. Er is dus al een bron van waarheid, en die is van jou.

Je hoeft niets dubbel in te stellen. En als je het blok ooit weghaalt, staan je aanbevelingen nog gewoon in Shopify.

Eén extensie, twee plekken

De extensie heeft twee targets. In de checkout zelf, tussen adres en betaling, staat de kaart met een Toevoegen-knop. Dat target vraagt Shopify Plus, en dat geldt voor elke app die daar iets wil tonen, ook de betaalde. Op de bedankpagina staat dezelfde kaart met een link naar het product in plaats van een knop, want de order is al geplaatst. Dat target werkt op elk plan vanaf Basic.

Beide targets delen één kaart-component en één bestand met de query en de filterlogica. Het verschil zit alleen in de actie rechts op de kaart: knop of link. Zo blijft het verschil tussen de twee plekken één regel in plaats van twee kopieën van dezelfde extensie.

61 regels codeToon code +
# extensions/checkout-upsell/shopify.extension.toml, targets en instellingen
[[extensions]]
name = "Upsell (Search & Discovery)"
handle = "checkout-upsell"
description = "Complementaire producten uit Search & Discovery als upsell-kaart. Door DIEK, diek.nl"
type = "ui_extension"

  [[extensions.targeting]]
  target = "purchase.checkout.block.render"
  module = "./src/CheckoutUpsell.tsx"

  [[extensions.targeting]]
  target = "purchase.thank-you.block.render"
  module = "./src/ThankYouUpsell.tsx"

  [extensions.capabilities]
  # Storefront API vanuit de extensie (productRecommendations). Geen netwerk
  # naar buiten, geen eigen backend.
  api_access = true

  # Instellingen die de merchant in de checkout-editor invult.
  [extensions.settings]
    [[extensions.settings.fields]]
    key = "heading"
    type = "single_line_text_field"
    name = "Kop boven de kaart"
    description = "Bijvoorbeeld: Vaak samen gekocht"

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

    [[extensions.settings.fields]]
    key = "button_label"
    type = "single_line_text_field"
    name = "Knoptekst (alleen checkout)"
    description = "Bijvoorbeeld: Toevoegen"

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

    [[extensions.settings.fields]]
    key = "multi_variant"
    type = "boolean"
    name = "Ook producten met meerdere varianten aanbieden (checkout)"
    description = "Standaard uit: in de checkout kun je geen maat kiezen. Aan = de eerste variant wordt toegevoegd."

    [[extensions.settings.fields]]
    key = "max_offers"
    type = "number_integer"
    name = "Maximaal aantal aanbiedingen"
    description = "1 tot 3. Meer dan één kaart in de checkout leidt af."

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

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

De query

De query vraagt per product in de cart de complementaire producten op, met de velden die de kaart nodig heeft: titel, handle, afbeelding op 160 pixels, of het te koop is, hoeveel varianten het heeft, en de eerste variant met prijs. Die laatste twee velden zijn het belangrijkst, daar komen we bij het filteren op terug.

// extensions/checkout-upsell/src/recommendations.ts, de query
const COMPLEMENTARY_QUERY = `#graphql
  query Complementary($productId: ID!) {
    productRecommendations(productId: $productId, intent: COMPLEMENTARY) {
      id
      title
      handle
      onlineStoreUrl
      availableForSale
      featuredImage { url(transform: { maxWidth: 160, maxHeight: 160, crop: CENTER }) }
      variantsCount { count }
      variants(first: 1) {
        nodes {
          id
          availableForSale
          price { amount currencyCode }
        }
      }
    }
  }
`;

We bevragen maximaal de eerste drie producten in de cart. Elke query is één Storefront-call vanuit de extensie, en de eerste paar regels in een winkelwagen zijn bijna altijd de relevante. Mislukt één query, dan valt alleen het aanbod uit dat product weg, niet de hele kaart.

Filteren: wat er wél op de kaart mag

Een aanbeveling die al in het mandje ligt is geen upsell maar een fout. Een uitverkocht product ook. En een product met maten kun je in de checkout niet zomaar toevoegen, want de klant kan daar geen maat kiezen. Dus loopt de lijst door drie zeven.

// extensions/checkout-upsell/src/recommendations.ts, filteren en samenvoegen
  const seen = new Set<string>();
  const offers: Offer[] = [];

  for (const product of results.flat()) {
    if (seen.has(product.id) || inCart.has(product.id)) continue;
    seen.add(product.id);
    const variant = product.variants.nodes[0];
    if (!variant || !product.availableForSale) continue;
    offers.push({
      productId: product.id,
      title: product.title,
      // onlineStoreUrl is null op een shop met wachtwoord (dev stores); dan
      // zelf de PDP-url bouwen uit de storefront-url en de handle.
      url:
        product.onlineStoreUrl ??
        (storefrontUrl ? `${storefrontUrl.replace(/\/$/, '')}/products/${product.handle}` : null),
      imageUrl: product.featuredImage?.url ?? null,
      variantId: variant.id,
      singleVariant: (product.variantsCount?.count ?? 1) === 1,
      availableForSale: variant.availableForSale,
      amount: Number(variant.price.amount),
      currencyCode: variant.price.currencyCode,
    });
    if (offers.length >= limit) break;
  }
  return offers;

De volgorde is bewust: eerst de volgorde van de cart, daarbinnen de volgorde die je zelf in Search & Discovery koos. Het blok verzint geen rangorde. Wat jij bovenaan zet, staat bovenaan.

Over die varianten. Standaard laat het checkout-blok alleen producten met één variant door. De verkeerde maat toevoegen is erger dan geen upsell, want dan zit de klant na de bestelling met een retour. Heb je varianten die geen maat zijn, een kleur of een geur waarvan de eerste prima is, dan zet je in de checkout-editor de instelling om en gaat de eerste variant erin. Op de bedankpagina speelt dit niet: daar gaat de klant via de link naar de productpagina en kiest zelf.

Toevoegen met één tik

Het checkout-blok leest de cart als signal, vraagt de aanbiedingen op, en voegt bij een tik toe met applyCartLinesChange. Twee dingen in die code zijn de moeite van het benoemen waard.

47 regels codeToon code +
// extensions/checkout-upsell/src/CheckoutUpsell.tsx, de beslissende regels
  // Signals lezen in de render abonneert op wijzigingen.
  const lines = shopify.lines.value;
  const canAdd = shopify.instructions.value.lines.canAddCartLine;
  const { heading, buttonLabel, maxOffers, multiVariant } = readSettings(shopify.settings.value);

  const cartProductIds = lines.map((line) => line.merchandise.product.id);
  const cartKey = cartProductIds.join('|');

  useEffect(() => {
    let cancelled = false;
    if (cartProductIds.length === 0) {
      setOffers([]);
      return;
    }
    (async () => {
      // Eén extra ophalen zodat we na het filteren op single-variant nog
      // genoeg over hebben.
      const all = await fetchComplementaryOffers(shopify.query, cartProductIds, maxOffers + 2, shopify.shop.storefrontUrl);
      if (!cancelled) {
        setOffers(all.filter((o) => o.availableForSale && (multiVariant || o.singleVariant)).slice(0, maxOffers));
      }
    })();
    return () => {
      cancelled = true;
    };
    // cartKey vat de cart-inhoud samen; herladen bij elke wijziging.
  }, [cartKey, maxOffers, multiVariant]);

  if (!canAdd || !offers || offers.length === 0) return null;

  async function handleAdd(offer: Offer) {
    if (!offer.variantId) return;
    setBusy(offer.productId);
    setError(null);
    const result = await shopify.applyCartLinesChange({
      type: 'addCartLine',
      merchandiseId: offer.variantId,
      quantity: 1,
    });
    setBusy(null);
    if (result.type === 'error') {
      setError('Toevoegen lukte even niet. Probeer het opnieuw.');
    } else {
      setAdded((prev) => new Set(prev).add(offer.productId));
    }
  }

Het eerste is canAddCartLine. Bij Apple Pay en Google Pay mag de winkelwagen niet meer veranderen zodra de betaalsheet open is, en dat vertelt Shopify de extensie via instructions. Is die false, dan rendert het blok niets. Geen knop die niet werkt, geen foutmelding, gewoon geen kaart.

Het tweede is wat er na het toevoegen gebeurt. De kaart verdwijnt niet stil, hij wordt een bevestiging met een vinkje en Toegevoegd. De klant ziet dat het gelukt is, en de orderregel verschijnt in het overzicht erboven. Gaat het mis, dan staat er een korte melding en blijft de knop staan.

// extensions/checkout-upsell/src/OfferCard.tsx
export function OfferCard({
  offer,
  formatCurrency,
  action,
}: {
  offer: Offer;
  formatCurrency: (amount: number, currency: string) => string;
  action: ComponentChildren;
}) {
  return (
    <s-grid gridTemplateColumns="auto 1fr auto" gap="base" alignItems="center">
      {offer.imageUrl ? <s-product-thumbnail src={offer.imageUrl} alt={offer.title} size="base" /> : <s-stack />}
      <s-stack gap="small-500">
        <s-text type="strong">{offer.title}</s-text>
        <s-text>{formatCurrency(offer.amount, offer.currencyCode)}</s-text>
      </s-stack>
      {action}
    </s-grid>
  );
}

export function OfferList({ heading, children }: { heading: string; children: ComponentChildren }) {
  return (
    <s-box background="subdued" borderRadius="base" padding="base">
      <s-stack gap="base">
        <s-text type="small" color="subdued">
          {heading}
        </s-text>
        {children}
      </s-stack>
    </s-box>
  );
}

Na het betalen is de order geplaatst en kan de klant niets meer toevoegen. Dus toont hetzelfde blok daar een link naar het product. Het verschil met de checkout is letterlijk de actie rechts op de kaart.

// extensions/checkout-upsell/src/ThankYouUpsell.tsx, de actie rechts op de kaart
          action={
            offer.url ? (
              <s-link href={offer.url} target="_blank">
                Bekijk
              </s-link>
            ) : (
              <s-stack />
            )
          }

Eén valkuil hier: op een shop met wachtwoord, zoals elke development store, geeft de Storefront API geen onlineStoreUrl terug. Dan bouwen we de url zelf uit de storefront-url van de shop en de handle. Anders test je op een dev store een kaart zonder link en denk je dat het kapot is.

// extensions/checkout-upsell/src/recommendations.ts, url-fallback voor shops met wachtwoord
      // onlineStoreUrl is null op een shop met wachtwoord (dev stores); dan
      // zelf de PDP-url bouwen uit de storefront-url en de handle.
      url:
        product.onlineStoreUrl ??
        (storefrontUrl ? `${storefrontUrl.replace(/\/$/, '')}/products/${product.handle}` : null),

Instellingen voor de merchant

Vier instellingen in de checkout-editor, elk met een default die werkt als je niets invult. De kop boven de kaart, de knoptekst, of producten met meerdere varianten mee mogen, en het maximum aantal kaarten. Dat maximum staat op één tot drie, en één werkt meestal het best. Meer dan één aanbod in een checkout leidt af van waar de klant voor kwam: betalen.

// extensions/checkout-upsell/src/recommendations.ts, instellingen met defaults
/** Instellingen uit de checkout-editor, met nette defaults. */
export function readSettings(settings: Record<string, string | number | boolean | undefined>) {
  const heading = typeof settings.heading === 'string' && settings.heading.trim() ? settings.heading : 'Vaak samen gekocht';
  const buttonLabel =
    typeof settings.button_label === 'string' && settings.button_label.trim() ? settings.button_label : 'Toevoegen';
  const raw = Number(settings.max_offers);
  const maxOffers = Number.isFinite(raw) && raw >= 1 ? Math.min(3, Math.floor(raw)) : 1;
  const multiVariant = settings.multi_variant === true;
  return { heading, buttonLabel, maxOffers, multiVariant };
}

Installeren zonder App Store

Dit is geen App Store-app. Je koppelt de code aan een app in je eigen Shopify-account en installeert die op je eigen shop, als custom distribution. Dat is Shopify's naam voor een app die voor één shop is, of voor de shops binnen één Plus-organisatie. Vijf stappen: de code binnenhalen, koppelen aan je account, testen, deployen, en in het Partner Dashboard onder Distributie een installatielink genereren. Daarna sleep je het blok in de checkout-editor op zijn plek, onder het orderoverzicht bijvoorbeeld, en nog een keer op het tabblad van de bedankpagina.

Geen ervaring met de terminal? In de README staat een prompt die je in Claude Code of Codex plakt. De agent doet het terminalwerk, jij logt in bij Shopify en doet de klikstappen in de browser. De repo bevat ook een AGENTS.md die de agent vertelt wat hij niet mag aanraken: geen scopes toevoegen, geen backend, de client_id nooit committen.

De vijf stappen met screenshots

Wat dit bewust niet doet

Geen post-purchase-scherm. Dat aanbod tussen betalen en bedankpagina werkt niet bij iDEAL, Klarna, PayPal en de meeste andere betaalmethoden in Nederland, dus bouwen we het niet. Het aanbod staat vóór de betaling, in de checkout-stap, of erna op de bedankpagina. Daar speelt de betaalmethode geen rol.

Geen AI. Het blok toont wat jij hebt ingevuld, in de volgorde die jij koos. Wil je automatische aanbevelingen op basis van koopgedrag, dan verander je in de query COMPLEMENTARY in RELATED en gebruik je Shopify's eigen related products. Dat is één woord, en het is de enige aanpassing die we zonder overleg zouden aanraden.

Geen kortingen. Het product wordt voor de normale prijs toegevoegd. Wil je tweede artikel twintig procent korting, dan hoort dat in Shopify's kortingen of in een Discount Function, en die werkt automatisch mee omdat het gewoon een cartregel is.

Waar dit wel en niet voor is

Dit is de uitgeklede versie van wat we voor klanten in de checkout bouwen. Het leest één lijst en toont één kaart. Voor de meeste shops met een ingevulde Search & Discovery is dat precies genoeg, en het vervangt een abonnement van honderden euro's per maand door nul.

Waar het ophoudt: upsells op eigen regels, welke collectie triggert welk aanbod met welke voorrang, bundels die als één regel in de cart landen via een Cart Transform, pre-order-bewuste aanbiedingen die niet in een pre-order-cart mogen, of B2B-betaalmethodes die per klantgroep verschillen. Dat zijn dezelfde bouwblokken, checkout UI-extensies en Functions, maar met een eigen datamodel in metaobjects eronder. Op onze demo-store draait die versie naast deze, en bij klanten in productie.

De volledige checkout-module staat hieronder. Hij is kort, en hij is de bron van alles wat de kaart doet.

102 regels codeToon code +
// extensions/checkout-upsell/src/CheckoutUpsell.tsx, volledig
import '@shopify/ui-extensions/preact';
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import { fetchComplementaryOffers, readSettings, type Offer } from './recommendations';
import { OfferCard, OfferList } from './OfferCard';

/**
 * Checkout-target (Shopify Plus): kaart met Toevoegen-knop.
 *
 * Leest de cart, vraagt de complementaire producten op en voegt op één tik
 * toe via applyCartLinesChange. Bewuste keuzes:
 *  - standaard alleen producten met één variant: in de checkout kun je geen
 *    maat kiezen, en de verkeerde maat toevoegen is erger dan geen upsell.
 *    De merchant kan dit omzetten (setting multi_variant), dan gaat de
 *    eerste variant erin;
 *  - `instructions.lines.canAddCartLine` wordt gecheckt: bij Apple/Google
 *    Pay mag de cart niet meer veranderen en rendert het blok niets;
 *  - na toevoegen verdwijnt de kaart niet stil maar wordt hij een bevestiging.
 */
export default function () {
  render(<CheckoutUpsell />, document.body);
}

function CheckoutUpsell() {
  const [offers, setOffers] = useState<Offer[] | null>(null);
  const [busy, setBusy] = useState<string | null>(null);
  const [added, setAdded] = useState<Set<string>>(new Set());
  const [error, setError] = useState<string | null>(null);

  // Signals lezen in de render abonneert op wijzigingen.
  const lines = shopify.lines.value;
  const canAdd = shopify.instructions.value.lines.canAddCartLine;
  const { heading, buttonLabel, maxOffers, multiVariant } = readSettings(shopify.settings.value);

  const cartProductIds = lines.map((line) => line.merchandise.product.id);
  const cartKey = cartProductIds.join('|');

  useEffect(() => {
    let cancelled = false;
    if (cartProductIds.length === 0) {
      setOffers([]);
      return;
    }
    (async () => {
      // Eén extra ophalen zodat we na het filteren op single-variant nog
      // genoeg over hebben.
      const all = await fetchComplementaryOffers(shopify.query, cartProductIds, maxOffers + 2, shopify.shop.storefrontUrl);
      if (!cancelled) {
        setOffers(all.filter((o) => o.availableForSale && (multiVariant || o.singleVariant)).slice(0, maxOffers));
      }
    })();
    return () => {
      cancelled = true;
    };
    // cartKey vat de cart-inhoud samen; herladen bij elke wijziging.
  }, [cartKey, maxOffers, multiVariant]);

  if (!canAdd || !offers || offers.length === 0) return null;

  async function handleAdd(offer: Offer) {
    if (!offer.variantId) return;
    setBusy(offer.productId);
    setError(null);
    const result = await shopify.applyCartLinesChange({
      type: 'addCartLine',
      merchandiseId: offer.variantId,
      quantity: 1,
    });
    setBusy(null);
    if (result.type === 'error') {
      setError('Toevoegen lukte even niet. Probeer het opnieuw.');
    } else {
      setAdded((prev) => new Set(prev).add(offer.productId));
    }
  }

  return (
    <OfferList heading={heading}>
      {offers.map((offer) => (
        <OfferCard
          key={offer.productId}
          offer={offer}
          formatCurrency={(amount, currency) => shopify.i18n.formatCurrency(amount, { currency })}
          action={
            added.has(offer.productId) ? (
              <s-stack direction="inline" gap="small-300" alignItems="center">
                <s-icon type="check-circle-filled" size="small" tone="success" />
                <s-text type="small">Toegevoegd</s-text>
              </s-stack>
            ) : (
              <s-button loading={busy === offer.productId || undefined} onClick={() => handleAdd(offer)}>
                {buttonLabel}
              </s-button>
            )
          }
        />
      ))}
      {error ? <s-banner tone="critical">{error}</s-banner> : null}
    </OfferList>
  );
}

Veelgestelde vragen.

Heb ik Shopify Plus nodig voor een upsell in de checkout?
Voor het blok in de checkout-stap zelf, tussen adres en betaling, ja. Dat is een regel van Shopify en geldt voor elke app die daar iets wil tonen, ook de betaalde upsell-apps. Het blok op de bedankpagina werkt op elk plan vanaf Basic; daar toont het een link naar het product in plaats van een knop.
Moet ik mijn aanbevelingen opnieuw invullen?
Nee. Het blok leest de complementaire producten die je al in Search & Discovery hebt ingesteld, per product onder Recommendations. Heb je dat nog nooit ingevuld, dan is dat de enige voorbereiding: kies per product wat erbij hoort. Verwijder je het blok later, dan staan die aanbevelingen nog gewoon in je admin.
Waarom slaat het blok producten met meerdere varianten over?
In de checkout kan de klant geen maat of variant kiezen. Standaard toont het blok in de checkout daarom alleen producten met één variant, zodat er nooit een verkeerde maat in de order belandt. Heb je varianten waarvan de eerste altijd goed is, bijvoorbeeld een kleur, dan zet je de instelling in de checkout-editor aan. Op de bedankpagina geldt de beperking niet, daar kiest de klant op de productpagina.
Werkt dit samen met kortingen en bundels?
Ja, omdat het toegevoegde product een gewone cartregel is. Automatische kortingen, kortingscodes en Discount Functions rekenen er gewoon over. Het blok geeft zelf geen korting; wil je een aanbieding op het tweede artikel, dan regel je dat in Shopify's kortingen en werkt het automatisch mee.
Diek ThunnissenDiek ThunnissenFounder & lead developer. Bouwt, verbetert en migreert de Shopify-laag voor DTC- en B2B-merken. LinkedInStuur me je checkout

Technische Shopify-inzichten uit de praktijk.

Geen sales, wel techniek.