En praktisk genomgång: bygg en innehållsdriven Stripe-butik på Profound CMS — en katalog som en handlare redigerar utan kod, två parametriska rutter, en headless-varukorg och Stripe-hosterad checkout.
Den färdiga butiken i rörelse — bläddra i en kategori, öppna en produkt, lägg i varukorg, checka ut.
En praktisk genomgång som bygger en innehållsdriven butik på Profound CMS: en produktkatalog (kategorier + artiklar) modellerad i CMS:et, list- och detaljsidor från ett och samma ruttnät, och en Stripe-hosterad kassa levererad som en headless-komponent.
Ryggraden är handskriven Next.js plus Profound-admin. Claude Code (via Profounds MCP) gör det tunga arbetet i tre uppgifter – sår in katalogen, kopplar in designsystemet och skriver butiksfrontskomponenterna (inklusive den headless-varukorgen). Tre delar: Setup, Build, Production.
Betalningar på en rad. Vi använder Stripe-hosterad Checkout: kunden betalar på Stripes sida, inte din. Din app gör bara två saker på serversidan – skapar en Checkout Session och verifierar en webhook. Inga kortfält, inga Stripe Elements, ingen PCI-börda.
/products/{item_code}, /categories/{category_code}) plus en statisk /cart, allt från en uppsättning komponenter.useCart) och Stripe-hosterad Checkout, där priset alltid löses på serversidan från ett Stripe Price ID.curl -fsSL https://bun.sh/install | bashstripe login) för lokala webhooks.gh) och ett Vercel-konto kopplat till GitHub.Profound separerar innehåll från rendering:
category, item); en taggad UI Element går att placera på en sida (nav, product_grid, …).meta.params.* i CEL, routeParams i React).cms-renderer; Stripe läggs till som vanliga API-rutter.Den enda regeln som formar bygget: CEL bindar endast string/number-fält. Så skalär krom (nav-varumärke, sidfot, rubriker) binds med CEL, medan allt som är rikt eller en samling (ett produktgalleri, en bildkarusell, rich text) hämtas inne i React-komponenten via ruttparam. Och Stripe är prisets sanning – CMS-fältet price är endast för visning; debiteringen löses alltid på serversidan från ett Stripe Price ID.
Slutläge: en liten publicerad katalog, appen kopplad för att läsa den, Stripe installerat, design på plats – inget renderat ännu.
Registrera dig hos Profound (WorkOS-auth). Skapa en webbplats med namnet store, kopiera sedan dess webbplats-ID (UUID:t i admin-URL:en) och en API-nyckel på read-nivå (Deployments → Create API key). Appen läser bara; katalogseedningen sker senare via MCP:n, som autentiserar separat.
bunx create-profound-next store
cd store
bun add stripe
Skelettet är ett Next.js App Router-projekt förkopplat för Profound (cms-renderer-SDK:n, en catch-all-rutt, ett generate-schemas-skript, en <Refresher>). Det levereras utan styling. bun add stripe hämtar server-SDK:n – det enda betalningsberoende som hosted checkout behöver.
Lägg till dina värden i .env.local:
# CMS
PROFOUND_API_KEY=<din read-nyckel>
NEXT_PUBLIC_PROFOUND_WEBSITE_ID=<ditt webbplats-id>
NEXT_PUBLIC_CMS_API_URL=https://cms.dev.tryprofound.com
NEXT_PUBLIC_BUNNY_CDN_URL=https://cms-profound.b-cdn.net # serverar CMS-hostade bilder
# Stripe
STRIPE_SECRET_KEY=sk_test_... # testnyckel här; byt till din live-nyckel när du går live
STRIPE_WEBHOOK_SECRET=whsec_... # fylls i i Build-steg 4
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Hämta STRIPE_SECRET_KEY från Stripe → Developers → API keys. Vi använder en testnyckel (sk_test_…) så bygget aldrig flyttar riktiga pengar; växla till din live-nyckel när du är redo att ta emot verkliga betalningar. Kör bun dev och öppna localhost:3000 – startern renderas.
Hosted checkout omdirigerar webbläsaren till en Stripe-URL, så serverns hemliga nyckel är allt Stripe behöver – ingen publishable key, inget klient-SDK för Stripe.
category, product_image och itemSkapa tre Custom Components (Components → Create new component) – datakällan, så ingen UI Element-tag. Sätt var och en till Active.
CMS:et har inget fält för "array av bild", så ett galleri är en array av referenser till en liten product_image-komponent. Skapa category och product_image (och sätt dem till Active) innan item – ett referensfält kan bara peka mot Active-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 av referenser → product_image), price (Number, cent – endast visning), currency (Select, usd), stripePriceId (Text), category (Reference → category), active (Boolean)Lämna alla fält valfria. Adminen gör fältnamn i lower_snake_case ("Stripe Price Id" → stripe_price_id) – det är det din kod använder, så läs tillbaka de riktiga namnen via generate-schemas senare. Vi kallar det routbara handtaget code (inte slug): det är Route Slug och nyckeln för en ren documents.getByCode-uppslagning senare.
Komponenten item – code som Route Slug, images som referenser till product_image, plus stripePriceId och en category-referens.
bun run generate-schemas
Skriver Zod-scheman + typer till generated/cms-schemas.ts (categorySchema/Category, itemSchema/Item). Dubbel funktion som anslutningskontroll – felaktiga referenser misslyckas här.
Installera och autentisera MCP:n en gång:
claude mcp add --transport http Profound http://107.21.107.99:8081/mcp
Kör mcp__Profound__authenticate, slutför WorkOS-flödet och instruera sedan Claude:
Generera en liten e-handelskatalog för en butik som heter Edison's Inventions — tre kategorier och dessa produkter, med en kort tidskorrekt
descriptionför var och en, ettpricei cent,currency: "usd"ochactive: 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)Varje kategori behöver ett
nameoch den gemenacode; varje artikel behöver ettname, den gemenacode,description,price(i cent),currencyochactive. Spara det tilldata/catalog.jsonoch validera mot våracategory- ochitem-komponenter. Använd sedan Profound MCP för att skapa varje som ett publicerat dokument: skapa kategorierna först, fånga deras ID:n och skapa sedan artiklarna medcategorysatt till en referens —{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. LämnastripePriceIdtomt än så länge. Kör artiklarna parallellt.
Claude skriver data/catalog.json, validerar den och fläktar ut parallella create_document-anrop (status: "published"). Så in kategorier innan artiklar så referenserna pekar på ID:n som redan finns.
De tre seedade kategorierna, publicerade och Live.
De åtta seedade produkterna, var och en kopplad till en kategori.
Katalogen ligger i CMS:et; ge nu några produkter ett riktigt Stripe-pris – handlarens uppgift, gjord i två adminpaneler, utan kod:
price_…).stripePriceId, spara.CMS:et håller katalogen; Stripe håller referenspriset; länken är en sträng som handlaren klistrar in. (Föredrar du att automatisera det? Den officiella Stripe MCP kan skapa Products/Prices åt dig – klistra in de returnerade ID:n på samma sätt.)
Skelettet levereras utan styling. Lägg en DESIGN.md (ett Tailwind v4-@theme-block + tokens) i projektroten – ditt eget, eller hämta ett från refero.design. Ge sedan Claude en prompt, begränsad till styling:
Läs designfilen jag just lade till. Sätt upp Tailwind om det behövs, koppla in temat och typsnitten så att stylingen fungerar. Använd
next/fontför typsnitt – hämta dem inte från Google vid körning. Endast styling – bygg inga sidor eller komponenter ännu.
Kontrollera att src/app/globals.css innehåller @import "tailwindcss"; + @theme-blocket och att localhost:3000 visar tokenserna. Håll prompten snäv (är den öppen bygger en agent en hel hemsida), och läs in typsnitt via next/font, aldrig en runtime-import från Google.
Valfritt – du kan nå en fungerande checkout utan bilder. För att lägga till dem: skapa ett product_image-dokument per bild (ladda upp i dess image-fält), referera sedan dem från produktens images-array. Ta med egna produktfoton eller generera en sammanhängande uppsättning med en bildmodell (låt Claude ta fram varumärkesanpassade prompts från DESIGN.md och lås en Midjourney---sref så varje bild matchar).
Det fristående
cms-rendererhar ingen hjälpfunktion för bild-URL:er, så levererabuildAssetUrlisrc/lib/image.ts(~40 rader) – den prefixarNEXT_PUBLIC_BUNNY_CDN_URLoch lägger till filändelsen. Komponenterna i Build-steg 3 använder den.
Bygg renderingslagret och checkouten så att det slutar i ett riktigt testläge-köp.
Fem komponenter, alla Active och taggade som UI Element (Settings → Tags), inga Route Slugs:
nav → brand · product_grid → heading · product_detail → heading ·
cart_summary → heading · footer → text (alla Text)Taggen UI Element gör att en komponent visas i Page Builders lista Add UI Element – Active räcker inte. Varje fält är ett skalärt (den typ CEL binder); själva katalogdatan är inte ett fält här – ProductGrid/ProductDetail hämtar den via ruttparam (steg 3).
bun run generate-schemas
En prompt bygger läshjälpen, de fem komponenterna, varukorgen och registret:
Bygg vår butiksfront i
src/, med Profoundscms-renderer-SDK.
src/lib/catalog.ts— en serverside-läsare för CMS. Skapa 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! })fråncms-renderer/lib/cms-api. ExporteragetItemByCode(code)→cms.documents.getByCode.query({ websiteId, schemaName: "item", code })som returnerarres.document.published_content. ExporteralistItems(categoryCode?)→cms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }), mappares.documentstill.published_content, filtreraactive !== falseoch, omcategoryCodeges, behåll artiklar varscategory._refmatchar kategorinsdocument.id. ExporteraresolveImages(refs)som löser varjeitem.images-referens viacms.documents.get.query({ websiteId, id: ref._ref })och gör om dess bildfält till en URL med den levereradebuildAssetUrl(del 1 steg 8).
src/components/— fem UI-elementkomponenter registrerade i catch-all-ruttens register efter komponentnamn, snake_case för att matcha admin:{ nav, product_grid, product_detail, cart_summary, footer }.NavochFooterläser sina skalfält från prop:encontent(typadBlockComponentProps<T>fråncms-renderer/lib/types).ProductGridochProductDetailär asynkrona serverkomponenter som läserrouteParamsoch hämtar fråncatalog.ts:routeParams.<param>är{ value, … }— läs.value, såProductGridanroparlistItems(routeParams.category_code?.value)(kort länkar till/products/{code}) ochProductDetailanropargetItemByCode(routeParams.item_code?.value)(galleri viaresolveImages, rich text-beskrivning, pris, Lägg i varukorg).CartSummaryrenderar varukorgen frånuseCartmed en Pay-knapp. BehållformatPricei ett rentsrc/lib/format.tsså klientkomponenter inte importerar serverendastcatalog.ts.
src/components/AddToCartButton.tsx— en"use client"-knapp som tar{ code, name, priceLabel }och anroparuseCart().addItem({ code, name, priceLabel, quantity: 1 }). Använd den iProductDetail.
src/lib/useCart.ts— en headless-varukorg: rader{ code, name, priceLabel, quantity }i state, persisterade tilllocalStorage, som exponeraraddItem/removeItem/updateQty/subtotaloch encheckout()som POST:ar{ lines: [{ code, quantity }] }(endast koder och kvantiteter — aldrig priser) till/api/stripe/checkout, och omdirigerar sedan till den returneradeurl.Styla allt med vårt designsystem, som våra egna komponenter — kopiera inte källsajtens layout.
Tre saker att känna till när det är klart:
content ({ content }: BlockComponentProps<T>) – destrukturera fält som topplistan props och blocket renderas tomt. Katalogdata kommer från routeParams + en catalog.ts-hämtning, eftersom CEL inte kan binda listor eller gallerier. Varukorgen bär artikelkoder, aldrig priser.routeParams.<param> är { value, schemaName, document } – läs .value. Läsningar returnerar published_content, inte .content. Registernycklar är snake_case för att matcha admin.@types/react/@types/react-dom till v19 – skelettet levereras med v18, vilket bryter asynkrona serverkomponentblock mot React 19.Tre korta serverfiler – den enda betalningskoden i appen. De återanvänder getItemByCode, så debiteringen löses på serversidan.
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 — lös varje artikel från CMS:et, debitera Stripes pris:
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getItemByCode } from "@/lib/catalog"; // server-side, read-tier-nyckel
export async function POST(req: Request) {
const { lines } = await req.json(); // [{ code, quantity }] — inga priser från klienten
const line_items = await Promise.all(
lines.map(async ({ code, quantity }: { code: string; quantity: number }) => {
const item = await getItemByCode(code); // servern löser från CMS:et
return { price: item!.stripe_price_id, quantity }; // pris från 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 omdirigerar hit
}
src/app/api/stripe/webhook/route.ts — den betrodda uppfyllnadssignalen:
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // RÅ-kropp — krävs för signaturverifiering
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") {
// uppfyll: registrera ordern / skicka kvitto.
}
return new Response(null, { status: 200 });
}
Obligatorisk skelettfix: skelettets
src/proxy.tsvidarebefordrar varje/api/*till CMS:et, så dina Stripe-rutter körs aldrig. Låt dem passera 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(); // hantera lokalt } return cmsProxy(request as unknown as Parameters<typeof cmsProxy>[0]); }; // behåll skelettets `export const config = { matcher: [...] }` oförändratKontrollera:
curl -X POST localhost:3000/api/stripe/webhook -d xreturnerarBad signature.
Kör Stripe CLI för lokala webhooks:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# kopiera whsec_... till STRIPE_WEBHOOK_SECRET, starta om bun dev
whsec_… är per session. Två regler bär säkerheten: checkout härleder priset från CMS:et med code (en manipulerad varukorg kan inte ändra det), och webhooken verifierar signaturen mot den råa kroppen.
Admin → Pages → Create page, tre gånger. Mappa varje parametrar till sin komponent (slug-fält code):
/products/{item_code} → item/categories/{category_code} → category/cart — en statisk sida (skriv in bokstavligen /cart, inte /{cart})För varje rutt: Page Builder → Add UI Element → Custom → lägg till komponenter i ordning, fyll i skalära fält (statiskt värde eller CEL), Publiser.
/products/{item_code}: nav, product_detail, footer/categories/{category_code}: nav, product_grid, footer/cart: nav, cart_summary, footerStäll in nav.brand och footer.text på statiska strängar; rubrikerna på statiska etiketter.
Page Builder på kategorirutten – elementet product_grid markerat, dess heading bunden via CEL.
Page Builder på produktrutten – elementet product_detail på en produktbinding.
Parametrisk Page Builder-fallgrop: på de två parametriska rutterna sparas inte tillagda UI-element (blocken blir föräldralösa och sidan renderas tom). Tills det är fixat, koppla de sidornas
block_idsdirekt via Profound MCPupdate_page, publicera sedan. (Den statiska/cartkopplas normalt.) Av samma anledning hämtarProductGridsin heading från kategorin den hämtar istället för via CEL.
/categories/lighting → gallret. Klicka på en produkt → detaljvy + Lägg i varukorg. /cart → Betala./cart?status=success, och stripe listen visar checkout.session.completed.En renderad produktsida – galleri, pris och Lägg i varukorg.
Varukorgen – orderrader och en enda Betala med Stripe-knapp.
Endast prissatta produkter går att köpa – köp en av de cirka tre du prissatte i steg 6.
Valfritt – internationalisera. Översätt varje komponent (alla 35 språk på en gång), lägg till ett
/{language}/…-segment mappat till den inbyggda systemkomponentenlanguage, och byt CEL-bindda fält tilldocuments.translated. Se handledningen för flygplatskatalogen, del 2 steg 7.
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # --public går också bra
Byggkommando:
generated/cms-schemas.tsär gitignored, så lås byggprocessen till att återskapa den – lägg tillvercel.json:{ "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun run generate-schemas && next build" }
I Vercel: Add New → Project, importera store och lägg till miljövariablerna – 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ärdet för den distribuerade slutpunkten, nedan) och NEXT_PUBLIC_SITE_URL (din prod-URL). Distribuera.
Koppla sedan den distribuerade webhooken (det lokala stripe listen-sekretet var bara för lokalt bruk): Stripe → Developers → Webhooks → + Add endpoint → https://<prod>/api/stripe/webhook, event checkout.session.completed. Kopiera dess whsec_… till Vercel och distribuera om.
Saknade miljövariabler = "fungerar lokalt, tomt i produktion" – vanligaste distributionsfällan. Vi distribuerar med testnycklar här; byt STRIPE_SECRET_KEY och webhooksekretet till dina live-värden när du är redo att ta emot riktiga betalningar.
Båda levereras med skelettet.
<Refresher> uppdaterar sidan du förhandsgranskar när en redaktör sparar i admin – ingen omdistribution. (Det är en förhandsvisning för redaktören; besökare ser publicerat innehåll på normal revalidering.)?edit_mode=true i valfri URL för redigeringsöverlagringar. Offentliga besökare får den rena sidan.Lägg till förhandsgranskningsrutten som skelettet utelämnar. Adminen laddar sin förhandsgransknings-iframe på
/cms-preview_<path>; utan den rutten får varje förhandsgranskning 404. Lägg till den:// src/app/cms-preview_/[...slug]/page.tsx import { ParametricRoutePreviewPage } from "cms-renderer/lib/renderer"; import { registry } from "../../registry"; // extrahera ditt register till en delad 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} />; }Lägg till
src/app/cms-preview_/page.tsxockså (samma,slug: []) för segmentroten.
En innehållsdriven Stripe-butik: en CMS-katalog, list- + detaljsidor från ett ruttnät och en fungerande hosted checkout. AI seedade katalogen, kopplade designen och skrev katalogläsaren + komponenterna + headless-varukorgen; du gjorde komponenterna, Stripe-prisernas länkar, de tre rutterna, CEL-kromen och tre korta Stripe-filer. CEL binder kromet; komponenterna hämtar katalogen. Och Stripe hölls liten – ett anrop till sessions.create och en signerad webhook, med kunden som betalar på Stripes egen sida.