Native bouwenBuild
Een loyaltyprogramma zonder loyalty-app. De checkout weet alles al.
Diek Thunnissen8 sep 202614 min leestijd
Welkomstdeal, drie tiers, gratis express, een BNPL-ladder, een cadeau, een ladder in de checkout en een membership-pagina. Vier Shopify Functions en één json-veld op de shop. Geen punten, geen widget, geen abonnement.

Een loyaltyprogramma op Shopify begint meestal met drie apps. Een punten-app, een VIP-tier-app, een cashback-app. Drie abonnementen, een widget op elke pagina, en je klantdata zit overal behalve in je eigen shop.
En dan het rare: de kassa weet van niks. De app rekent punten uit in zijn eigen database, de checkout ziet een gewone klant. Wil je dat een Gold-member gratis express krijgt, dan moet de app dat via een omweg regelen, en die omweg is precies waar het misgaat zodra de app een storing heeft of je hem opzegt.
Dus bouwden we het andersom. Op onze demo-store zit een compleet programma: welkomstdeal op de eerste bestelling, drie tiers op besteding, gratis express vanaf Silver, achteraf betalen pas na negentig dagen, een cadeau vanaf een tier, een ladder in de checkout, je member-prijs op de productpagina en een membership-pagina in je account. Aantal geïnstalleerde apps: nul.
Waarom een punten-app meer kost dan het abonnement
Punten zijn een tweede valuta in je shop. Je moet ze uitgeven, bijhouden, laten verlopen en ooit inlossen. Dat grootboek staat bij de app, niet bij jou. Zeg je het abonnement op, dan is het saldo van je klanten weg, en jij mag uitleggen waarom.
De widget doet de rest. Een script op elke pagina, een pop-up die om aandacht vraagt, en een klant die zich afvraagt of die 340 punten nou iets waard zijn. Ondertussen weet je checkout niet wie er voor de kassa staat, want de app en Shopify praten alleen via tags en webhooks met elkaar.
Wat je normaal doet is stapelen. Een app voor punten, een app voor tiers, een app voor VIP-verzending. Elk met een eigen dashboard, elk met een eigen idee van wie je beste klant is.
De checkout weet alles al
Dit is de omkering. Shopify Functions krijgen tegenwoordig de klant gewoon mee in de input: het aantal bestellingen, de totale besteding, en sinds API-versie 2026-10 ook de aanmaakdatum van het account. Geen tags, geen Flow-sync, geen nachtelijke import. Wie in de checkout staat, is bekend op het moment dat het ertoe doet.
Dat betekent dat de beslissing waar een loyalty-app een externe database voor nodig heeft, hier één regel code is: hoeveel heeft deze klant bij ons besteed, en welke tier hoort daarbij. Alles wat daarna komt is een lezer van die ene regel.
“Wie veel bij je bestelde, betaalt vandaag gewoon minder. Zonder punten, zonder saldo, zonder app.”
Eén json-veld op de shop
De hele configuratie is één shop-metafield. Tiers met drempel en percentage, of express erbij hoort, de welkomstdeal, de BNPL-drempel, het cadeau, en de drop-regels. Verander je een drempel in de admin, dan klopt alles weer, in de kassa én op de pagina's die het uitleggen.
43 regels codeToon code +Verberg code −
// Shop-metafield custom.member_tiers (json), demo-store
{
"welcome": {
"percent": 10,
"label": "Welkomstdeal"
},
"tiers": [
{
"min_spent": 0,
"name": "Bronze",
"percent": 3
},
{
"min_spent": 250,
"name": "Silver",
"percent": 5,
"express": true
},
{
"min_spent": 750,
"name": "Gold",
"percent": 8,
"express": true
}
],
"express_needles": [
"express",
"avond",
"same day",
"spoed"
],
"bnpl_after_days": 90,
"gift": {
"product": "gid://shopify/Product/8707726213354",
"handle": "de-tote",
"min_tier": "Bronze",
"label": "Member-cadeau"
},
"drops": {
"min_orders": 1,
"min_spent": 100
}
}Waarom json en niet zes losse metafields? Omdat de tiers bij elkaar horen. Een drempel zonder percentage is niets, een cadeau zonder minimum-tier ook niet. Eén veld dwingt af dat de configuratie als geheel klopt, en elke lezer parseert hem op dezelfde manier: tiers sorteren op drempel, ontbrekende sleutels betekenen dat die regel uit staat. Een ontbrekende welkomstdeal is geen fout, dan is er gewoon geen welkomstdeal.
Die keuze voor één veld is de kern van het ontwerp. Vier Functions, drie checkout-blokken, een accountpagina en een theme-snippet lezen exact dezelfde json. De ladder in de checkout kan dus nooit iets beloven wat de korting niet doet, want ze kijken naar dezelfde regel.
De tiers: besteding bepaalt, niet punten
Bronze vanaf nul euro met drie procent, Silver vanaf 250 euro met vijf procent en gratis express, Gold vanaf 750 euro met acht procent. Wie nog nooit bestelde krijgt de welkomstdeal: tien procent op de eerste order, en daarna schuift hij de ladder op.
De drempels zijn de knoppen waar je aan draait. Zet Silver te laag en iedereen is Silver, zet Gold te hoog en niemand haalt hem. We beginnen bij klanten met de gemiddelde besteding van een terugkerende klant over een jaar als Silver-grens en twee tot drie keer dat bedrag als Gold, en stellen bij op wat de ladder in de checkout doet. Dat bijstellen is een admin-klus van één minuut, geen developerwerk.
Het verschil met punten is dat het voordeel er meteen is. Geen sparen, geen inlossen, geen verlopen saldo. De klant die vorig jaar drie keer bij je kocht, ziet dat vandaag aan zijn prijs. Dat is ook het eerlijkste verhaal naar de klant: je hoeft niets te doen, je bent al member omdat je klant bent.
De lezers in de kassa: korting, express, achteraf betalen
De eerste lezer is de kortings-Function. Hij kijkt naar numberOfOrders en amountSpent, kiest welkomstdeal of tier, en zet een orderkorting neer. De kortingstitel is meteen het datakanaal naar de rest: "Member Silver · 5% · nog €350 tot Gold". Daar komen we bij de valkuilen op terug.
Eén detail in die eerste lezer: de welkomstdeal hangt aan numberOfOrders gelijk aan nul, niet aan de leeftijd van het account. Een echte eerste bestelling dus, niet een account dat net is aangemaakt om de deal te pakken. Het onderscheid klinkt klein, maar het is het verschil tussen een welkomstkorting en een kortingscode die iedereen elke keer opnieuw aanmaakt.
De tweede lezer is een delivery customization. Heeft de tier express: true, dan worden de spoedopties honderd procent gratis via een gerichte verzendkorting. Welke opties spoed zijn, is ook data: een lijstje zoekwoorden in hetzelfde json-veld. Standaardverzending blijft gewoon betaald, dit is een perk, geen gratis-verzenddrempel.
// extensions/member-shipping, ingekort tot de beslissende regels
const spent = Number(customer.amountSpent.amount);
const tier = config.tiers.filter((t) => spent >= t.min_spent).pop();
if (!tier?.express) {
return { operations: [] };
}
const needles = (config.express_needles ?? DEFAULT_NEEDLES).map((n) =>
n.toLowerCase(),
);
const candidates = input.cart.deliveryGroups.flatMap((group) =>
group.deliveryOptions
.filter((option) => {
const title = option.title?.toLowerCase() ?? '';
return needles.some((needle) => title.includes(needle));
})
.map((option) => ({
message: `Gratis ${option.title} · Member ${tier.name}`,
targets: [{ deliveryOption: { handle: option.handle } }],
value: { percentage: { value: 100 } },
})),
);De derde lezer is een payment customization, en die is de interessantste. Achteraf betalen is voor een gast of een vers account jouw risico en jouw transactiefee. Voor een bewezen klant is het conversie. Dus staat Klarna niet voor iedereen op dezelfde plek. Vier treden, de eerste die raakt wint: klein bedrag weg, hoge retourratio weg, jong account onderaan of verborgen, bewezen klant bovenaan.
// extensions/member-payment, ingekort tot de vier treden (de eerste die raakt wint)
// 1. Klein bedrag: BNPL weg.
const total = num(input.cart.cost.totalAmount.amount);
if (cfg.minAmount !== null && total !== null && total < cfg.minAmount) {
return { operations: hideAll() };
}
const customer = input.cart.buyerIdentity?.customer ?? null;
// 2. Retourratio boven de grens: BNPL weg.
const ratio = num(customer?.returnRatio?.value);
if (cfg.maxReturnRatio !== null && ratio !== null && ratio > cfg.maxReturnRatio) {
return { operations: hideAll() };
}
// 3. Gast of jong account: verbergen of onderaan.
if (cfg.afterDays !== null) {
let young = true;
if (customer?.createdAt) {
const ageMs =
new Date(input.shop.localTime.date).getTime() -
new Date(customer.createdAt).getTime();
young = ageMs < cfg.afterDays * MS_PER_DAY;
}
if (young) {
return { operations: cfg.young === 'bottom' ? moveAll(false) : hideAll() };
}
}
// 4. Bewezen klant: BNPL bovenaan.
const orders = num(customer?.numberOfOrders);
if (cfg.topAfterOrders !== null && orders !== null && orders >= cfg.topAfterOrders) {
return { operations: moveAll(true) };
}De lezers die het uitleggen: cadeau, balk, prijs
Een Function kan niets aan de winkelwagen toevoegen. Dus is het cadeau een tweeluik: een checkout-blok biedt De Tote aan zodra de tier hoog genoeg is, en een vierde Function zet die regel op nul euro. Cap op één stuk, het tweede exemplaar is gewoon betaald.
// extensions/member-gift, ingekort tot de beslissende regels
const spent = Number(customer.amountSpent.amount);
const tierIndex =
config.tiers.filter((t) => spent >= t.min_spent).length - 1;
const minIndex = config.tiers.findIndex(
(t) => t.name === config.gift.min_tier,
);
if (tierIndex < 0 || minIndex < 0 || tierIndex < minIndex) {
return { operations: [] };
}
const giftLine = input.cart.lines.find(
(line) =>
line.merchandise.__typename === 'ProductVariant' &&
line.merchandise.product.id === config.gift.product,
);
if (!giftLine) {
return { operations: [] };
}
const tier = config.tiers[tierIndex];
return {
operations: [
{
productDiscountsAdd: {
candidates: [
{
message: `${config.gift.label ?? 'Member-cadeau'} · ${tier.name}`,
targets: [{ cartLine: { id: giftLine.id, quantity: 1 } }],
value: { percentage: { value: 100 } },
},
],
selectionStrategy: ProductDiscountSelectionStrategy.First,
},
},
],
};De member-balk in de checkout is de ladder: één voortgangssegment per tier-traject, de actieve tier als badge, en "nog €350 tot Gold". Het blok leest de tiers uit het shop-veld via de Storefront API en de actuele stand uit de kortingstitel van de eerste Function. Een gast ziet de lege ladder als teaser, iemand met de welkomstdeal ziet hem op nul staan met "alles telt mee".
// extensions/checkout-member-bar/src/MemberBar.tsx, de twee stukken die ertoe doen
const CONFIG_QUERY = `#graphql
query MemberTiersConfig {
shop {
member: metafield(namespace: "custom", key: "member_tiers") { value }
}
}
`;
const tierTitle = titles.find((t) => /^Member \S+ · \d+%/.test(t));
if (tierTitle) {
const m = /^Member (\S+) · (\d+)%(?: · nog €(\d+) tot (\S+))?/.exec(tierTitle);
if (m) {En op de productpagina staat het voordeel al vóór de checkout. Een theme-snippet leest hetzelfde json-veld en de besteding van de ingelogde klant, en zet onder de prijs "Member Silver · jij betaalt €125,13". Gasten zien "Members betalen tot 8% minder" met een login-link. Bewust één stille regel, geen chip: de echte prijs blijft de hoofdrol houden.
{% comment %} snippets/diek-member.liquid, ingekort: de tier-bepaling {% endcomment %}
{%- liquid
assign diek_mb_cfg = shop.metafields.custom.member_tiers.value
if diek_mb_cfg and diek_mb_cfg.tiers and customer
assign diek_mb_naam = ''
assign diek_mb_pct = 0
for diek_mb_t in diek_mb_cfg.tiers
assign diek_mb_drempel = diek_mb_t.min_spent | times: 100
if customer.total_spent >= diek_mb_drempel and diek_mb_t.percent > diek_mb_pct
assign diek_mb_naam = diek_mb_t.name
assign diek_mb_pct = diek_mb_t.percent
endif
endfor
endif
-%}{% doc %}
DIEK member-prijs, direct onder elke productprijs (PDP én product cards).
Bewust typografisch en stil: één regel, geen chip. De prijs eronder mag de
aandacht niet van de échte prijs weghalen.
Ingelogde member: 'Member Bronze · jij betaalt €125,13'.
Gast: teaser met het hoogste tier-percentage (alleen in de volle variant).
Bron: snippets/diek-member.liquid → shop.metafields.custom.member_tiers.
@param {product} product - Het product waarvan de prijs getoond wordt.
@param {boolean} [compact] - Kaart-variant: korter, geen gast-teaser.
{% enddoc %}
{%- capture diek_mb -%}{%- render 'diek-member' -%}{%- endcapture -%}
{%- assign diek_mb = diek_mb | strip -%}
{%- unless diek_mb == 'nee' -%}
{%- assign diek_mb_d = diek_mb | split: '^^' -%}
{%- if diek_mb_d[0] == 'ja' -%}
{%- assign diek_mb_pct = diek_mb_d[2] | plus: 0 -%}
{%- assign diek_mb_rest = 100 | minus: diek_mb_pct -%}
{%- assign diek_mb_prijs = product.price | times: diek_mb_rest | divided_by: 100 -%}
{%- if compact -%}
<p class="diek-member-price diek-member-price--compact">
<span class="diek-member-price__tier">Member</span> {{ diek_mb_prijs | money }}
</p>
{%- else -%}
<p class="diek-member-price">
<span class="diek-member-price__tier">Member {{ diek_mb_d[1] }}</span>
· jij betaalt {{ diek_mb_prijs | money }}
</p>
{%- endif -%}
{%- elsif compact == false or compact == nil -%}
<p class="diek-member-price diek-member-price--gast">
Members betalen tot {{ diek_mb_d[1] }}% minder.
<a href="{{ routes.account_login_url }}">Log in</a>
</p>
{%- endif -%}
{%- endunless -%}De lezers in het account: membership-pagina en orderstatus
Dit is het stuk dat je normaal bij een loyalty-app koopt. Een volledige pagina in het klantaccount met je tier-badge, de ladder met stations, wat je nú krijgt, wat de volgende tier oplevert, en je besteding plus aantal orders. Boven de bestellijst staat een compacte versie, en op de orderstatuspagina van een verse bestelling zie je wat die order opleverde: "€149 van deze bestelling telt mee", of bij een tier-sprong de banner "Je bent nu Member Silver".
Ook hier geen eigen backend. De config komt uit het shop-veld via de Storefront API, de besteding is de som van de orders via de Customer Account API. De lus die loyalty-apps verkopen, bestellen, status zien, opnieuw bestellen, draait op twee queries.
// extensions/customer-member-blocks/src/member.ts: besteding = som van de orders (Customer Account API)
query MemberOrders {
customer {
orders(first: 100, sortKey: PROCESSED_AT, reverse: true) {
nodes {
totalPrice { amount currencyCode }
}
}
}
}De code van de kortings-Function
De eerste Function volledig, want hij is kort en hij is de bron van alles wat de andere lezers tonen. Input query en logica.
// extensions/member-deal/src/cart_lines_discounts_generate_run.graphql
query CartInput {
cart {
buyerIdentity {
customer {
numberOfOrders
amountSpent {
amount
}
}
}
}
shop {
memberConfig: metafield(namespace: "custom", key: "member_tiers") {
jsonValue
}
}
discount {
discountClasses
}
}106 regels codeToon code +Verberg code −
// extensions/member-deal/src/cart_lines_discounts_generate_run.ts
import {
DiscountClass,
OrderDiscountSelectionStrategy,
CartInput,
CartLinesDiscountsGenerateRunResult,
} from '../generated/api';
interface WelcomeConfig {
percent: number;
label?: string;
}
interface Tier {
min_spent: number;
name: string;
percent: number;
express?: boolean;
}
interface MemberConfig {
welcome?: WelcomeConfig;
tiers: Tier[];
}
export function parseMemberConfig(raw: unknown): MemberConfig | null {
if (!raw || typeof raw !== 'object') return null;
const { welcome, tiers } = raw as Partial<MemberConfig>;
if (!Array.isArray(tiers)) return null;
const valid = tiers
.filter(
(t): t is Tier =>
!!t &&
typeof t === 'object' &&
Number.isFinite((t as Tier).min_spent) &&
Number.isFinite((t as Tier).percent) &&
typeof (t as Tier).name === 'string',
)
.sort((a, b) => a.min_spent - b.min_spent);
if (valid.length === 0) return null;
const validWelcome =
welcome && Number.isFinite(welcome.percent) && welcome.percent > 0
? welcome
: undefined;
return { welcome: validWelcome, tiers: valid };
}
export function currentTier(tiers: Tier[], spent: number): Tier | undefined {
return tiers.filter((t) => spent >= t.min_spent).pop();
}
export function cartLinesDiscountsGenerateRun(
input: CartInput,
): CartLinesDiscountsGenerateRunResult {
if (!input.discount.discountClasses.includes(DiscountClass.Order)) {
return { operations: [] };
}
const config = parseMemberConfig(input.shop.memberConfig?.jsonValue);
const customer = input.cart.buyerIdentity?.customer;
if (!config || !customer) {
return { operations: [] };
}
let percent: number;
let message: string;
if (customer.numberOfOrders === 0 && config.welcome) {
percent = config.welcome.percent;
message = `${config.welcome.label ?? 'Welkomstdeal'} · ${percent}% op je eerste bestelling`;
} else {
const spent = Number(customer.amountSpent.amount);
const tier = currentTier(config.tiers, spent);
if (!tier || tier.percent <= 0) {
return { operations: [] };
}
percent = tier.percent;
const next = config.tiers[config.tiers.indexOf(tier) + 1];
message = next
? `Member ${tier.name} · ${percent}% · nog €${Math.ceil(next.min_spent - spent)} tot ${next.name}`
: `Member ${tier.name} · ${percent}%`;
}
return {
operations: [
{
orderDiscountsAdd: {
candidates: [
{
message,
targets: [
{
orderSubtotal: { excludedCartLineIds: [] },
},
],
value: {
percentage: { value: percent },
},
},
],
selectionStrategy: OrderDiscountSelectionStrategy.First,
},
},
],
};
}Twee valkuilen
De eerste: Functions zien elkaar niet, en een checkout-blok kan de besteding van de klant niet zelf opvragen. Alleen de kortings-Function kent amountSpent. Dus reist de stand via de kortingstitel naar de balk. "Member Silver · 5% · nog €350 tot Gold" is geen marketingzin, het is een datapakketje dat het blok met een regex uitleest. Verander je het formaat van die titel, dan breekt de ladder. Dat staat in beide bestanden als commentaar, en het moet daar blijven staan.
De tweede: twee bronnen voor dezelfde besteding. De checkout gebruikt Shopify's eigen amountSpent, de accountpagina telt de orders op. Bij refunds en annuleringen kunnen die een paar euro uiteenlopen. Voor de tier-bepaling maakt dat in de praktijk niets uit, maar de afspraak is expliciet: de checkout is de waarheid, het account vertelt het verhaal. Bouw je dit na, leg die afspraak dan vast voordat een klant met een screenshot komt.
Waar dit wel en niet voor is
Dit is voordeel op basis van historie, geen spaarprogramma. Wil je dat klanten punten sparen voor een reis of een product dat ze anders niet kopen, dan heb je een grootboek nodig en dat is app-werk. Referral-programma's, verjaardagsbonussen en punten voor reviews vallen ook buiten dit model. Een app doet die dingen beter dan we ze hier zouden nabouwen.
De ladder in de checkout en het cadeau-blok zijn checkout UI-extensies, en die draaien alleen op Shopify Plus. De Functions zelf, de productpagina en het klantaccount werken op elk plan. Dus zonder Plus heb je nog steeds de korting, de gratis express, de BNPL-ladder, de member-prijs en de membership-pagina. Alleen de balk in de kassa niet.
En het werkt alleen ingelogd. Een gast is voor de checkout niemand. Voor een merk met terugkerende klanten is dat geen drempel maar een filter, en de teaser op de productpagina geeft ze een reden om in te loggen. Voor een shop waar iedereen als gast afrekent, is dit de verkeerde eerste stap. Dezelfde stack draait trouwens ook de drop-gate uit ons bots-artikel, het is één programma met meerdere regels.
Veelgestelde vragen.
- Heb je Shopify Plus nodig voor dit loyaltyprogramma?
- Niet voor de kern. De vier Functions (korting, gratis express, betaalmethodes, cadeau), de member-prijs op de productpagina en de membership-pagina in het klantaccount werken op elk plan. Alleen de blokken ín de checkout, de ladder en het cadeau-aanbod, zijn checkout UI-extensies en die vragen Plus.
- Kunnen klanten hiermee ook punten sparen?
- Nee. Dit model geeft voordeel op basis van wat een klant al besteed heeft, direct in de prijs. Punten zijn een eigen valuta met een grootboek, en dat is werk voor een app. Vraag je wel eerst af of je punten nodig hebt: de meeste programma's gebruiken ze als omweg naar precies dit, korting voor terugkerende klanten.
- Wat gebeurt er als je een drempel of percentage aanpast?
- Je wijzigt het json-veld op de shop in de admin. Alle lezers, de Functions, de checkout-blokken, de productpagina en het klantaccount, lezen dat veld live. Er is geen deploy en geen sync nodig. Let wel op de volgorde: de tiers moeten oplopend op min_spent staan, de code sorteert ze maar rekent op unieke namen.
Diek ThunnissenFounder & lead developer. Bouwt, verbetert en migreert de Shopify-laag voor DTC- en B2B-merken. LinkedInPlan een gesprek →

