Een praktische walkthrough: bouw een contentgestuurde Stripe-storefront op Profound CMS — een catalogus die een merchant zonder code kan bewerken, twee parametrische routes, een headless winkelwagen en door Stripe gehoste checkout.
De voltooide winkel in beweging — bekijk een categorie, open een product, voeg toe aan de winkelwagen, reken af.
Een praktische walkthrough die een contentgestuurde winkel op Profound CMS opbouwt: een productcatalogus (categorieën + items) gemodelleerd in het CMS, lijst- en detailpagina’s vanuit één set routes, en door Stripe gehoste checkout geleverd als een headless component.
De ruggengraat is met de hand geschreven Next.js plus de Profound-admin. Claude Code (via de Profound MCP) doet het zware werk bij drie taken — het vullen van de catalogus, het aansluiten van het designsysteem en het schrijven van de storefront-componenten (inclusief de headless winkelwagen). Drie delen: Setup, Build, Production.
Betalingen in één regel. We gebruiken door Stripe gehoste Checkout: de klant betaalt op Stripe’s pagina, niet op die van jou. Je app doet slechts twee dingen aan de serverkant — een Checkout Session aanmaken en één webhook verifiëren. Geen kaartvelden, geen Stripe Elements, geen PCI-last.
/products/{item_code}, /categories/{category_code}) plus een
statische /cart, allemaal vanuit één set componenten.useCart) en door Stripe gehoste checkout, waarbij de prijs altijd
server-side wordt opgelost vanuit een Stripe Price ID.curl -fsSL https://bun.sh/install | bashstripe login) voor lokale webhooks.gh) en een Vercel-account dat verbonden is met GitHub.Profound scheidt content van rendering:
category, item); één met het label UI Element is te plaatsen op een pagina
(nav, product_grid, …).meta.params.* in CEL, routeParams in React).cms-renderer; Stripe wordt toegevoegd als gewone API-routes.De ene regel die de build bepaalt: CEL bindt alleen string/number-velden. Dus
scalair chrome (nav-brand, footer, koppen) bind je met CEL, terwijl alles wat rijk is of een
collectie (een productraster, een afbeeldingsgalerij, rich text) binnen de React-component
wordt opgehaald op basis van de routeparameter. En Stripe is de bron van waarheid voor de prijs — de CMS-price
wordt alleen getoond; de afrekening komt altijd server-side uit een Stripe Price ID.
Eindsituatie: een kleine gepubliceerde catalogus, de app aangesloten om deze te lezen, Stripe geïnstalleerd, design aanwezig — nog niets gerenderd.
Meld je aan bij Profound (WorkOS-auth). Maak een website met de naam store, kopieer vervolgens het website ID (de UUID in de admin-URL) en een API-sleutel met leesrechten (Deployments → Create API key).
De app leest alleen; het vullen van de catalogus gebeurt later via de MCP, die afzonderlijk authenticatie vereist.
bunx create-profound-next store
cd store
bun add stripe
De scaffold is een Next.js App Router-project dat vooraf is ingericht voor Profound (de cms-renderer-SDK,
een catch-all route, een generate-schemas-script, een <Refresher>). Er worden geen stijlen meegeleverd.
bun add stripe haalt de server-SDK op — de enige betalingsafhankelijkheid die gehoste checkout nodig heeft.
Voeg je waarden toe aan .env.local:
# CMS
PROFOUND_API_KEY=<je read key>
NEXT_PUBLIC_PROFOUND_WEBSITE_ID=<je website-id>
NEXT_PUBLIC_CMS_API_URL=https://cms.dev.tryprofound.com
NEXT_PUBLIC_BUNNY_CDN_URL=https://cms-profound.b-cdn.net # levert CMS-gehoste afbeeldingen
# Stripe
STRIPE_SECRET_KEY=sk_test_... # test key hier; wissel naar je live key wanneer je live gaat
STRIPE_WEBHOOK_SECRET=whsec_... # wordt ingevuld in Build stap 4
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Haal STRIPE_SECRET_KEY uit Stripe → Developers → API keys. We gebruiken een testsleutel
(sk_test_…) zodat het bouwen nooit echt geld verplaatst; schakel naar je live sleutel zodra je
klaar bent om echte betalingen te accepteren. Voer bun dev uit en open localhost:3000 — de starter wordt gerenderd.
Gehoste checkout leidt de browser om naar een Stripe-URL, dus de servergeheime sleutel is alles wat Stripe nodig heeft — geen publishable key, geen client-SDK van Stripe.
category, product_image en itemMaak drie Custom Components (Components → Create new component) — de datalaag, dus geen UI Element-tag. Stel elk in op Active.
Het CMS heeft geen veld "array of image", dus een galerij is een array van referenties naar een kleine
product_image-component. Maak category en product_image (en zet ze op Active)
voordat je item maakt — een reference-veld kan alleen Active componenten als doel hebben.
category — name (Text), code (Text, Route Slug), description (Rich text), heroImage (Image)product_image — image (Image)item — name (Text), code (Text, Route Slug), description (Rich text), images (array van referenties → product_image), price (Number, cents — alleen weergave), currency (Select, usd), stripePriceId (Text), category (Reference → category), active (Boolean)Laat alle velden optioneel. De admin zet veldnamen in lagere snake_case ("Stripe Price Id" →
stripe_price_id) — daarop baseert je code zich, dus lees de echte namen straks terug uit
generate-schemas. We noemen de route-handle code (niet slug): het is de Route Slug en de sleutel voor een nette documents.getByCode-lookup later.
De component item — code als Route Slug, images als referenties naar product_image, plus stripePriceId en een category-referentie.
bun run generate-schemas
Schrijft Zod-schema’s + types naar generated/cms-schemas.ts (categorySchema/Category,
itemSchema/Item). Dubbele controle van de verbinding — met foutieve inloggegevens faalt dit hier.
Installeer en authenticatieer de MCP één keer:
claude mcp add --transport http Profound http://107.21.107.99:8081/mcp
Voer mcp__Profound__authenticate uit, rond de WorkOS-flow af en geef Claude vervolgens de prompt:
Genereer een kleine ecommerce-catalogus voor een winkel genaamd Edison’s Inventions — drie categorieën en deze producten, met een korte tijdsgetrouwe
descriptionvoor elk, eenpricein cents,currency: "usd", enactive: true:
- Lighting & Power (
code: lighting): Incandescent Lightbulb (incandescent-lightbulb, $24), Electric Dynamo (electric-dynamo, $890), Electric Pen (electric-pen, $49)- Sound Recording (
code: sound): Tinfoil Phonograph (tinfoil-phonograph, $249), Carbon Microphone (carbon-microphone, $59), Dictaphone (dictaphone, $179)- Motion Pictures (
code: motion): Kinetoscope (kinetoscope, $399), Kinetograph Camera (kinetograph, $549)Elke categorie heeft een
nameen die lowercasecode; elk item heeft eenname, die lowercasecode, dedescription,price(in cents),currencyenactive. Sla het op indata/catalog.jsonen valideer het tegen onze componentencategoryenitem. Maak vervolgens met de Profound MCP elk document aan als gepubliceerd: maak eerst de categorieën, leg hun ID’s vast, maak daarna de items metcategoryingesteld op een referentie —{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. LaatstripePriceIdvoorlopig leeg. Doe de items parallel.
Claude schrijft data/catalog.json, valideert deze en voert parallelle create_document-calls uit (status: "published"). Vul categorieën vóór items zodat de referenties naar ID’s wijzen die al bestaan.
De drie gevulde categorieën, gepubliceerd en Live.
De acht gevulde producten, elk gekoppeld aan een categorie.
De catalogus staat in het CMS; geef nu een paar producten een echte Stripe-prijs — het werk van de merchant, gedaan in twee adminpanelen, zonder code:
price_…).stripePriceId, sla op.Het CMS bevat de catalogus; Stripe bevat de prijs van record; de link is één string die de merchant plakt. (Lievere automatisering? De officiële Stripe MCP kan de Products/Prices voor je aanmaken — plak de teruggegeven ID’s op dezelfde manier.)
De scaffold wordt ongestyled geleverd. Plaats een DESIGN.md (een Tailwind v4-@theme-blok + tokens) in de projectroot — je eigen bestand, of download er een van refero.design.
Vraag vervolgens Claude, beperkt tot styling:
Lees het designbestand dat ik zojuist heb toegevoegd. Richt Tailwind in indien nodig, sluit vervolgens het thema en de fonts aan zodat de styling werkt. Gebruik
next/fontvoor fonts — laad ze niet op runtime van Google. Alleen de styling — bouw nog geen pagina’s of componenten.
Controleer of src/app/globals.css @import "tailwindcss"; + het @theme-blok bevat en dat localhost:3000 de tokens toont. Houd de prompt strak (open prompts laten een agent een hele homepage scaffolden) en laad fonts via next/font, nooit via een runtime-Google-import.
Optioneel — je kunt een werkende checkout bereiken zonder afbeeldingen. Voeg je ze toe? Maak dan per afbeelding een product_image-document (upload naar het veld image), en verwijs daar vervolgens naar vanuit de images-array van het product. Gebruik eigen productfoto’s, of genereer een samenhangende set met een imagemodel (laat Claude prompts afleiden die bij DESIGN.md passen en vergrendel één Midjourney --sref zodat elke opname overeenkomt).
De standalone
cms-rendererheeft geen helper voor image-URL’s, dus leverbuildAssetUrlaan insrc/lib/image.ts(~40 regels) — deze voegtNEXT_PUBLIC_BUNNY_CDN_URLals voorvoegsel toe en plakt de extensie eraan. De componenten in Build stap 3 gebruiken dit.
Bouw de renderlaag en de checkout, met als resultaat een echte aankoop in testmodus.
Vijf componenten, elk Active en gelabeld als UI Element (Settings → Tags), geen Route Slug:
nav → brand · product_grid → heading · product_detail → heading ·
cart_summary → heading · footer → text (allen Text)Het label UI Element zorgt ervoor dat een component verschijnt in de Add UI Element-lijst van de Page Builder — Active alleen is niet genoeg. Elk veld is een scalair (het type dat CEL bindt); de echte catalogusdata is geen veld hier — ProductGrid/ProductDetail halen die op basis van de routeparameter op (stap 3).
bun run generate-schemas
Eén prompt bouwt de leeshulp, de vijf componenten, de cart en de registry:
Bouw onze storefront in
src/, met gebruik van de Profound-cms-renderer-SDK.
src/lib/catalog.ts— een server-side CMS-reader. Maak een client metgetCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! })uitcms-renderer/lib/cms-api. ExporteergetItemByCode(code)→cms.documents.getByCode.query({ websiteId, schemaName: "item", code })en laatres.document.published_contentteruggeven. ExporteerlistItems(categoryCode?)→cms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }), mapres.documentsnaar.published_content, filteractive !== false, en alscategoryCodeis meegegeven behoud je items waarvancategory._refgelijk is aan dedocument.idvan de categorie. ExporteerresolveImages(refs)die elke referentie uititem.imagesophaalt viacms.documents.get.query({ websiteId, id: ref._ref })en het beeldveld omzet in een URL met de aangeleverdebuildAssetUrl(Deel 1 stap 8).
src/components/— vijf UI-elementcomponenten geregistreerd in de registry van de catch-all route per componentnaam, snake_case om overeen te komen met de admin:{ nav, product_grid, product_detail, cart_summary, footer }.NavenFooterlezen hun scalair veld van decontent-prop (getypt alsBlockComponentProps<T>uitcms-renderer/lib/types).ProductGridenProductDetailzijn async servercomponenten dierouteParamslezen en de data ophalen uitcatalog.ts:routeParams.<param>is{ value, … }— lees.value, dusProductGridroeptlistItems(routeParams.category_code?.value)aan (kaarten linken naar/products/{code}) enProductDetailroeptgetItemByCode(routeParams.item_code?.value)aan (galerij viaresolveImages, rich-textbeschrijving, prijs, knop Toevoegen aan winkelwagen).CartSummaryrendert de winkelwagen vanuituseCartmet een knop Betalen. PlaatsformatPricein een puresrc/lib/format.tszodat clientcomponenten de server-onlycatalog.tsniet importeren.
src/components/AddToCartButton.tsx— een"use client"-knop met{ code, name, priceLabel }dieuseCart().addItem({ code, name, priceLabel, quantity: 1 })aanroept. Gebruik deze binnenProductDetail.
src/lib/useCart.ts— een headless winkelwagen: orderregels{ code, name, priceLabel, quantity }in state, opgeslagen inlocalStorage, metaddItem/removeItem/updateQty/subtotalen eencheckout()dat{ lines: [{ code, quantity }] }(alleen codes en hoeveelheden — nooit prijzen) POST naar/api/stripe/checkout, en daarna doorstuurt naar de geretourneerdeurl.Style alles met ons designsysteem, als onze eigen componenten — kopieer de layout van de bron-site niet.
Drie dingen om te weten nadat dit is uitgevoerd:
content ({ content }: BlockComponentProps<T>) — als je velden als
top-level props destructureert rendert het block leeg. Catalogusdata komt binnen via routeParams + een fetch in
catalog.ts, omdat CEL geen lijsten of galerijen kan binden. De winkelwagen draagt item-codes, nooit prijzen.routeParams.<param> is { value, schemaName, document } — lees .value. Leescalls geven
published_content terug, niet .content. Registry-sleutels zijn snake_case om overeen te komen met de admin.@types/react/@types/react-dom naar v19 — de scaffold levert v18, wat async
servercomponent-blocks breekt tegenover React 19.Drie korte serverbestanden — de enige betalingscode in de app. Ze hergebruiken getItemByCode, zodat de prijs server-side wordt bepaald.
src/lib/stripe.ts:
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
src/app/api/stripe/checkout/route.ts — haal elk item op uit het CMS, reken af tegen de Stripe-prijs:
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getItemByCode } from "@/lib/catalog"; // server-side, sleutel met leesrechten
export async function POST(req: Request) {
const { lines } = await req.json(); // [{ code, quantity }] — geen prijzen van de client
const line_items = await Promise.all(
lines.map(async ({ code, quantity }: { code: string; quantity: number }) => {
const item = await getItemByCode(code); // server haalt op uit het CMS
return { price: item!.stripe_price_id, quantity }; // prijs uit het CMS, nooit van de client
})
);
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items,
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/cart?status=success`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/cart?status=cancelled`,
});
return NextResponse.json({ url: session.url }); // client leidt hierheen om
}
src/app/api/stripe/webhook/route.ts — het vertrouwde fulfillment-signaal:
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // RAUWE body — vereist voor handtekeningcontrole
const sig = req.headers.get("stripe-signature")!;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Bad signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
// fulfill: order registreren / ontvangstbewijs versturen.
}
return new Response(null, { status: 200 });
}
Benodigde scaffoldfix: de scaffold stuurt
src/proxy.tselke/api/*door naar de CMS, waardoor je Stripe-routes nooit draaien. Laat deze eerst passeren:import { createCmsProxy } from "cms-renderer/lib/proxy"; import { NextResponse, type NextRequest } from "next/server"; import { cmsConfig } from "@/lib/cms-config"; const cmsProxy = createCmsProxy({ upstream: cmsConfig.cmsUrl }); const LOCAL_API_PREFIXES = ["/api/stripe"]; export const proxy = async (request: NextRequest) => { if (LOCAL_API_PREFIXES.some((p) => request.nextUrl.pathname.startsWith(p))) { return NextResponse.next(); // lokaal afhandelen } return cmsProxy(request as unknown as Parameters<typeof cmsProxy>[0]); }; // laat de `export const config = { matcher: [...] }` van de scaffold ongewijzigdControle:
curl -X POST localhost:3000/api/stripe/webhook -d xgeeftBad signatureterug.
Laat de Stripe CLI draaien voor lokale webhooks:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# kopieer de whsec_... naar STRIPE_WEBHOOK_SECRET, start bun dev opnieuw
De whsec_… is per sessie. Twee regels dragen de beveiliging: checkout leidt de prijs opnieuw af
uit het CMS via code (een gemanipuleerde winkelwagen kan deze niet veranderen), en de webhook verifieert de
handtekening tegen de rauwe body.
Admin → Pages → Create page, drie keer. Koppel elke parameter aan de bijbehorende component (slug-veld code):
/products/{item_code} → item/categories/{category_code} → category/cart — een statische pagina (voer letterlijk /cart in, niet /{cart})Voor elke route: Page Builder → Add UI Element → Custom → voeg componenten in de juiste volgorde toe, vul de scalair velden (statische waarde of CEL), Publish.
/products/{item_code}: nav, product_detail, footer/categories/{category_code}: nav, product_grid, footer/cart: nav, cart_summary, footerStel nav.brand en footer.text in op statische strings; koppen op statische labels.
De Page Builder op de categorie-route — het element product_grid geselecteerd, de heading gebonden via CEL.
De Page Builder op de productroute — het element product_detail op een productbinding.
Parametrische Page Builder-valkuil: op de twee parametrische routes blijven UI-elementen niet bewaard (blocks raken verweesd en de pagina rendert leeg). Totdat dit is opgelost, koppel je die pagina’s
block_idsrechtstreeks via de Profound MCPupdate_page, en publiceer je daarna. (De statische/cartkoppelt zich normaal.) Om dezelfde reden haaltProductGridzijn heading af van de categorie die hij ophaalt, in plaats van via CEL.
/categories/lighting → het raster. Klik op een product → detail + Voeg toe aan winkelwagen. /cart → Betalen./cart?status=success, en stripe listen toont
checkout.session.completed.Een gerenderde productpagina — galerij, prijs en Voeg toe aan winkelwagen.
De winkelwagen — orderregels en één Pay-with-Stripe-knop.
Alleen geprijsde items zijn te kopen — koop één van de ~3 die je in stap 6 hebt geprijsd.
Optioneel — internationaliseren. Vertaal elke component (alle 35 talen tegelijk), voeg een
/{language}/…-segment toe dat is gekoppeld aan de ingebouwde System-componentlanguage, en schakel CEL-gebonden velden over opdocuments.translated. Zie de vliegveld-directory-tutorial, Deel 2 stap 7.
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # --public is ook prima
Build-opdracht:
generated/cms-schemas.tsstaat in .gitignore, dus pin de build zodat deze opnieuw genereert — voegvercel.jsontoe:{ "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun run generate-schemas && next build" }
In Vercel: Add New → Project, importeer store, en voeg de omgevingsvariabelen toe — PROFOUND_API_KEY,
NEXT_PUBLIC_PROFOUND_WEBSITE_ID, NEXT_PUBLIC_CMS_API_URL, NEXT_PUBLIC_BUNNY_CDN_URL,
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (de waarde voor het gedeployde endpoint, hieronder), en
NEXT_PUBLIC_SITE_URL (je productie-URL). Deploy.
Koppel vervolgens de gedeployde webhook (de lokale stripe listen-sleutel was alleen lokaal): Stripe
→ Developers → Webhooks → + Add endpoint → https://<prod>/api/stripe/webhook, event
checkout.session.completed. Kopieer de whsec_… naar Vercel en deploy opnieuw.
Ontbrekende omgevingsvariabelen = "lokaal werkt het, in productie blanco" — dé deploy-valkuil nummer 1. We deployen hier met testsleutels; wissel STRIPE_SECRET_KEY en de webhook-sleutel naar je live waarden zodra je echte betalingen wilt accepteren.
Beide worden meegeleverd met de scaffold.
<Refresher> werkt de pagina bij die je aan het previewen bent zodra een editor in de admin opslaat — geen redeploy nodig. (Het is een preview voor de editor; bezoekers zien gepubliceerde content bij normale revalidatie.)?edit_mode=true toe aan elke URL voor edit-overlays. Publieke bezoekers zien de schone pagina.Voeg de preview-route toe die de scaffold weglaat. De admin laadt zijn preview-iframe op
/cms-preview_<path>; zonder die route krijgt elke preview een 404. Voeg deze toe:// src/app/cms-preview_/[...slug]/page.tsx import { ParametricRoutePreviewPage } from "cms-renderer/lib/renderer"; import { registry } from "../../registry"; // haal je registry naar een gedeelde module export default async function Page({ params, searchParams }) { const { slug } = await params; const PreviewPage = ParametricRoutePreviewPage as any; // async RSC; React 19-types return <PreviewPage registry={registry} apiKey={process.env.PROFOUND_API_KEY ?? ""} websiteId={process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID ?? ""} cmsUrl={process.env.NEXT_PUBLIC_CMS_API_URL ?? "https://cms.dev.tryprofound.com"} params={Promise.resolve({ slug })} searchParams={searchParams} />; }Voeg ook
src/app/cms-preview_/page.tsxtoe (zelfde,slug: []) voor de segmentroot.
Een contentgestuurde Stripe-storefront: een CMS-catalogus, lijst + detailpagina’s vanuit één set routes
en een werkende gehoste checkout. AI vulde de catalogus, sloot het design aan en schreef de catalogusreader + componenten + headless winkelwagen; jij deed de componenten, de Stripe-prijskoppelingen, de drie routes, de CEL-chrome en drie korte Stripe-bestanden. CEL bindt de chrome;
componenten halen de catalogus op. En Stripe bleef klein — één sessions.create-call en één
ondertekende webhook, met de shopper die betaalt op Stripe’s eigen pagina.