Una guida pratica: costruisci uno storefront Stripe guidato dai contenuti su Profound CMS — un catalogo che un merchant modifica senza codice, due route parametriche, un carrello headless e un checkout ospitato da Stripe.
Il negozio finito in azione — sfoglia una categoria, apri un prodotto, aggiungi al carrello, effettua il checkout.
Una guida pratica che realizza un negozio basato sui contenuti in Profound CMS: un catalogo prodotti (categorie + articoli) modellato nel CMS, pagine di elenco e di dettaglio da un solo set di route e checkout ospitato da Stripe distribuito come componente headless.
La spina dorsale è Next.js scritto a mano più l'admin di Profound. Claude Code (via Profound MCP) svolge il grosso del lavoro in tre attività — caricamento del catalogo, collegamento del design system e scrittura dei componenti dello storefront (incluso il carrello headless). Tre parti: Configurazione, Build, Produzione.
Pagamenti in una sola riga. Usiamo Stripe-hosted Checkout: l'acquirente paga sulla pagina di Stripe, non sulla tua. La tua app fa solo due cose lato server — crea una Checkout Session e verifica un webhook. Niente campi carta, niente Stripe Elements, niente oneri PCI.
/products/{item_code}, /categories/{category_code}) più una
/cart statica, tutte dallo stesso set di componenti.useCart) e checkout ospitato da Stripe, con il prezzo sempre
risolto lato server da un ID Price di Stripe.curl -fsSL https://bun.sh/install | bashstripe login) per i webhook locali.gh) e un account Vercel connesso a GitHub.Profound separa contenuti e rendering:
category, item); uno contrassegnato come UI Element può essere posizionato su una pagina
(nav, product_grid, …).meta.params.* in CEL, routeParams in React).cms-renderer; Stripe viene aggiunto come normali route API.L'unica regola che dà forma alla build: CEL collega solo campi string/number. Quindi
il chrome scalare (brand della nav, footer, intestazioni) è collegato con CEL, mentre tutto ciò che è ricco o una
collezione (una griglia prodotti, una galleria immagini, rich text) viene recuperato all'interno del componente React
tramite il parametro della route. E Stripe è la fonte della verità per i prezzi — il price nel CMS
serve solo per la visualizzazione; l'addebito viene sempre risolto lato server da un ID Price di Stripe.
Stato finale: un piccolo catalogo pubblicato, l'app collegata per leggerlo, Stripe installato, design in posto — niente ancora in rendering.
Registrati su Profound (auth WorkOS). Crea un sito chiamato store, poi copia il suo ID sito web
(lo UUID nell'URL dell'admin) e una API key di livello read (Deployments → Create API key).
L'app effettua solo letture; il seed del catalogo più avanti passa dal MCP, che si autentica
separatamente.
bunx create-profound-next store
cd store
bun add stripe
Lo scaffold è un progetto Next.js App Router preconfigurato per Profound (l'SDK cms-renderer,
una route catch-all, uno script generate-schemas, un <Refresher>). Non include styling.
bun add stripe installa l'SDK server — l'unica dipendenza di pagamenti necessaria per il checkout ospitato.
Aggiungi i tuoi valori a .env.local:
# CMS
PROFOUND_API_KEY=<la tua read key>
NEXT_PUBLIC_PROFOUND_WEBSITE_ID=<il tuo website id>
NEXT_PUBLIC_CMS_API_URL=https://cms.dev.tryprofound.com
NEXT_PUBLIC_BUNNY_CDN_URL=https://cms-profound.b-cdn.net # serve le immagini ospitate dal CMS
# Stripe
STRIPE_SECRET_KEY=sk_test_... # chiave di test qui; sostituisci con la chiave live quando vai live
STRIPE_WEBHOOK_SECRET=whsec_... # da compilare nello step Build 4
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Recupera STRIPE_SECRET_KEY da Stripe → Developers → API keys. Usiamo una chiave di test
(sk_test_…) così la build non muove denaro reale; passa alla tua chiave live quando sei pronto
a ricevere pagamenti reali. Esegui bun dev e apri localhost:3000 — lo starter si vede.
Il checkout ospitato reindirizza il browser a un URL Stripe, quindi la chiave segreta server è tutto ciò che serve a Stripe — nessuna chiave pubblicabile, nessun SDK client di Stripe.
category, product_image e itemCrea tre Componenti personalizzati (Components → Create new component) — la fonte dati, quindi niente tag UI Element. Imposta ciascuno come Active.
Il CMS non ha un campo "array of image", quindi una gallery è un'array di riferimenti a un piccolo
componente product_image. Crea category e product_image (e impostali come Active)
prima di item — un campo reference può puntare solo a componenti Active.
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 di reference → product_image), price (Number, centesimi — solo display), currency (Select, usd), stripePriceId (Text), category (Reference → category), active (Boolean)Lascia tutti i campi opzionali. L'admin trasforma i nomi campo in lower-snake-case ("Stripe Price Id" →
stripe_price_id) — sono quelli che il tuo codice userà, quindi leggi i nomi reali da
generate-schemas dopo. Chiamiamo il handle routable code (non slug): è il Route
Slug e la chiave per una lookup pulita documents.getByCode più avanti.
Il componente item — code come Route Slug, images come reference a product_image, più stripePriceId e un reference category.
bun run generate-schemas
Scrive gli schemi Zod + tipi in generated/cms-schemas.ts (categorySchema/Category,
itemSchema/Item). Funziona anche come check di connessione — credenziali errate falliscono qui.
Installa e autentica il MCP una volta:
claude mcp add --transport http Profound http://107.21.107.99:8081/mcp
Esegui mcp__Profound__authenticate, completa il flusso WorkOS, poi chiedi a Claude:
Genera un piccolo catalogo ecommerce per un negozio chiamato Edison's Inventions — tre categorie e questi prodotti, con una breve
descriptionaccurata per il periodo, unpricein centesimi,currency: "usd"eactive: 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)Ogni categoria deve avere
namee quelcodeminuscolo; ogni item deve averename, quelcodeminuscolo, ladescription,price(in centesimi),currencyeactive. Salvalo indata/catalog.jsone validalo contro i nostri componenticategoryeitem. Poi usa Profound MCP per creare ciascuno come documento pubblicato: crea prima le categorie, cattura i loro ID, poi crea gli item concategoryimpostato a una reference —{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. LasciastripePriceIdvuoto per ora. Esegui gli item in parallelo.
Claude scrive data/catalog.json, lo valida e lancia chiamate create_document in parallelo (status: "published").
Popola prima le categorie così i riferimenti puntano a ID già esistenti.
Le tre categorie caricate, pubblicate e Live.
Gli otto prodotti caricati, ciascuno collegato a una categoria.
Il catalogo è nel CMS; ora assegna a qualche prodotto un prezzo reale su Stripe — compito del merchant, fatto in due pannelli admin, senza codice:
price_…).stripePriceId, salva.Il CMS contiene il catalogo; Stripe è la fonte di verità dei prezzi; il collegamento è una stringa che il merchant incolla. (Preferisci automatizzare? L'Stripe MCP ufficiale può creare per te Products/Prices — incolla gli ID restituiti allo stesso modo.)
Lo scaffold arriva senza stile. Inserisci un DESIGN.md (un blocco @theme Tailwind v4 + token) alla
radice del progetto — il tuo, oppure scaricane uno da refero.design.
Poi chiedi a Claude, limitandoti allo styling:
Leggi il file di design che ho appena aggiunto. Configura Tailwind se serve, poi collega il tema e i font così lo styling funziona. Usa
next/fontper i font — non caricarli da Google a runtime. Solo lo styling — non costruire pagine o componenti per ora.
Verifica che src/app/globals.css abbia @import "tailwindcss"; + il blocco @theme e che
localhost:3000 mostri i token. Mantieni il prompt preciso (se è aperto, un agente ti costruisce un
intero homepage), e carica i font via next/font, mai con import Google a runtime.
Opzionale — puoi arrivare a un checkout funzionante anche senza immagini. Per aggiungerle: crea un
documento product_image per immagine (carica nel suo campo image), poi referenziale dal campo images
nel prodotto. Porta le tue foto prodotto, o generane un set coerente con un modello di immagini (fai ricavare
a Claude prompt in linea con DESIGN.md e fissa un --sref Midjourney così ogni scatto combacia).
Il
cms-rendererstandalone non ha un helper per gli URL delle immagini, quindi integrabuildAssetUrlinsrc/lib/image.ts(~40 righe) — anteponeNEXT_PUBLIC_BUNNY_CDN_URLe aggiunge l'estensione. I componenti dello step 3 della Build lo usano.
Costruisci il layer di rendering e il checkout, arrivando a un acquisto reale in modalità test.
Cinque componenti, ciascuno Active e contrassegnato come UI Element (Settings → Tags), senza Route Slug:
nav → brand · product_grid → heading · product_detail → heading ·
cart_summary → heading · footer → text (tutti Text)Il tag UI Element è ciò che rende un componente visibile nella lista Add UI Element del Page Builder —
Active da solo non basta. Ogni campo è uno scalare (quelli che CEL collega); i veri dati di catalogo non sono qui —
ProductGrid/ProductDetail li recuperano tramite il parametro della route (step 3).
bun run generate-schemas
Un unico prompt produce l'helper di lettura, i cinque componenti, il carrello e il registry:
Costruisci il nostro storefront in
src/, usando l'SDKcms-rendererdi Profound.
src/lib/catalog.ts— un lettore CMS lato server. Crea un client congetCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! })dacms-renderer/lib/cms-api. EsportagetItemByCode(code)→cms.documents.getByCode.query({ websiteId, schemaName: "item", code })che restituisceres.document.published_content. EsportalistItems(categoryCode?)→cms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }), mappares.documentsa.published_content, filtraactive !== false, e secategoryCodeè fornito tieni gli item il cuicategory._refcorrisponde all'iddella categoria. EsportaresolveImages(refs)che risolve ogni referenceitem.imagestramitecms.documents.get.query({ websiteId, id: ref._ref })e trasforma il suo campo immagine in un URL conbuildAssetUrlintegrato (Parte 1 step 8).
src/components/— cinque componenti UI element registrati nel registry della route catch-all per nome del componente, snake_case per combaciare con l'admin:{ nav, product_grid, product_detail, cart_summary, footer }.NaveFooterleggono il loro campo scalare dalla propcontent(tipataBlockComponentProps<T>dacms-renderer/lib/types).ProductGrideProductDetailsono server component async che leggonorouteParamse recuperano dacatalog.ts:routeParams.<param>è{ value, … }— leggi.value, quindiProductGridchiamalistItems(routeParams.category_code?.value)(le card linkano a/products/{code}) eProductDetailchiamagetItemByCode(routeParams.item_code?.value)(gallery viaresolveImages, descrizione rich-text, prezzo, Add-to-cart).CartSummaryrenderizza il carrello dauseCartcon un pulsante Pay. MantieniformatPricein un purosrc/lib/format.tscosì i componenti client non importano ilcatalog.tssolo server.
src/components/AddToCartButton.tsx— un pulsante"use client"che accetta{ code, name, priceLabel }e chiamauseCart().addItem({ code, name, priceLabel, quantity: 1 }). Usalo all'interno diProductDetail.
src/lib/useCart.ts— un carrello headless: line item{ code, name, priceLabel, quantity }in state, persistiti inlocalStorage, che esponeaddItem/removeItem/updateQty/subtotale uncheckout()che fa POST di{ lines: [{ code, quantity }] }(solo codici e quantità — mai prezzi) verso/api/stripe/checkout, poi reindirizza all'urlrestituito.Stila tutto con il nostro design system, come componenti nostri — non copiare il layout del sito sorgente.
Tre cose da sapere dopo l'esecuzione:
content ({ content }: BlockComponentProps<T>) — destruttura i campi come
prop top-level e il blocco renderizza vuoto. I dati di catalogo arrivano da routeParams + fetch
in catalog.ts, perché CEL non può collegare liste o gallery. Il carrello trasporta i codici degli item, mai i prezzi.routeParams.<param> è { value, schemaName, document } — leggi .value. Le letture restituiscono
published_content, non .content. Le chiavi del registry sono in snake_case per combaciare con l'admin.@types/react/@types/react-dom alla v19 — lo scaffold arriva con la v18, che rompe i blocchi
server component async su React 19.Tre file server brevi — l'unico codice pagamenti dell'app. Riutilizzano getItemByCode, così
l'addebito si risolve lato server.
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 — risolve ogni item dal CMS, addebita il prezzo su Stripe:
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getItemByCode } from "@/lib/catalog"; // lato server, chiave di lettura
export async function POST(req: Request) {
const { lines } = await req.json(); // [{ code, quantity }] — nessun prezzo dal client
const line_items = await Promise.all(
lines.map(async ({ code, quantity }: { code: string; quantity: number }) => {
const item = await getItemByCode(code); // il server risolve dal CMS
return { price: item!.stripe_price_id, quantity }; // prezzo dal CMS, mai dal 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 }); // il client reindirizza qui
}
src/app/api/stripe/webhook/route.ts — il segnale affidabile per il fulfillment:
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // corpo RAW — necessario per verificare la firma
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: registra l'ordine / invia una ricevuta.
}
return new Response(null, { status: 200 });
}
Fix necessario dello scaffold:
src/proxy.tsnello scaffold inoltra ogni/api/*al CMS, quindi le tue route Stripe non girano mai. Lasciale passare per prime: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(); // gestisci localmente } return cmsProxy(request as unknown as Parameters<typeof cmsProxy>[0]); }; // lascia invariato lo `export const config = { matcher: [...] }` dello scaffoldVerifica:
curl -X POST localhost:3000/api/stripe/webhook -d xrestituisceBad signature.
Esegui Stripe CLI per i webhook locali:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copia il whsec_... in STRIPE_WEBHOOK_SECRET, riavvia bun dev
Il whsec_… è per sessione. Due regole tutelano la sicurezza: il checkout ricalcola il prezzo
dal CMS tramite code (un carrello manomesso non può cambiarlo) e il webhook verifica la
firma sul corpo raw.
Admin → Pages → Create page, tre volte. Mappa ogni parametro al suo componente (campo slug code):
/products/{item_code} → item/categories/{category_code} → category/cart — una pagina statica (inserisci letteralmente /cart, non /{cart})Per ogni route: Page Builder → Add UI Element → Custom → aggiungi i componenti in ordine, compila i campi scalari (valore statico o CEL), Publish.
/products/{item_code}: nav, product_detail, footer/categories/{category_code}: nav, product_grid, footer/cart: nav, cart_summary, footerImposta nav.brand e footer.text come stringhe statiche; le intestazioni come etichette statiche.
Il Page Builder sulla route category — l'elemento product_grid selezionato, il suo heading collegato con CEL.
Il Page Builder sulla route product — l'elemento product_detail su un binding di prodotto.
Gotcha del Page Builder parametrico: sulle due route parametriche, aggiungere elementi UI non persiste (i blocchi restano orfani e la pagina renderizza vuota). Finché non viene risolto, collega i
block_idsdi quelle pagine direttamente via il Profound MCPupdate_page, poi pubblica. (La/cartstatica si collega normalmente.) Per lo stesso motivo,ProductGridricava la sua heading dalla categoria che recupera anziché tramite CEL.
/categories/lighting → la griglia. Clicca un prodotto → dettaglio + Aggiungi al carrello. /cart → Pay./cart?status=success, e stripe listen mostra
checkout.session.completed.Una pagina prodotto renderizzata — gallery, prezzo e Aggiungi al carrello.
Il carrello — line item e un unico pulsante Pay-with-Stripe.
Sono acquistabili solo gli item con prezzo — compra uno dei ~3 che hai prezzato nello step 6.
Opzionale — internazionalizza. Traduci ogni componente (tutte le 35 lingue in una volta), aggiungi un segmento
/{language}/…mappato al componente Systemlanguageintegrato, e passa i campi collegati con CEL adocuments.translated. Vedi il tutorial dell'aeroporto, Parte 2 step 7.
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # anche --public va bene
Build command:
generated/cms-schemas.tsè ignorato da git, quindi blocca la build per rigenerarlo — aggiungivercel.json:{ "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun run generate-schemas && next build" }
In Vercel: Add New → Project, importa store e aggiungi le variabili d'ambiente — PROFOUND_API_KEY,
NEXT_PUBLIC_PROFOUND_WEBSITE_ID, NEXT_PUBLIC_CMS_API_URL, NEXT_PUBLIC_BUNNY_CDN_URL,
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (il valore dell'endpoint deployato, sotto), e
NEXT_PUBLIC_SITE_URL (il tuo URL di prod). Distribuisci.
Poi collega il webhook in produzione (il secret di stripe listen era solo locale): Stripe
→ Developers → Webhooks → + Add endpoint → https://<prod>/api/stripe/webhook, evento
checkout.session.completed. Copia il whsec_… in Vercel e ridistribuisci.
Variabili d'ambiente mancanti = "funziona in locale, vuoto in prod" — la gotcha #1 del deploy. Distribuiamo con
chiavi di test qui; passa STRIPE_SECRET_KEY e il secret del webhook ai valori live quando sei pronto a
ricevere pagamenti reali.
Entrambi sono inclusi nello scaffold.
<Refresher> aggiorna la pagina che stai visualizzando in anteprima quando un editor
salva nell'admin — nessuna ridistribuzione. (È un'anteprima per l'editor; i visitatori vedono i contenuti pubblicati
sulla normale revalidazione.)?edit_mode=true a qualsiasi URL per gli overlay di editing. I visitatori pubblici
vedono la pagina pulita.Aggiungi la route di anteprima che lo scaffold omette. L'admin carica il suo iframe di anteprima su
/cms-preview_<path>; senza quella route ogni anteprima restituisce 404. Aggiungila:// src/app/cms-preview_/[...slug]/page.tsx import { ParametricRoutePreviewPage } from "cms-renderer/lib/renderer"; import { registry } from "../../registry"; // estrai il tuo registry in un modulo condiviso export default async function Page({ params, searchParams }) { const { slug } = await params; const PreviewPage = ParametricRoutePreviewPage as any; // async RSC; tipi React 19 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} />; }Aggiungi anche
src/app/cms-preview_/page.tsx(stesso codice,slug: []) per la root del segmento.
Uno storefront Stripe guidato dai contenuti: un catalogo nel CMS, pagine di elenco + dettaglio da un unico set di
route e un checkout ospitato funzionante. L'AI ha caricato il catalogo, collegato il design e scritto il
lettore del catalogo + i componenti + il carrello headless; tu hai gestito i componenti, i collegamenti dei prezzi Stripe,
le tre route, il chrome CEL e tre brevi file Stripe. CEL collega il chrome; i componenti recuperano il catalogo. E Stripe
è rimasto leggero — una chiamata sessions.create e un webhook firmato, con l'acquirente che paga sulla pagina di Stripe.