Beschikbaar voor nieuwe Shopify-projecten

CheckoutBuild

Pre-orders op Shopify zonder app: één datum, elf lezers

Diek ThunnissenDiek Thunnissen8 sep 202612 min leestijd

Een pre-order-app kent je productpagina, maar niet je checkout. Dus biedt de kassa Klarna aan op iets wat over drie weken verzendt. Op onze demo-store is een pre-order één datumveld op het product, en elf lagen luisteren ernaar: theme, USP-balk, vijf Functions, twee checkout-blokken, de thank-you-pagina en de mail. Verzet de datum, en alles klopt weer.

Een pre-order-app kent je productpagina, maar niet je checkout. Dus staat er een keurige badge "Pre-order, verzending vanaf 12 september" op het product, en drie schermen verder biedt de kassa Klarna aan op iets wat pas over drie weken de deur uitgaat. De USP-balk in de checkout belooft nog steeds "voor 23:59 besteld, morgen in huis". De verzendopties tonen avondlevering. En de gratis muts die bij de drop hoorde, staat gewoon voor 39 euro in de winkelwagen.

Dat is geen slordigheid van de app. De app kan er niet bij. Een theme-app leeft in de storefront en de checkout is een ander systeem. Alles wat de checkout over pre-orders moet weten, moet daar ook zelf staan.

Shopify heeft geen knop die "pre-order" heet. Maar het heeft wel alles wat je nodig hebt om er een te bouwen: een datumveld op het product en een checkout die dat veld in elke laag kan lezen. Op onze demo-store luisteren elf plekken naar dezelfde datum. Verzet je die datum in de admin, dan kloppen ze alle elf weer.

Wat je normaal doet, en waarom dat ophoudt bij de kassa

De gangbare route is een pre-order-app. Die zet een badge op de productpagina, wisselt de knoptekst en houdt soms een teller bij. Prima, tot de klant op afrekenen klikt. De checkout weet van niets, dus daar gaat alles gewoon door: spoedverzending, achteraf betalen, de standaardbelofte over levertijd. Je repareert dat met een tweede app voor betaalmethodes, een handmatige regel in de verzendinstellingen en een mailtje naar het magazijn dat ze die orders even moeten laten liggen.

Vier plekken, vier keer dezelfde datum overtypen, en op de releasedag vier keer terugdraaien. En als de release een week schuift, begin je opnieuw. Iedereen die een pre-sale heeft gedraaid, kent de vrijdagavond waarop iemand vergat één van die vier plekken bij te werken.

Eén datum, elf lezers

Het mechanisme is een datum in de toekomst. Staat op een product een releasedatum die nog moet komen, dan is dat product een pre-order. Ligt de datum in het verleden, dan is het een gewoon product. Meer regel is er niet. Elke laag die iets met pre-orders moet doen, leest dat ene veld en vergelijkt het met vandaag.

Het belangrijkste is dat de checkout die datum zelf kan lezen. Shopify Functions krijgen product-metafields van elke cart-regel in hun input, plus de datum van vandaag in de tijdzone van de shop. Een Function hoeft dus niets op te slaan, niets te synchroniseren en niets aan te roepen. Hij kijkt naar de cart, ziet een datum, en beslist.

Op 12 september stopt het vanzelf. Niemand hoeft iets uit te zetten.

Dat laatste is de reden dat we dit met een datum bouwen en niet met een vinkje. Een vinkje moet iemand omzetten. Een datum verloopt.

De vier velden

Alles staat op het product, als metafields. Geen metaobject, want een releasedatum en een limiet zijn waarden van dít product, en Functions lezen product-metafields direct op de cart-regel. Een metaobject wordt pas interessant bij een campagne over tientallen producten tegelijk, daarover verderop meer.

Product-metafields (namespace custom), op Het Overshirt in de demo-store

  preorder_release_date   date             2026-09-12   de schakelaar: toekomst = pre-order
  preorder_limit          number_integer   2            max. per bestelling, leeg = geen limiet
  preorder_note           single_line      "Levering kan 1-2 dagen afwijken"   optioneel
  preorder_remaining      number_integer   5            de drop-teller, Flow telt af per order

Shop-metafield (namespace custom)

  preorder_gift           json             {"product": "gid://shopify/Product/…", "label": "Gratis muts bij je pre-order"}

Het eerste veld is de schakelaar. De andere drie zijn optioneel: geen limiet betekent onbeperkt per bestelling, geen teller betekent geen drop-plafond, geen cadeau betekent geen cadeau. Je kunt dus klein beginnen met alleen een datum en de rest later erbij zetten.

Lezer 1 tot 4: de storefront

De theme toont, de checkout dwingt af. Dat is een bewuste verdeling. In de theme staat één snippet die de datum leest en drie dingen kan tekenen: een badge op de productkaart, een regel onder de koopknop met de datum en de toelichting, en dezelfde regel op de cart-regel. De koopknop zelf krijgt de tekst "Pre-order" in plaats van "In winkelwagen".

{% comment %} theme/snippets/preorder-badge.liquid {% endcomment %}
{%- liquid
  assign release = product.metafields.custom.preorder_release_date.value
  if release
    assign today_s = 'now' | date: '%s' | plus: 0
    assign release_s = release | date: '%s' | plus: 0
    if release_s > today_s
      if style == 'line'
        echo '<p class="preorder-line"><span class="preorder-badge">Pre-order</span> Verzending vanaf '
        echo release | date: '%-d %B'
        echo '.'
        if product.metafields.custom.preorder_note.value != blank
          echo ' '
          echo product.metafields.custom.preorder_note.value
        endif
        echo '</p>'
      else
        echo '<span class="preorder-badge">Pre-order</span>'
      endif
    endif
  endif
-%}

Twee dingen om op te letten. Ten eerste vergelijkt Liquid strings als je de datums niet omzet; de plus: 0 forceert een getal. Ten tweede is render scope-isolated, dus je geeft het product expliciet mee, ook op een cart-regel waar het item.product heet.

De vierde storefront-lezer zit al in de checkout maar is nog steeds tonen, niet afdwingen: het USP-blok. Elke USP-regel in ons metaobject heeft een veld hide_for_preorder. Staat dat aan en zit er een pre-order in de cart, dan verdwijnt die regel. Zo verdwijnt "morgen in huis" precies op het moment dat het niet meer waar is, zonder dat iemand de USP's hoeft aan te passen.

Lezer 5 tot 9: de checkout

Hier zit het werk. Vijf Functions en twee blokken lezen dezelfde datum. We beginnen met de validation, want dat is de enige die een klant kan tegenhouden, en die staat er daarom volledig.

# extensions/preorder-validation/src/cart_validations_generate_run.graphql
query CartValidationsGenerateRunInput {
  cart {
    lines {
      quantity
      merchandise {
        __typename
        ... on ProductVariant {
          product {
            title
            releaseDate: metafield(namespace: "custom", key: "preorder_release_date") {
              value
            }
            preorderLimit: metafield(namespace: "custom", key: "preorder_limit") {
              value
            }
            preorderRemaining: metafield(namespace: "custom", key: "preorder_remaining") {
              value
            }
          }
        }
      }
    }
  }
  shop {
    localTime {
      date
    }
  }
}
60 regels codeToon code +
// extensions/preorder-validation/src/cart_validations_generate_run.ts
import type {
  CartValidationsGenerateRunInput,
  CartValidationsGenerateRunResult,
  ValidationError,
} from "../generated/api";

/**
 * Pre-order limiet — afgedwongen in de checkout, niet in de theme.
 *
 * Een product is pre-order als custom.preorder_release_date bestaat én in
 * de toekomst ligt (ISO-datums vergelijken lexicografisch correct met
 * shop.localTime.date). custom.preorder_limit begrenst het aantal stuks
 * per bestelling; zonder limiet geldt geen begrenzing.
 *
 * custom.preorder_remaining is de shop-brede teller: Flow telt hem per
 * order af (flow/preorder-decrement.md), deze check houdt de checkout
 * tegen als de cart méér vraagt dan er nog zijn. 0 = uitverkocht (elke
 * quantity blokkeert); leeg metafield = teller uit, alleen de limiet.
 * Race tussen twee gelijktijdige carts is geaccepteerd — dit is een
 * demo-teller, geen inventory-lock.
 */

export function cartValidationsGenerateRun(
  input: CartValidationsGenerateRunInput,
): CartValidationsGenerateRunResult {
  const today = input.shop.localTime.date;
  const errors: ValidationError[] = [];

  for (const line of input.cart.lines) {
    if (line.merchandise.__typename !== "ProductVariant") continue;
    const product = line.merchandise.product;
    const release = product.releaseDate?.value;
    if (!release || release <= today) continue;

    const limit = Number(product.preorderLimit?.value);
    if (Number.isFinite(limit) && limit > 0 && line.quantity > limit) {
      errors.push({
        message: `${product.title} is een pre-order: maximaal ${limit} per bestelling.`,
        target: "$.cart",
      });
    }

    const remainingRaw = product.preorderRemaining?.value;
    const remaining = Number(remainingRaw);
    if (
      remainingRaw != null &&
      remainingRaw !== "" &&
      Number.isFinite(remaining) &&
      line.quantity > remaining
    ) {
      errors.push({
        message: `Van ${product.title} zijn er nog ${remaining} pre-orders.`,
        target: "$.cart",
      });
    }
  }

  return { operations: [{ validationAdd: { errors } }] };
}

De validation doet twee dingen. Meer stuks in de cart dan de limiet? Checkout dicht, met de limiet in de melding. Meer stuks dan er nog over zijn in de drop? Checkout dicht, met het resterende aantal in de melding. De teller zelf is een metafield op het product dat Flow na elke order aftelt. Geen database, geen app: het veld ís de teller.

Dan de verzendopties. Zit er een pre-order in de cart, dan krijgt elke verzendoptie de releasedatum in zijn naam, en verdwijnen de spoedopties. De laatste datum in de cart wint, want de order gaat pas als alles er is. Van de Function alleen het beslissende deel; de datumopmaak en de lijst met spoed-woorden laten we weg.

41 regels codeToon code +
// extensions/preorder-delivery/src/cart_delivery_options_transform_run.ts (kern)
const today = input.shop.localTime.date;

let latestRelease: string | null = null;
for (const line of input.cart.lines) {
  if (line.merchandise.__typename !== "ProductVariant") continue;
  const release = line.merchandise.product.releaseDate?.value;
  if (!release || release <= today) continue;
  if (!latestRelease || release > latestRelease) latestRelease = release;
}

if (!latestRelease) {
  return { operations: [] };
}

const suffix = ` — verzending vanaf ${formatDate(latestRelease)}`;
const operations = input.cart.deliveryGroups.flatMap((group) => {
  const toHide = group.deliveryOptions.filter((option) =>
    isSpoedOptie(option.title),
  );
  // Guard: als élke optie in de group spoed is, niets verbergen —
  // een lege checkout is erger dan een eerlijke hernoemde spoedoptie.
  const hideAllowed = toHide.length < group.deliveryOptions.length;
  const hidden = hideAllowed ? new Set(toHide.map((o) => o.handle)) : new Set();

  return group.deliveryOptions.flatMap((option) => {
    if (hidden.has(option.handle)) {
      return [{ deliveryOptionHide: { deliveryOptionHandle: option.handle } }];
    }
    return [
      {
        deliveryOptionRename: {
          deliveryOptionHandle: option.handle,
          title: `${option.title ?? "Verzending"}${suffix}`,
        },
      },
    ];
  });
});

return { operations };

Let op de guard in het midden. Als élke optie in een bezorggroep een spoedoptie is, verbergt de Function niets en hernoemt hij alleen. Een checkout zonder verzendopties is erger dan een hernoemde spoedoptie.

Achteraf betalen gaat eruit. Een betaaltermijn van dertig dagen op iets wat over vijf weken verzendt, is voor de klant verwarrend en voor de betaalaanbieder een reden om af te keuren. De Function kijkt naar de naam van de betaalmethode, want het input-object heeft geen type-veld. iDEAL, kaart en gewone Shop Pay blijven staan.

// extensions/preorder-payment/src/cart_payment_methods_transform_run.ts (kern)
const BNPL_NEEDLES = [
  "klarna",
  "afterpay",
  "clearpay",
  "riverty",
  "billie",
  "scalapay",
  "in3",
  "achteraf",
  "pay later",
  "buy now pay later",
  "bnpl",
  "installment",
  "shop pay installment",
];

const today = input.shop.localTime.date;

const hasPreorder = input.cart.lines.some((line) => {
  if (line.merchandise.__typename !== "ProductVariant") return false;
  const release = line.merchandise.product.releaseDate?.value;
  return !!release && release > today;
});

if (!hasPreorder) {
  return { operations: [] };
}

const operations = input.paymentMethods
  .filter((method) => {
    const name = method.name.toLowerCase();
    return BNPL_NEEDLES.some((needle) => name.includes(needle));
  })
  .map((method) => ({
    paymentMethodHide: { paymentMethodId: method.id },
  }));

return { operations };

De gratis verzending die de balk belooft, is een echte korting van honderd procent op de verzendkosten, met de datum als guard. Zonder die guard was de hele shop gratis. Daarom is dit een aparte Function naast de staffelkorting, die alleen op producten werkt.

// extensions/preorder-shipping/src/cart_delivery_options_discounts_generate_run.ts (kern)
if (!input.discount.discountClasses.includes(DiscountClass.Shipping)) {
  return { operations: [] };
}

const today = input.shop.localTime.date;
const hasPreorder = input.cart.lines.some((line) => {
  if (line.merchandise.__typename !== "ProductVariant") return false;
  const release = line.merchandise.product.releaseDate?.value;
  return !!release && release > today;
});

if (!hasPreorder || input.cart.deliveryGroups.length === 0) {
  return { operations: [] };
}

return {
  operations: [
    {
      deliveryDiscountsAdd: {
        candidates: input.cart.deliveryGroups.map((group) => ({
          message: "Gratis verzending — pre-order",
          targets: [
            {
              deliveryGroup: { id: group.id },
            },
          ],
          value: {
            percentage: { value: 100 },
          },
        })),
        selectionStrategy: DeliveryDiscountSelectionStrategy.All,
      },
    },
  ],
};

En het cadeau. Een Function kan niets aan de cart toevoegen, dus het patroon is in tweeën: het gift-blok in de checkout biedt de muts aan en zet hem met één tik in de cart, de Function maakt die regel gratis zolang er een pre-order naast ligt. Beide lezen hetzelfde shop-veld voor welk product het cadeau is. Cap op één stuk, het tweede exemplaar is gewoon betaald.

47 regels codeToon code +
// extensions/preorder-gift/src/cart_lines_discounts_generate_run.ts (kern)
const gift = parseGift(input.shop.gift?.jsonValue);
if (!gift) {
  return { operations: [] };
}

const today = input.shop.localTime.date;
const hasPreorder = input.cart.lines.some((line) => {
  if (line.merchandise.__typename !== 'ProductVariant') return false;
  const release = line.merchandise.product.releaseDate?.value;
  return !!release && release > today;
});
if (!hasPreorder) {
  return { operations: [] };
}

const giftLine = input.cart.lines.find(
  (line) =>
    line.merchandise.__typename === 'ProductVariant' &&
    line.merchandise.product.id === gift.product,
);
if (!giftLine) {
  return { operations: [] };
}

return {
  operations: [
    {
      productDiscountsAdd: {
        candidates: [
          {
            message: gift.label ?? 'Gratis accessoire — pre-order',
            targets: [
              {
                cartLine: { id: giftLine.id, quantity: 1 },
              },
            ],
            value: {
              percentage: { value: 100 },
            },
          },
        ],
        selectionStrategy: ProductDiscountSelectionStrategy.First,
      },
    },
  ],
};

De twee blokken zijn checkout UI extensions. Het gift-blok is het zichtbare deel van de cadeau-Function. Het upsell-blok is pre-order-bewust: is het aangeboden product zelf een pre-order, dan toont de kaart de badge en de verzenddatum naast de gewone toevoegknop, en na toevoegen neemt de rest van de keten het over. Beide blokken lezen de releasedatum via appMetafields, dezelfde bron als de Functions.

Lezer 10 en 11: na de order

De thank-you-pagina bevestigt de verwachting op het moment dat de spanning het hoogst is: wanneer komt het, en dat annuleren tot verzending kosteloos kan. Dat laatste is geen marketingzin. Het is Shopify's eigen self-serve cancellation: verzoek via het klantaccount, alleen mogelijk zolang de order niet verzonden is, dus letterlijk "tot verzending". Twee instellingen aanzetten, geen code.

De orderbevestigingsmail krijgt dezelfde regel per line item. Dat is een snippet in de notification-template, binnen de loop over de regels.

{% comment %} theme/notifications/order-confirmation-preorder.liquid, in de line-items-loop van de orderbevestiging {% endcomment %}
{%- assign preorder_release = line.product.metafields.custom.preorder_release_date.value -%}
{%- if preorder_release -%}
  {%- assign today_s = 'now' | date: '%s' | plus: 0 -%}
  {%- assign release_s = preorder_release | date: '%s' | plus: 0 -%}
  {%- if release_s > today_s -%}
    <p style="margin:4px 0 0; font-size:13px; color:#555;">
      Pre-order &middot; verzending vanaf {{ preorder_release | date: '%-d %B' }}.
      Tot verzending kun je kosteloos annuleren via je account.
    </p>
  {%- endif -%}
{%- endif -%}

En dan het magazijn. Een Flow-workflow op "order created" kijkt of een regel een releasedatum heeft, tagt de order met preorder en zet een fulfillment hold met de notitie dat er niet verzonden mag worden vóór de releasedatum. Het magazijn pickt niets te vroeg, en op de releasedag filter je op de tag en release je de holds in bulk. Een tweede Flow telt de teller af, per regel, met een guard zodat hij nooit onder nul komt.

Flow "Pre-order teller aftellen": order created → for each line item →
conditie: preorder_release_date en preorder_remaining niet leeg →
actie: Send Admin API request (metafieldsSet)

{
  "metafields": [
    {
      "ownerId": "{{ lineItem.product.id }}",
      "namespace": "custom",
      "key": "preorder_remaining",
      "type": "number_integer",
      "value": "{{ lineItem.product.metafields.custom.preorder_remaining.value | minus: lineItem.quantity | at_least: 0 }}"
    }
  ]
}

Flow is klikwerk, geen code. Dat is een voordeel (je kunt het per shop in vijf minuten aanzetten) en een nadeel (het staat niet in versiebeheer). We documenteren de recepten daarom als tekst naast de code.

De valkuilen

Validations draaien ook bij toevoegen aan de winkelwagen, niet alleen in de checkout. Voor de limiet is dat precies goed: de klant hoort meteen "maximaal 2 per bestelling" als hij een derde toevoegt. Maar voor meldingen die alleen in de checkout betekenis hebben, zoals "log in om af te rekenen", moet je op de checkout-stap filteren via buyerJourney.step, anders blokkeer je de productpagina met een tekst die daar nergens op slaat. Bij de bots-gate hebben we dat zo gedaan.

De schakelaar is de datum van vandaag, en die komt uit shop.localTime, dus in de tijdzone van de shop. Op de releasedag om middernacht lokale tijd stoppen alle Functions vanzelf: de datum is niet meer in de toekomst. De theme rekent met 'now' in Liquid en doet hetzelfde. Het thank-you-blok draait in de browser en pakt de datum in UTC, dus daar kan een uur verschil in zitten. Voor een bevestigingstekst is dat niet erg. Voor een blokkade zou het dat wel zijn, en daarom zit de blokkade in de Function.

De drop-teller is geen voorraadslot. Twee klanten die op dezelfde seconde afrekenen, lezen allebei de oude stand; in het slechtste geval staat de teller één order te hoog tot de volgende run. De limiet per bestelling begrenst de schade. Wil je een harde grens, dan is dat de gewone voorraad met "doorverkopen bij uitverkocht" uit, niet deze teller.

En de campagne-vraag. Zodra één drop tien producten deelt, wil je de datum niet tien keer invullen. Dan komt er een metaobject Pre-order met de datum, de limiet, de toelichting en een productlijst, en een Flow op "metaobject entry updated" die de waarden naar de product-metafields schrijft. De Functions blijven de metafields lezen, want een Function volgt geen metaobject-referenties. Het metaobject is het beheer, de metafields zijn het koppelvlak. Eén ding syncen we bewust niet mee: de teller. Anders reset elke campagne-bewerking je drop.

Wat je hiervoor nodig hebt

De Functions zelf draaien op elk Shopify-plan: validation, delivery customization, payment customization en de twee kortingen zijn gewone Function-targets. De twee checkout-blokken, het gift-blok en het upsell-blok, zijn checkout UI extensions en die vragen Plus. Zonder Plus verlies je het cadeau-aanbod in de checkout, niet de rest. De theme-snippet, de mail-snippet en de Flows werken overal.

Aanzetten is één datum invullen op het product. Uitzetten is hem leegmaken of laten verlopen. Verzetten is één veld. Dat is de hele pitch, en het is de reden dat we dit liever bouwen dan installeren.

Veelgestelde vragen.

Werkt dit zonder Shopify Plus?
Grotendeels. De vijf Functions (validation, verzendopties, betaalmethodes, gratis verzending, cadeaukorting) draaien op elk plan, net als de theme-snippet, de mailregel en de Flows. Alleen de twee checkout-blokken, het cadeau-aanbod en de pre-order-bewuste upsell, zijn checkout UI extensions en die vragen Plus. Zonder Plus mis je dus het aanbieden van het cadeau in de checkout, niet de rest van de keten.
Wat gebeurt er op de releasedag?
Niets, en dat is het punt. De releasedatum ligt dan niet meer in de toekomst, dus elke Function ziet het product als een gewoon product: verzendopties heten weer normaal, Klarna komt terug, de gratis verzending en het cadeau stoppen. In de admin filter je op de tag preorder en release je de fulfillment holds in bulk, zodat het magazijn kan gaan picken.
Hoe voorkom je dat je meer pre-orders verkoopt dan je kunt leveren?
Met twee grenzen. De limiet per bestelling zit in de validation en is hard. De drop-teller (preorder_remaining) telt Flow per order af en de validation sluit de checkout zodra de cart meer vraagt dan er over is. Die teller is niet atomair: bij twee gelijktijdige orders kan hij één order te hoog staan. Wil je een harde grens, gebruik dan de gewone voorraad met doorverkopen uit.
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.