En praktisk gennemgang: byg en indholdsbaseret Stripe-butik på Profound CMS — et katalog, en forhandler kan redigere uden kode, to parametriske ruter, en headless kurv og Stripe-hostet checkout.
Den færdige butik i bevægelse — gennemse en kategori, åbn et produkt, læg i kurv, gennemfør betaling.
En praktisk gennemgang, der bygger en indholdsbaseret butik på Profound CMS: et produktkatalog (kategorier + varer) modelleret i CMS'et, liste- og detaljesider fra ét sæt ruter og en Stripe-hostet checkout, leveret som en headless komponent.
Rygraden er håndskrevet Next.js plus Profound-admin. Claude Code (via Profound MCP) tager sig af det tunge arbejde på tre opgaver — seedning af kataloget, tilkobling af designsystemet og skrivning af butikskomponenterne (inklusive den headless kurv). Tre dele: Opsætning, Opbygning, Produktion.
Betalinger på én linje. Vi bruger Stripe-hostet Checkout: kunden betaler på Stripes side, ikke din. Din app gør kun to ting server-side — opretter en Checkout Session og verificerer ét webhook. Ingen kortfelter, ingen Stripe Elements, ingen PCI-byrde.
/products/{item_code}, /categories/{category_code}) plus en statisk /cart, alle fra ét sæt komponenter.useCart) og Stripe-hostet checkout, hvor prisen altid løses server-side fra et Stripe Price ID.curl -fsSL https://bun.sh/install | bashstripe login) til lokale webhooks.gh) og en Vercel-konto forbundet til GitHub.Profound adskiller indhold fra rendering:
category, item); en med tagget UI Element kan placeres på en side (nav, product_grid, …).meta.params.* i CEL, routeParams i React).cms-renderer; Stripe tilføjes som almindelige API-ruter.Den ene regel, der former byggeriet: CEL binder kun string/number-felter. Så skalart krom (nav-mærke, footer, overskrifter) bindes med CEL, mens alt rigere eller en samling (et produktgrid, et billedgalleri, rich text) hentes inde i React-komponenten via rutens parameter. Og Stripe er prisens sandhedskilde — CMS-feltet price er kun til visning; betalingen afgøres altid server-side fra et Stripe Price ID.
Sluttilstand: et lille publiceret katalog, appen forbundet til at læse det, Stripe installeret, design på plads — intet renderet endnu.
Tilmeld dig hos Profound (WorkOS-auth). Opret et website med navnet store, kopier derefter dets website ID (UUID'en i admin-URL'en) og en read-tier API key (Deployments → Create API key). Appen læser kun; katalogseedningen kører senere gennem MCP'en, som autentificerer separat.
bunx create-profound-next store
cd store
bun add stripe
Scaffoldet er et Next.js App Router-projekt forudtrådet til Profound (cms-renderer-SDK'et, en catch-all-rute, et generate-schemas-script, en <Refresher>). Det leveres uden styling. bun add stripe henter server-SDK'et — den eneste betalingsafhængighed hosted checkout behøver.
Tilføj dine værdier til .env.local:
# CMS
PROFOUND_API_KEY=<din read key>
NEXT_PUBLIC_PROFOUND_WEBSITE_ID=<dit website-id>
NEXT_PUBLIC_CMS_API_URL=https://cms.dev.tryprofound.com
NEXT_PUBLIC_BUNNY_CDN_URL=https://cms-profound.b-cdn.net # leverer CMS-hostede billeder
# Stripe
STRIPE_SECRET_KEY=sk_test_... # testnøgle her; skift til din live-nøgle, når du går live
STRIPE_WEBHOOK_SECRET=whsec_... # udfyldes i Opbygning, trin 4
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Hent STRIPE_SECRET_KEY fra Stripe → Developers → API keys. Vi bruger en testnøgle (sk_test_…), så byggeriet aldrig flytter rigtige penge; skift til din live-nøgle, når du er klar til at tage imod rigtige betalinger. Kør bun dev og åbn localhost:3000 — starteren renderes.
Hosted checkout omdirigerer browseren til en Stripe-URL, så serverens hemmelige nøgle er alt, Stripe behøver — ingen publicerbar nøgle, intet klient-Stripe-SDK.
category, product_image og itemOpret tre Custom Components (Components → Create new component) — datakilden, så ingen UI Element-tag. Sæt hver til Active.
CMS'et har intet felt "array of image", så et galleri er et array af referencer til en lille product_image-komponent. Opret category og product_image (og sæt dem aktive) før item — et referencefelt kan kun pege på aktive komponenter.
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 af referencer → product_image), price (Number, cents — kun visning), currency (Select, usd), stripePriceId (Text), category (Reference → category), active (Boolean)Lad alle felter være valgfrie. Admin'en laver felt-navnene om til lower_snake_case ("Stripe Price Id" → stripe_price_id) — det er det, din kode bruger, så læs de rigtige navne tilbage fra generate-schemas bagefter. Vi navngiver den rutbare nøgle code (ikke slug): den er Route Slug og nøglen til et rent documents.getByCode-opslag senere.
Komponenten item — code som Route Slug, images som referencer til product_image, plus stripePriceId og en category-reference.
bun run generate-schemas
Skriver Zod-schemas + typer til generated/cms-schemas.ts (categorySchema/Category, itemSchema/Item). Fungerer også som forbindelsestjek — forkerte legitimationsoplysninger fejler her.
Installer og autentificér MCP'en én gang:
claude mcp add --transport http Profound http://107.21.107.99:8081/mcp
Kør mcp__Profound__authenticate, gennemfør WorkOS-flowet, og prompt derefter Claude:
Generér et lille e-handelskatalog til en butik kaldet Edison's Inventions — tre kategorier og disse produkter med en kort tidsvarende
descriptionfor hver, enpricei cents,currency: "usd", ogactive: 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)Hver kategori skal have et
nameog dencodei små bogstaver; hver vare skal have etname, dencodei små bogstaver,description,price(i cents),currencyogactive. Gem det idata/catalog.jsonog valider det mod vores komponentercategoryogitem. Brug derefter Profound MCP til at oprette hver som et publiceret dokument: opret kategorierne først, fang deres ID'er, og opret derefter varerne medcategorysat til en reference —{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. LadstripePriceIdstå tomt for nu. Kør varerne parallelt.
Claude skriver data/catalog.json, validerer det og skyder parallelle create_document-kald af (status: "published"). Seed kategorier før varer, så referencerne peger på ID'er, der allerede findes.
De tre seedede kategorier, publiceret og live.
De otte seedede produkter, hver knyttet til en kategori.
Kataloget ligger i CMS'et; giv nu et par produkter en rigtig Stripe-pris — forhandlerens opgave, gjort i to administrationspaneler, uden kode:
price_…).stripePriceId, gem.CMS'et holder kataloget; Stripe holder prisen som kilde til sandhed; forbindelsen er én streng, som forhandleren indsætter. (Vil du automatisere det? Den officielle Stripe MCP kan oprette produkterne/priserne for dig — indsæt de returnerede ID'er på samme måde.)
Scaffoldet leveres ustylet. Læg en DESIGN.md (et Tailwind v4 @theme-blok + tokens) i projektrødder — din egen, eller hent en fra refero.design. Prompt derefter Claude, begrænset til styling:
Læs designfilen, jeg lige har tilføjet. Opsæt Tailwind om nødvendigt, og forbind derefter temaet og skrifterne, så stylingen virker. Brug
next/fonttil skrifter — indlæs dem ikke fra Google ved kørsel. Kun stylingen — byg ikke sider eller komponenter endnu.
Bekræft, at src/app/globals.css har @import "tailwindcss"; + @theme-blokken, og at localhost:3000 viser tokens. Hold prompten stram (åben prompt, en agent scaffold'er en hel hjemmeside), og indlæs skrifter via next/font, aldrig en kørende Google-import.
Valgfrit — du kan nå en fungerende checkout uden billeder. Vil du tilføje dem: Opret et product_image-dokument pr. billede (upload i dets image-felt), og referér derefter disse fra produktets images-array. Medbring dine egne produktfotos, eller generér et sammenhængende sæt med en billedmodel (lad Claude udlede on-brand prompts fra DESIGN.md og lås en Midjourney --sref, så hvert billede matcher).
Det selvstændige
cms-rendererhar ingen helper til billed-URL, så importérbuildAssetUrltilsrc/lib/image.ts(~40 linjer) — den præfikserNEXT_PUBLIC_BUNNY_CDN_URLog tilføjer filtype. Komponenterne i Opbygning, trin 3 bruger den.
Byg renderingslaget og checkout'en, og slut af med et rigtigt køb i testtilstand.
Fem komponenter, hver Active og tagget UI Element (Settings → Tags), ingen Route Slug:
nav → brand · product_grid → heading · product_detail → heading · cart_summary → heading · footer → text (alle Text)Tagget UI Element er det, der får en komponent til at dukke op i Page Builders liste Add UI Element — Active alene er ikke nok. Hvert felt er en skalar (den slags, CEL binder); selve katalogdataene er ikke et felt her — ProductGrid/ProductDetail henter dem via rutens parameter (trin 3).
bun run generate-schemas
Én prompt bygger read-helperen, de fem komponenter, kurven og registret:
Byg vores butik i
src/ved hjælp af Profoundcms-renderer-SDK'et.
src/lib/catalog.ts— en server-side CMS-læser. Opret en klient medgetCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! })fracms-renderer/lib/cms-api. EksportérgetItemByCode(code)→cms.documents.getByCode.query({ websiteId, schemaName: "item", code }), der returnererres.document.published_content. EksportérlistItems(categoryCode?)→cms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }), mapres.documentstil.published_content, filtréractive !== false, og hviscategoryCodeer angivet, behold varer hviscategory._refmatcher kategoriensdocument.id. EksportérresolveImages(refs), der opløser hveritem.images-reference viacms.documents.get.query({ websiteId, id: ref._ref })og gør feltet image til en URL med den importeredebuildAssetUrl(Del 1, trin 8).
src/components/— fem UI-elementkomponenter registreret i catch-all-rutens register efter komponentnavn, snake_case for at matche admin:{ nav, product_grid, product_detail, cart_summary, footer }.NavogFooterlæser deres skalare felt viacontent-proppen (typetBlockComponentProps<T>fracms-renderer/lib/types).ProductGridogProductDetailer asynkrone serverkomponenter, der læserrouteParamsog henter fracatalog.ts:routeParams.<param>er{ value, … }— læs.value, såProductGridkalderlistItems(routeParams.category_code?.value)(kort linker til/products/{code}), ogProductDetailkaldergetItemByCode(routeParams.item_code?.value)(galleri viaresolveImages, rich-text-beskrivelse, pris, Læg i kurv).CartSummaryrender kurven frauseCartmed en Betal-knap. BeholdformatPricei en rensrc/lib/format.ts, så klientkomponenter ikke importerer server-onlycatalog.ts.
src/components/AddToCartButton.tsx— en"use client"-knap, der tager{ code, name, priceLabel }og kalderuseCart().addItem({ code, name, priceLabel, quantity: 1 }). Brug den iProductDetail.
src/lib/useCart.ts— en headless kurv: linjeposter{ code, name, priceLabel, quantity }i state, gemt ilocalStorage, der eksponereraddItem/removeItem/updateQty/subtotalog encheckout(), der POST'er{ lines: [{ code, quantity }] }(kun koder og mængder — aldrig priser) til/api/stripe/checkoutog derefter omdirigerer til den returneredeurl.Style det hele med vores designsystem, som vores egne komponenter — kopier ikke kilde-sidens layout.
Tre ting, du skal vide, når det er kørt:
content ({ content }: BlockComponentProps<T>) — destrukturer felter som topniveau-propper, og blokken render tom. Katalogdata hentes fra routeParams + et kald til catalog.ts, fordi CEL ikke kan binde lister eller gallerier. Kurven bærer varens koder, aldrig priser.routeParams.<param> er { value, schemaName, document } — læs .value. Læsninger returnerer published_content, ikke .content. Registreringsnøgler er snake_case for at matche admin.@types/react/@types/react-dom til v19 — scaffoldet leverer v18, som bryder asynkrone serverkomponentblokke mod React 19.Tre korte serverfiler — den eneste betalingskode i appen. De genbruger getItemByCode, så betalingen afgøres server-side.
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 — opløs hver vare fra CMS'et, opkræv Stripe-prisen:
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getItemByCode } from "@/lib/catalog"; // server-side, read-tier-nøgle
export async function POST(req: Request) {
const { lines } = await req.json(); // [{ code, quantity }] — ingen priser fra klienten
const line_items = await Promise.all(
lines.map(async ({ code, quantity }: { code: string; quantity: number }) => {
const item = await getItemByCode(code); // serveren slår op i CMS'et
return { price: item!.stripe_price_id, quantity }; // pris fra CMS, aldrig klient
})
);
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 }); // klienten omdirigerer hertil
}
src/app/api/stripe/webhook/route.ts — det betroede fuldførelsessignal:
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // RÅ body — krævet for signaturverificering
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: registrér ordren / send kvittering.
}
return new Response(null, { status: 200 });
}
Nødvendig scaffold-fix: scaffoldets
src/proxy.tsvideresender alle/api/*til CMS'et, så dine Stripe-ruter aldrig kører. Lad dem passere først: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(); // håndtér lokalt } return cmsProxy(request as unknown as Parameters<typeof cmsProxy>[0]); }; // behold scaffoldets `export const config = { matcher: [...] }` uændretBekræft:
curl -X POST localhost:3000/api/stripe/webhook -d xreturnererBad signature.
Kør Stripe CLI til lokale webhooks:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# kopiér whsec_... til STRIPE_WEBHOOK_SECRET, genstart bun dev
whsec_… er per session. To regler bærer sikkerheden: checkout genudleder prisen fra CMS'et efter code (en manipuleret kurv kan ikke ændre den), og webhooken verificerer signaturen mod den rå body.
Admin → Pages → Create page, tre gange. Kortlæg hver parameter til sin komponent (slug-felt code):
/products/{item_code} → item/categories/{category_code} → category/cart — en statisk side (indtast bogstaveligt /cart, ikke /{cart})For hver rute: Page Builder → Add UI Element → Custom → tilføj komponenter i rækkefølge, udfyld skalare felter (statisk værdi eller CEL), Publicér.
/products/{item_code}: nav, product_detail, footer/categories/{category_code}: nav, product_grid, footer/cart: nav, cart_summary, footerSæt nav.brand og footer.text til statiske strenge; overskrifter til statiske etiketter.
Page Builder på kategori-ruten — elementet product_grid valgt, dets heading bundet via CEL.
Page Builder på produkt-ruten — elementet product_detail på en produktbinding.
Parametrisk Page Builder-faldgrube: på de to parametriske ruter gemmes tilføjede UI-elementer ikke (blokke bliver forladte, og siden render tom). Indtil det er rettet, forbind disse siders
block_idsdirekte via Profound MCPupdate_page, og publicér derefter. (Statisk/cartknytter fint.) Af samme grund henterProductGridsin heading fra den kategori, den læser, i stedet for via CEL.
/categories/lighting → grid'et. Klik på et produkt → detalje + Læg i kurv. /cart → Betal./cart?status=success, og stripe listen viser checkout.session.completed.En renderet produktside — galleri, pris og Læg i kurv.
Kurven — linjeposter og en enkelt Betal-med-Stripe-knap.
Kun prissatte varer kan købes — køb en af de ~3, du prissatte i trin 6.
Valgfrit — internationalisér. Oversæt hver komponent (alle 35 sprog på én gang), tilføj et segment
/{language}/…map til den indbyggede System-komponentlanguage, og skift CEL-bundne felter tildocuments.translated. Se tutorialen for lufthavnsoversigten, Del 2, trin 7.
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # --public er også fint
Build-kommando:
generated/cms-schemas.tser i .gitignore, så lås buildet til at regenerere den — tilføjvercel.json:{ "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun run generate-schemas && next build" }
I Vercel: Add New → Project, importér store, og tilføj miljøvariablerne — PROFOUND_API_KEY, NEXT_PUBLIC_PROFOUND_WEBSITE_ID, NEXT_PUBLIC_CMS_API_URL, NEXT_PUBLIC_BUNNY_CDN_URL, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (værdien for den deployede endpoint, nedenfor) og NEXT_PUBLIC_SITE_URL (din prod-URL). Deploy.
Kobl derefter det deployede webhook på (den lokale stripe listen-hemmelighed var kun lokal): Stripe → Developers → Webhooks → + Add endpoint → https://<prod>/api/stripe/webhook, event checkout.session.completed. Kopiér dens whsec_… til Vercel, og deploy igen.
Manglende miljøvariabler = "virker lokalt, tom i prod" — deploy-faldgrube nr. 1. Vi deployer med testnøgler her; skift STRIPE_SECRET_KEY og webhook-hemmeligheden til dine live værdier, når du er klar til at modtage rigtige betalinger.
Begge følger med scaffoldet.
<Refresher> opdaterer siden, du forhåndsviser, når en redaktør gemmer i admin — ingen redeploy. (Det er en forhåndsvisning for redaktøren; besøgende ser publiceret indhold med normal revalidering.)?edit_mode=true til enhver URL for redigeringsoverlays. Offentlige besøgende får den rene side.Tilføj preview-ruten, som scaffoldet udelader. Admin'en indlæser sin preview-iframe på
/cms-preview_<path>; uden den rute giver hver preview 404. Tilføj den:// src/app/cms-preview_/[...slug]/page.tsx import { ParametricRoutePreviewPage } from "cms-renderer/lib/renderer"; import { registry } from "../../registry"; // træk dit register ud i et delt modul export default async function Page({ params, searchParams }) { const { slug } = await params; const PreviewPage = ParametricRoutePreviewPage as any; // async RSC; React 19-typer 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} />; }Tilføj også
src/app/cms-preview_/page.tsx(samme,slug: []) til segmentroden.
En indholdsbaseret Stripe-butik: et CMS-katalog, liste + detaljesider fra ét sæt ruter og en fungerende hosted checkout. AI seedede kataloget, forbandt designet og skrev kataloglæseren + komponenterne + den headless kurv; du lavede komponenterne, Stripe-prislinkene, de tre ruter, CEL-kromet og tre korte Stripe-filer. CEL binder kromet; komponenterne henter kataloget. Og Stripe forblev lille — ét sessions.create-kald og én signeret webhook, med kunden, der betaler på Stripes egen side.