Eine praktische Schritt-für-Schritt-Anleitung: Baue ein inhaltsgetriebenes Stripe-Schaufenster auf Profound CMS – ein Katalog, den ein Händler ohne Code pflegt, zwei parametrische Routen, ein Headless-Warenkorb und ein Stripe-gehosteter Checkout.
Der fertige Store in Aktion – eine Kategorie durchstöbern, ein Produkt öffnen, in den Warenkorb legen, zur Kasse gehen.
Ein praxisnaher Leitfaden, der einen inhaltsgesteuerten Store auf Profound CMS aufbaut: ein Produktkatalog (Kategorien + Artikel), im CMS modelliert, Listing- und Detailseiten aus einem gemeinsamen Satz von Routen sowie ein Stripe-gehosteter Checkout, ausgeliefert als Headless-Komponente.
Das Rückgrat besteht aus handgeschriebenem Next.js plus dem Profound-Admin. Claude Code (über den Profound MCP) stemmt die Schwerarbeit bei drei Aufgaben – das Befüllen des Katalogs, das Verdrahten des Designsystems und das Schreiben der Storefront-Komponenten (einschließlich des Headless-Warenkorbs). Drei Teile: Einrichtung, Aufbau, Produktion.
Zahlungen in einer Zeile. Wir nutzen Stripe-hosted Checkout: Der Käufer zahlt auf der Seite von Stripe, nicht auf deiner. Deine App erledigt serverseitig nur zwei Dinge – eine Checkout-Session erstellen und einen Webhook verifizieren. Keine Kartenfelder, keine Stripe Elements, keine PCI-Belastung.
/products/{item_code}, /categories/{category_code}) plus eine statische /cart, alle aus einem Satz von Komponenten.useCart) und Stripe-gehosteten Checkout, wobei der Preis immer serverseitig über eine Stripe-Price-ID aufgelöst wird.curl -fsSL https://bun.sh/install | bashstripe login) für lokale Webhooks.gh) und ein Vercel-Konto, das mit GitHub verbunden ist.Profound trennt Content von Rendering:
category, item); eine mit dem Tag UI Element ist auf einer Seite platzierbar (nav, product_grid, …).meta.params.* in CEL, routeParams in React).cms-renderer; Stripe wird als normale API-Routen ergänzt.Die eine Regel, die den Aufbau bestimmt: CEL bindet nur string-/number-Felder. Skalare Chrome-Elemente (Marke in der Navigation, Footer, Überschriften) werden mit CEL gebunden, während alles, was reichhaltig oder eine Sammlung ist (ein Produktgitter, eine Bildergalerie, Rich Text), innerhalb der React-Komponente über den Routenparameter abgerufen wird. Und Stripe ist die Preis-Quelle der Wahrheit – der price im CMS dient nur der Anzeige; die Belastung wird immer serverseitig aus einer Stripe-Price-ID aufgelöst.
Zielzustand: ein kleiner veröffentlichter Katalog, die App ist angebunden, Stripe installiert, das Design sitzt – noch wird nichts gerendert.
Bei Profound anmelden (WorkOS-Auth). Eine Website namens store erstellen, dann deren Website-ID (die UUID in der Admin-URL) und einen Read-Tier-API-Key (Deployments → Create API key) kopieren. Die App liest nur; das spätere Katalog-Seeding läuft über den MCP, der separat authentifiziert.
bunx create-profound-next store
cd store
bun add stripe
Das Scaffold ist ein Next.js-App-Router-Projekt, das bereits für Profound vorkonfiguriert ist (cms-renderer-SDK, eine Catch-all-Route, ein generate-schemas-Script, ein <Refresher>). Styling ist keines enthalten. bun add stripe holt das Server-SDK – die einzige Zahlungsabhängigkeit, die ein gehosteter Checkout braucht.
Eigene Werte in .env.local eintragen:
# CMS
PROFOUND_API_KEY=<dein Read-Key>
NEXT_PUBLIC_PROFOUND_WEBSITE_ID=<deine Website-ID>
NEXT_PUBLIC_CMS_API_URL=https://cms.dev.tryprofound.com
NEXT_PUBLIC_BUNNY_CDN_URL=https://cms-profound.b-cdn.net # liefert CMS-gehostete Bilder aus
# Stripe
STRIPE_SECRET_KEY=sk_test_... # hier Testschlüssel; auf Live-Key umstellen, wenn du live gehst
STRIPE_WEBHOOK_SECRET=whsec_... # wird in Aufbau, Schritt 4, eingetragen
NEXT_PUBLIC_SITE_URL=http://localhost:3000
STRIPE_SECRET_KEY aus Stripe → Developers → API keys holen. Wir verwenden einen Testschlüssel (sk_test_…), damit beim Aufbau kein echtes Geld fließt; tausche ihn aus, sobald du echte Zahlungen annehmen möchtest. bun dev starten und localhost:3000 öffnen – der Starter rendert.
Hosted Checkout leitet den Browser auf eine Stripe-URL um, daher benötigt Stripe nur den geheimen Serversschlüssel – kein Publishable Key, kein clientseitiges Stripe-SDK.
category, product_image und item definierenDrei Custom Components erstellen (Components → Create new component) – die Datenquelle, also ohne UI-Element-Tag. Jede auf Active setzen.
Das CMS hat kein „Array von Bildern“-Feld, daher ist eine Galerie ein Array von Referenzen auf eine kleine product_image-Komponente. category und product_image (und auf Active setzen) vor item erstellen – ein Referenzfeld kann nur auf aktive Komponenten zielen.
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 von Referenzen → product_image), price (Number, Cent – nur Anzeige), currency (Select, usd), stripePriceId (Text), category (Reference → category), active (Boolean)Alle Felder optional lassen. Der Admin setzt Feldnamen auf Lower-Snake-Case („Stripe Price Id“ → stripe_price_id) – darauf greift dein Code zu, also lies die echten Namen anschließend aus generate-schemas aus. Wir nennen das routbare Handle code (nicht slug): Es ist sowohl der Route Slug als auch der Schlüssel für ein sauberes documents.getByCode später.
Die item-Komponente – code als Route Slug, images als Referenzen auf product_image, plus stripePriceId und eine category-Referenz.
bun run generate-schemas
Schreibt Zod-Schemas + Typen nach generated/cms-schemas.ts (categorySchema/Category, itemSchema/Item). Dient gleichzeitig als Verbindungstest – falsche Zugangsdaten schlagen hier fehl.
Den MCP einmal installieren und authentifizieren:
claude mcp add --transport http Profound http://107.21.107.99:8081/mcp
mcp__Profound__authenticate ausführen, den WorkOS-Flow abschließen, dann Claude instruieren:
Erstelle einen kleinen E-Commerce-Katalog für einen Store namens Edison’s Inventions – drei Kategorien und diese Produkte, jeweils mit einer kurzen, zeitgenauen
description, einempricein Cent,currency: "usd"undactive: 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)Jede Kategorie braucht
nameund den kleingeschriebenencode; jeder Artikelname, denselben kleingeschriebenencode, diedescription,price(in Cent),currencyundactive. Speichere alles indata/catalog.jsonund validiere es gegen unsere Komponentencategoryunditem. Anschließend per Profound MCP jedes Dokument als veröffentlicht anlegen: erst die Kategorien erstellen, ihre IDs sichern, dann die Artikel mitcategoryals Referenz –{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. Lege die Artikel parallel an.
Claude erstellt data/catalog.json, validiert es und verteilt parallele create_document-Aufrufe (status: "published"). Kategorien vor Artikeln befüllen, damit Referenzen auf bereits vorhandene IDs zeigen.
Die drei befüllten Kategorien, veröffentlicht und live.
Die acht befüllten Produkte, jeweils mit einer Kategorie verknüpft.
Der Katalog ist im CMS; jetzt bekommen ein paar Produkte einen realen Stripe-Preis – die Aufgabe des Händlers, erledigt in zwei Admin-Oberflächen, ganz ohne Code:
price_…) kopieren.stripePriceId einfügen, speichern.Das CMS verwaltet den Katalog; Stripe ist die Preisquelle; die Verbindung ist eine Zeichenkette, die der Händler einfügt. (Lieber automatisieren? Der offizielle Stripe MCP kann Products/Prices für dich anlegen – die zurückgegebenen IDs trägst du ebenso ein.)
Das Scaffold ist ungestylt. Lege eine DESIGN.md an (ein Tailwind-v4-@theme-Block + Tokens) im Projektstamm – dein eigener oder lade einen von refero.design herunter. Anschließend Claude mit Fokus auf Styling anweisen:
Lies die Design-Datei, die ich gerade hinzugefügt habe. Richte Tailwind ein, falls nötig, und verknüpfe das Theme und die Fonts, damit das Styling funktioniert. Verwende
next/fontfür Schriften – lade sie nicht zur Laufzeit von Google. Nur das Styling – baue noch keine Seiten oder Komponenten.
Prüfen, dass src/app/globals.css @import "tailwindcss"; plus den @theme-Block enthält und dass localhost:3000 die Tokens zeigt. Den Prompt knapp halten (bei offenen Formulierungen baut ein Agent schnell eine komplette Homepage), Schriften mit next/font laden, nie über einen Laufzeitimport von Google.
Optional – ein funktionierender Checkout braucht keine Bilder. Für Bilder: ein product_image-Dokument pro Bild anlegen (Upload ins image-Feld), dann diese Referenzen im images-Array des Produkts setzen. Eigene Produktfotos nutzen oder mit einem Bildmodell eine stimmige Serie erzeugen lassen (Claude kann auf Basis von DESIGN.md passende Prompts ableiten und mit einem Midjourney---sref eine einheitliche Optik sichern).
Das eigenständige
cms-rendererhat keinen Image-URL-Helper, daherbuildAssetUrlinsrc/lib/image.tseinbauen (~40 Zeilen) – es setztNEXT_PUBLIC_BUNNY_CDN_URLvoran und ergänzt die Erweiterung. Die Komponenten in Aufbau, Schritt 3, verwenden ihn.
Baue die Rendering-Schicht und den Checkout – am Ende steht ein echter Kauf im Testmodus.
Fünf Komponenten, jeweils Active und mit dem Tag UI Element (Settings → Tags), kein Route Slug:
nav → brand · product_grid → heading · product_detail → heading · cart_summary → heading · footer → text (alle Text)Der Tag UI Element sorgt dafür, dass eine Komponente in der Liste Add UI Element des Page Builders erscheint – Active allein reicht nicht. Jedes Feld ist ein Skalar (die Art, die CEL bindet); die eigentlichen Katalogdaten sind hier kein Feld – ProductGrid/ProductDetail holen sie in Schritt 3 über den Routenparameter.
bun run generate-schemas
Ein Prompt erstellt den Lese-Helper, die fünf Komponenten, den Warenkorb und das Registry:
Baue unsere Storefront in
src/mit dem Profound-cms-renderer-SDK.
src/lib/catalog.ts– ein serverseitiger CMS-Reader. Erzeuge einen Client mitgetCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! })auscms-renderer/lib/cms-api. ExportieregetItemByCode(code)→cms.documents.getByCode.query({ websiteId, schemaName: "item", code }), dasres.document.published_contentzurückgibt. ExportierelistItems(categoryCode?)→cms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }), mapperes.documentsauf.published_content, filtereactive !== false, und wenncategoryCodeübergeben wird, behalte nur Items, derencategory._refderdocument.idder Kategorie entspricht. ExportiereresolveImages(refs), das jedeitem.images-Referenz übercms.documents.get.query({ websiteId, id: ref._ref })auflöst und das Bildfeld mit dem eingebautenbuildAssetUrl(Teil 1, Schritt 8) in eine URL umwandelt.
src/components/– fünf UI-Element-Komponenten, die in der Catch-all-Route-Registry unter dem Komponentennamen registriert werden, snake_case wie im Admin:{ nav, product_grid, product_detail, cart_summary, footer }.NavundFooterlesen ihr Skalarfeld über diecontent-Prop (typisiert mitBlockComponentProps<T>auscms-renderer/lib/types).ProductGridundProductDetailsind asynchrone Serverkomponenten, dierouteParamslesen und auscatalog.tsnachladen:routeParams.<param>ist{ value, … }– auf.valuezugreifen, also ruftProductGridlistItems(routeParams.category_code?.value)auf (Karten verlinken auf/products/{code}) undProductDetailgetItemByCode(routeParams.item_code?.value)(Galerie viaresolveImages, Rich-Text-Beschreibung, Preis, Add-to-cart).CartSummaryrendert den Warenkorb aususeCartmit einem Pay-Button.formatPricein eine reinesrc/lib/format.tsauslagern, damit Client-Komponenten nicht das serverseitigecatalog.tsimportieren.
src/components/AddToCartButton.tsx– ein"use client"-Button mit den Props{ code, name, priceLabel }, deruseCart().addItem({ code, name, priceLabel, quantity: 1 })aufruft. InProductDetailverwenden.
src/lib/useCart.ts– ein Headless-Warenkorb: Positionen{ code, name, priceLabel, quantity }im State, persistiert inlocalStorage, mitaddItem/removeItem/updateQty/subtotalund einemcheckout(), das{ lines: [{ code, quantity }] }(nur Codes und Mengen – keine Preise) an/api/stripe/checkoutpostet und anschließend auf die zurückgegebeneurlumleitet.Alles mit unserem Designsystem stylen, als eigene Komponenten – nicht das Layout der Quellseite kopieren.
Drei Dinge, die du danach wissen solltest:
content ({ content }: BlockComponentProps<T>) – Felder als Top-Level-Props zu dekonstruieren, lässt den Block leer. Katalogdaten kommen aus routeParams + einem catalog.ts-Fetch, weil CEL keine Listen oder Galerien bindet. Der Warenkorb trägt Artikel-Codes, nie Preise.routeParams.<param> ist { value, schemaName, document } – auf .value zugreifen. Lesevorgänge liefern published_content, nicht .content. Registry-Schlüssel sind snake_case, passend zum Admin.@types/react/@types/react-dom auf v19 anheben – das Scaffold liefert v18, was asynchrone Server-Komponentenblöcke in React 19 bricht.Drei kurze Serverdateien – der gesamte Zahlungscode der App. Sie verwenden getItemByCode, sodass die Belastung serverseitig aufgelöst wird.
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 – jedes Item aus dem CMS auflösen, den Stripe-Preis belasten:
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getItemByCode } from "@/lib/catalog"; // serverseitig, Read-Tier-Key
export async function POST(req: Request) {
const { lines } = await req.json(); // [{ code, quantity }] — keine Preise vom Client
const line_items = await Promise.all(
lines.map(async ({ code, quantity }: { code: string; quantity: number }) => {
const item = await getItemByCode(code); // Server löst über das CMS auf
return { price: item!.stripe_price_id, quantity }; // Preis aus dem CMS, nie vom 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 leitet hierhin weiter
}
src/app/api/stripe/webhook/route.ts – das vertrauenswürdige Fulfillment-Signal:
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // RAW-Body – nötig für Signaturprüfung
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") {
// Fulfillment: Bestellung erfassen / Quittung senden.
}
return new Response(null, { status: 200 });
}
Notwendiger Scaffold-Fix: Das Scaffold leitet
src/proxy.tsjede/api/*an das CMS weiter, daher laufen deine Stripe-Routen nie. Lass sie zuerst passieren: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(); // lokal behandeln } return cmsProxy(request as unknown as Parameters<typeof cmsProxy>[0]); }; // die `export const config = { matcher: [...] }` des Scaffolds unverändert lassenPrüfen:
curl -X POST localhost:3000/api/stripe/webhook -d xgibtBad signaturezurück.
Stripe CLI für lokale Webhooks starten:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# das whsec_... in STRIPE_WEBHOOK_SECRET eintragen, bun dev neu starten
Das whsec_… gilt pro Sitzung. Zwei Regeln sichern das Ganze: Der Checkout ermittelt den Preis per code erneut über das CMS (ein manipuliertes Cart kann ihn nicht ändern), und der Webhook verifiziert die Signatur gegen den rohen Body.
Admin → Pages → dreimal Seite erstellen. Jede Route mit ihrem Dokument verknüpfen (Slug-Feld code):
/products/{item_code} → item/categories/{category_code} → category/cart – eine statische Seite (den Literal /cart eintragen, nicht /{cart})Für jede Route: Page Builder → Add UI Element → Custom → Komponenten in Reihenfolge hinzufügen, skalare Felder füllen (statischer Wert oder CEL), Publish.
/products/{item_code}: nav, product_detail, footer/categories/{category_code}: nav, product_grid, footer/cart: nav, cart_summary, footernav.brand und footer.text als statische Strings setzen; Überschriften als statische Bezeichnungen.
Der Page Builder auf der Kategorieroute – das Element product_grid ausgewählt, die Überschrift per CEL gebunden.
Der Page Builder auf der Produktroute – das Element product_detail auf einer Produktbindung.
Parametrischer Page-Builder-Knackpunkt: Auf den beiden parametrischen Routen bleiben hinzugefügte UI-Elemente nicht erhalten (Blöcke verwaisen, die Seite rendert leer). Bis das behoben ist, die
block_idsdieser Seiten direkt über den Profound MCPupdate_pageverdrahten und anschließend veröffentlichen. (Die statische/cartlässt sich normal verknüpfen.) Aus demselben Grund leitetProductGridseine Überschrift aus der geladenen Kategorie statt über CEL ab.
/categories/lighting → das Grid. Produkt anklicken → Detail + Add to cart. /cart → Pay./cart?status=success, und stripe listen zeigt checkout.session.completed.Eine gerenderte Produktseite – Galerie, Preis und Add-to-cart.
Der Warenkorb – Positionen und ein einzelner Pay-with-Stripe-Button.
Nur Produkte mit Preis sind kaufbar – kaufe eines der ~3 Produkte, die du in Schritt 6 bepreist hast.
Optional – internationalisieren. Übersetze jede Komponente (alle 35 Sprachen auf einmal), füge ein Segment
/{language}/…hinzu, das der eingebauten Systemkomponentelanguagezugeordnet ist, und wechsle CEL-gebundene Felder zudocuments.translated. Siehe das Airport-Directory-Tutorial, Teil 2, Schritt 7.
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # --public geht natürlich auch
Build-Befehl:
generated/cms-schemas.tsist gitignored, daher den Build darauf festnageln, die Dateien neu zu erzeugen –vercel.jsonhinzufügen:{ "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun run generate-schemas && next build" }
In Vercel: Add New → Project, store importieren und die Umgebungsvariablen eintragen – PROFOUND_API_KEY, NEXT_PUBLIC_PROFOUND_WEBSITE_ID, NEXT_PUBLIC_CMS_API_URL, NEXT_PUBLIC_BUNNY_CDN_URL, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (der Wert für den Deployment-Endpunkt, siehe unten) und NEXT_PUBLIC_SITE_URL (deine Produktions-URL). Deployen.
Dann den deployten Webhook verdrahten (der lokale stripe listen-Schlüssel galt nur lokal): Stripe → Developers → Webhooks → + Add endpoint → https://<prod>/api/stripe/webhook, Event checkout.session.completed. Das whsec_… nach Vercel kopieren und redeployen.
Fehlende Umgebungsvariablen = „lokal ok, in Produktion leer“ – der Deploy-Klassiker. Wir deployen hier mit Testschlüsseln; stelle STRIPE_SECRET_KEY und das Webhook-Secret auf deine Live-Werte um, sobald du echte Zahlungen akzeptieren möchtest.
Beides kommt mit dem Scaffold.
<Refresher> aktualisiert die Seite in der Vorschau, sobald ein Editor im Admin speichert – kein Redeploy nötig. (Er ist eine Vorschau für den Editor; Besucher sehen veröffentlichte Inhalte mit normaler Revalidation.)?edit_mode=true an jede URL hängen, um Bearbeitungs-Overlays zu sehen. Öffentliche Besucher bekommen die saubere Seite.Die fehlende Preview-Route ergänzen. Der Admin lädt sein Preview-iframe unter
/cms-preview_<path>; ohne diese Route gibt es 404. Ergänze sie:// src/app/cms-preview_/[...slug]/page.tsx import { ParametricRoutePreviewPage } from "cms-renderer/lib/renderer"; import { registry } from "../../registry"; // Registry in ein Shared-Modul auslagern export default async function Page({ params, searchParams }) { const { slug } = await params; const PreviewPage = ParametricRoutePreviewPage as any; // async RSC; React-19-Typen 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} />; }
src/app/cms-preview_/page.tsxebenfalls hinzufügen (gleich,slug: []) für das Segment-Root.
Eine inhaltsgesteuerte Stripe-Storefront: ein CMS-Katalog, Listing- + Detailseiten aus einem einzigen Routensatz und ein funktionierender gehosteter Checkout. KI hat den Katalog befüllt, das Design verdrahtet und den Katalogleser + Komponenten + Headless-Warenkorb geschrieben; du hast die Komponenten, die Stripe-Preisverknüpfungen, die drei Routen, das CEL-Chrome und drei kurze Stripe-Dateien erledigt. CEL bindet das Chrome; Komponenten holen den Katalog. Und Stripe blieb schlank – ein sessions.create-Aufruf und ein signierter Webhook, bei dem der Käufer auf der Stripe-eigenen Seite bezahlt.