Un tutoriel pratique : construisez une boutique Stripe pilotée par le contenu sur Profound CMS — un catalogue qu’un commerçant édite sans code, deux routes paramétriques, un panier headless et un checkout hébergé Stripe.
La boutique terminée en action — naviguez dans une catégorie, ouvrez un produit, ajoutez-le au panier, passez en caisse.
Un tutoriel pratique qui construit une boutique pilotée par le contenu sur Profound CMS : un catalogue de produits (catégories + articles) modélisé dans le CMS, des pages de liste et de détail issues d’un même ensemble de routes, et un checkout hébergé Stripe livré en tant que composant headless.
L’ossature repose sur du Next.js écrit à la main associé à l’admin Profound. Claude Code (via le MCP Profound) fait l’essentiel du travail sur trois tâches — le préremplissage du catalogue, le câblage du système de design et l’écriture des composants de la boutique (y compris le panier headless). Trois parties : Configuration, Construction, Production.
Des paiements en une ligne. Nous utilisons Stripe Checkout hébergé : l’acheteur paie sur la page de Stripe, pas la vôtre. Votre application fait seulement deux choses côté serveur — créer une session Checkout et vérifier un webhook. Pas de champs carte, pas de Stripe Elements, pas de charge PCI.
/products/{item_code}, /categories/{category_code}) plus un /cart statique, tous basés sur un seul ensemble de composants.useCart) et un checkout hébergé Stripe, avec le prix toujours résolu côté serveur à partir d’un identifiant de prix Stripe.curl -fsSL https://bun.sh/install | bashstripe login) pour les webhooks locaux.gh) et un compte Vercel connecté à GitHub.Profound sépare le contenu du rendu :
category, item) ; un composant marqué UI Element est plaçable sur une page (nav, product_grid, …).meta.params.* en CEL, routeParams en React).cms-renderer; Stripe est ajouté comme de simples routes API.La règle qui structure la construction : CEL ne lie que les champs string/number. Donc l’habillage scalaire (marque de la nav, pied de page, titres) est lié avec CEL, tandis que tout ce qui est riche ou une collection (une grille de produits, une galerie d’images, du rich text) est récupéré à l’intérieur du composant React par paramètre de route. Et Stripe est la source de vérité tarifaire — le price du CMS n’est qu’un affichage ; le débit est toujours résolu côté serveur à partir d’un identifiant de prix Stripe.
État final : un petit catalogue publié, l’application câblée pour le lire, Stripe installé, design en place — rien n’est encore rendu.
Inscrivez-vous sur Profound (authentification WorkOS). Créez un site nommé store, puis copiez son ID de site web (l’UUID dans l’URL de l’admin) et une clé API niveau lecture (Deployments → Create API key). L’application ne fait que lire ; le préremplissage du catalogue passe ensuite par le MCP, qui s’authentifie séparément.
bunx create-profound-next store
cd store
bun add stripe
Le squelette est un projet Next.js App Router pré-câblé pour Profound (le SDK cms-renderer, une route catch-all, un script generate-schemas, un <Refresher>). Il n’embarque aucun style. bun add stripe récupère le SDK serveur — la seule dépendance de paiement nécessaire pour un checkout hébergé.
Ajoutez vos valeurs à .env.local :
# CMS
PROFOUND_API_KEY=<votre clé lecture>
NEXT_PUBLIC_PROFOUND_WEBSITE_ID=<votre ID de site>
NEXT_PUBLIC_CMS_API_URL=https://cms.dev.tryprofound.com
NEXT_PUBLIC_BUNNY_CDN_URL=https://cms-profound.b-cdn.net # sert les images hébergées par le CMS
# Stripe
STRIPE_SECRET_KEY=sk_test_... # clé test ici ; remplacez-la par votre clé live lorsque vous passez en production
STRIPE_WEBHOOK_SECRET=whsec_... # à renseigner à l’étape 4 de la Construction
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Récupérez STRIPE_SECRET_KEY dans Stripe → Developers → API keys. Nous utilisons une clé test (sk_test_…) pour que la construction ne manipule jamais de vrai argent ; basculez sur votre clé live quand vous serez prêt à accepter des paiements réels. Exécutez bun dev et ouvrez localhost:3000 — le starter s’affiche.
Le checkout hébergé redirige le navigateur vers une URL Stripe, donc la clé secrète serveur suffit à Stripe — pas de clé publiable, pas de SDK client Stripe.
category, product_image et itemCréez trois Custom Components (Components → Create new component) — source de données, donc sans tag UI Element. Activez chacun (Active).
Le CMS n’a pas de champ « tableau d’images », donc une galerie est un tableau de références vers un petit composant product_image. Créez category et product_image (et activez-les) avant item — un champ de référence ne cible que des composants actifs.
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 of references → product_image), price (Number, cents — affichage uniquement), currency (Select, usd), stripePriceId (Text), category (Reference → category), active (Boolean)Laissez tous les champs facultatifs. L’admin met les noms de champs en snake_case (stripe_price_id) — c’est ce que votre code utilisera, donc relisez les vrais noms via generate-schemas. Nous nommons le handle routable code (pas slug) : c’est le Route Slug et la clé pour une récupération documents.getByCode plus tard.
Le composant item — code comme Route Slug, images en références vers product_image, plus stripePriceId et une référence category.
bun run generate-schemas
Écrit des schémas Zod + types dans generated/cms-schemas.ts (categorySchema/Category, itemSchema/Item). Fait aussi office de vérification de connexion — des identifiants erronés échouent ici.
Installez et authentifiez le MCP une fois :
claude mcp add --transport http Profound http://107.21.107.99:8081/mcp
Exécutez mcp__Profound__authenticate, terminez le flux WorkOS, puis demandez à Claude :
Génère un petit catalogue ecommerce pour une boutique appelée Edison's Inventions — trois catégories et ces produits, avec une
descriptioncourte fidèle à l’époque, unpriceen cents,currency: "usd", etactive: 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 $)Chaque catégorie a besoin d’un
nameet ducodeen minuscules ; chaque article a besoin d’unname, de cecodeen minuscules, de ladescription, duprice(en cents), decurrencyet deactive. Enregistre le tout dansdata/catalog.jsonet valide-le par rapport à nos composantscategoryetitem. Ensuite, utilise le MCP Profound pour créer chacun comme document publié : crée d’abord les catégories, capture leurs IDs, puis crée les articles aveccategorydéfinie comme référence —{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. LaissestripePriceIdvide pour l’instant. Traite les articles en parallèle.
Claude écrit data/catalog.json, le valide et enchaîne les appels create_document en parallèle (status: "published"). Préremplissez les catégories avant les articles afin que les références pointent vers des IDs déjà existants.
Les trois catégories préremplies, publiées et en ligne.
Les huit produits préremplis, chacun lié à une catégorie.
Le catalogue est dans le CMS ; donnez maintenant un vrai prix Stripe à quelques produits — le travail du commerçant, réalisé dans deux panneaux d’admin, sans code :
price_…).stripePriceId, enregistrez.Le CMS détient le catalogue ; Stripe détient le prix de référence ; le lien est une chaîne que le commerçant colle. (Vous préférez automatiser ? Le Stripe MCP officiel peut créer les Products/Prices pour vous — collez les IDs retournés de la même manière.)
Le squelette n’est pas stylé. Placez un fichier DESIGN.md (un bloc @theme Tailwind v4 + tokens) à la racine du projet — le vôtre ou téléchargez-en un depuis refero.design.
Ensuite, demandez à Claude, en vous limitant au style :
Lis le fichier de design que je viens d’ajouter. Mets en place Tailwind si nécessaire, puis câble le thème et les polices pour que le style fonctionne. Utilise
next/fontpour les polices — ne les charge pas depuis Google à l’exécution. Contente-toi du style — ne construis pas encore de pages ni de composants.
Vérifiez que src/app/globals.css contient @import "tailwindcss"; + le bloc @theme et que localhost:3000 affiche les tokens. Gardez l’invite concise (si elle est trop ouverte, un agent générera toute une page d’accueil), et chargez les polices via next/font, jamais via un import Google à l’exécution.
Optionnel — vous pouvez atteindre un checkout fonctionnel sans images. Pour en ajouter : créez un document product_image par image (téléchargez-la dans son champ image), puis référencez-les depuis le tableau images du produit. Apportez vos propres photos produits, ou générez un ensemble cohérent avec un modèle d’image (demandez à Claude de dériver des prompts alignés sur DESIGN.md et verrouillez un --sref Midjourney pour que chaque prise soit cohérente).
Le
cms-rendererautonome n’a pas de helper d’URL d’image, donc intégrezbuildAssetUrldanssrc/lib/image.ts(~40 lignes) — il préfixeNEXT_PUBLIC_BUNNY_CDN_URLet ajoute l’extension. Les composants de l’étape 3 de la Construction l’utilisent.
Construisez la couche de rendu et le checkout, pour terminer avec un achat réel en mode test.
Cinq composants, chacun Active et tagué UI Element (Settings → Tags), sans Route Slug :
nav → brand · product_grid → heading · product_detail → heading · cart_summary → heading · footer → text (tous des Text)Le tag UI Element est ce qui rend un composant disponible dans la liste Add UI Element du Page Builder — Active ne suffit pas. Chaque champ est scalaire (ce que CEL sait lier) ; les données de catalogue ne sont pas un champ ici — ProductGrid/ProductDetail les récupèrent via le paramètre de route (étape 3).
bun run generate-schemas
Une seule invite construit l’aide à la lecture, les cinq composants, le panier et le registre :
Construis notre vitrine dans
src/, à l’aide du SDKcms-rendererde Profound.
src/lib/catalog.ts— un lecteur CMS côté serveur. Crée un client avecgetCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! })depuiscms-renderer/lib/cms-api. ExportegetItemByCode(code)→cms.documents.getByCode.query({ websiteId, schemaName: "item", code })renvoyantres.document.published_content. ExportelistItems(categoryCode?)→cms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }), mapperes.documentsvers.published_content, filtreactive !== false, et sicategoryCodeest fourni garde les articles dontcategory._refégaledocument.idde la catégorie. ExporteresolveImages(refs)qui résout chaque référenceitem.imagesviacms.documents.get.query({ websiteId, id: ref._ref })et transforme son champ image en URL grâce aubuildAssetUrlintégré (Partie 1 étape 8).
src/components/— cinq composants d’UI enregistrés dans le registre de la route catch-all par nom de composant, en snake_case pour correspondre à l’admin :{ nav, product_grid, product_detail, cart_summary, footer }.NavetFooterlisent leur champ scalaire depuis la propcontent(typéeBlockComponentProps<T>decms-renderer/lib/types).ProductGridetProductDetailsont des composants serveur async qui lisentrouteParamset récupèrent viacatalog.ts:routeParams.<param>vaut{ value, … }— lisez.value, doncProductGridappellelistItems(routeParams.category_code?.value)(les cartes lient vers/products/{code}) etProductDetailappellegetItemByCode(routeParams.item_code?.value)(galerie viaresolveImages, description rich text, prix, ajout au panier).CartSummaryrend le panier depuisuseCartavec un bouton Payer. ConservezformatPricedans unsrc/lib/format.tspur pour que les composants client n’importent pascatalog.ts(réservé au serveur).
src/components/AddToCartButton.tsx— un bouton"use client"recevant{ code, name, priceLabel }et appelantuseCart().addItem({ code, name, priceLabel, quantity: 1 }). Utilise-le dansProductDetail.
src/lib/useCart.ts— un panier headless : lignes{ code, name, priceLabel, quantity }dans l’état, persistées danslocalStorage, exposantaddItem/removeItem/updateQty/subtotalet uncheckout()qui POST{ lines: [{ code, quantity }] }(codes et quantités uniquement — jamais les prix) vers/api/stripe/checkout, puis redirige vers l’urlrenvoyée.Stylise le tout avec notre système de design, via nos propres composants — ne copie pas la mise en page du site source.
Trois éléments à retenir après exécution :
content ({ content }: BlockComponentProps<T>) — si vous déstructurez les champs en props de niveau supérieur, le bloc reste vide. Les données catalogue proviennent de routeParams + une récupération via catalog.ts, car CEL ne peut pas lier de listes ni de galeries. Le panier transporte les codes des articles, jamais les prix.routeParams.<param> vaut { value, schemaName, document } — lisez .value. Les lectures renvoient published_content, pas .content. Les clés du registre sont en snake_case pour correspondre à l’admin.@types/react/@types/react-dom en v19 — le squelette livre la v18, qui casse les blocs de composants serveur async avec React 19.Trois petits fichiers serveur — le seul code de paiement de l’app. Ils réutilisent getItemByCode, donc le prix est résolu côté serveur.
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 — résout chaque article depuis le CMS, facture le prix Stripe :
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getItemByCode } from "@/lib/catalog"; // côté serveur, clé niveau lecture
export async function POST(req: Request) {
const { lines } = await req.json(); // [{ code, quantity }] — aucun prix depuis le client
const line_items = await Promise.all(
lines.map(async ({ code, quantity }: { code: string; quantity: number }) => {
const item = await getItemByCode(code); // résolution côté serveur depuis le CMS
return { price: item!.stripe_price_id, quantity }; // prix via le CMS, jamais depuis le 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 }); // le client redirige vers cette URL
}
src/app/api/stripe/webhook/route.ts — le signal de traitement fiable :
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // Corps BRUT — requis pour la vérification de signature
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") {
// exécuter : enregistrer la commande / envoyer un reçu.
}
return new Response(null, { status: 200 });
}
Correction obligatoire du squelette : le fichier
src/proxy.tsdu squelette transfère chaque/api/*vers le CMS, donc vos routes Stripe ne s’exécutent jamais. Laissez-les passer d’abord :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(); // géré localement } return cmsProxy(request as unknown as Parameters<typeof cmsProxy>[0]); }; // laissez inchangé le `export const config = { matcher: [...] }` du squeletteVérification :
curl -X POST localhost:3000/api/stripe/webhook -d xrenvoieBad signature.
Lancez le Stripe CLI pour les webhooks locaux :
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copiez le whsec_... dans STRIPE_WEBHOOK_SECRET, redémarrez bun dev
Le whsec_… est propre à chaque session. Deux règles assurent la sécurité : le checkout redérive le prix depuis le CMS par code (un panier falsifié ne peut pas le modifier), et le webhook vérifie la signature contre le corps brut.
Admin → Pages → Create page, trois fois. Associez chaque paramètre à son composant (champ slug code) :
/products/{item_code} → item/categories/{category_code} → category/cart — une page statique (saisissez littéralement /cart, pas /{cart})Pour chaque route : Page Builder → Add UI Element → Custom → ajoutez les composants dans l’ordre, renseignez les champs scalaires (valeur statique ou CEL), Publiez.
/products/{item_code} : nav, product_detail, footer/categories/{category_code} : nav, product_grid, footer/cart : nav, cart_summary, footerDéfinissez nav.brand et footer.text sur des chaînes statiques ; les titres sur des étiquettes statiques.
Le Page Builder sur la route catégorie — l’élément product_grid sélectionné, son heading lié via CEL.
Le Page Builder sur la route produit — l’élément product_detail sur un binding produit.
Astuce Page Builder paramétrique : sur les deux routes paramétriques, l’ajout d’éléments d’interface ne persiste pas (les blocs sont orphelins et la page rend du vide). Tant que ce n’est pas corrigé, cablez directement les
block_idsde ces pages via le MCP Profoundupdate_page, puis publiez. (La page statique/carts’attache normalement.) Pour la même raison,ProductGriddérive son heading de la catégorie qu’il récupère plutôt que via CEL.
/categories/lighting → la grille. Cliquez sur un produit → détail + bouton Ajouter au panier. /cart → Payer./cart?status=success, et stripe listen affiche checkout.session.completed.Une page produit rendue — galerie, prix et bouton Ajouter au panier.
Le panier — lignes et unique bouton Payer avec Stripe.
Seuls les articles tarifés sont achetables — achetez l’un des ~3 que vous avez tarifés à l’étape 6.
Optionnel — internationalisez. Traduisez chaque composant (les 35 langues d’un coup), ajoutez un segment
/{language}/…mappé sur le composant systèmelanguage, et basculez les champs liés via CEL surdocuments.translated. Voir le tutoriel d’annuaire aéroportuaire, Partie 2 étape 7.
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # --public fonctionne aussi
Commande de build :
generated/cms-schemas.tsest ignoré par git, donc épinglez le build pour le régénérer — ajoutezvercel.json:{ "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun run generate-schemas && next build" }
Dans Vercel : Add New → Project, importez store, et ajoutez les variables d’env — PROFOUND_API_KEY, NEXT_PUBLIC_PROFOUND_WEBSITE_ID, NEXT_PUBLIC_CMS_API_URL, NEXT_PUBLIC_BUNNY_CDN_URL, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (la valeur de l’endpoint déployé, ci-dessous) et NEXT_PUBLIC_SITE_URL (votre URL de prod). Déployez.
Ensuite, câblez le webhook déployé (le secret stripe listen local était uniquement pour le local) : Stripe → Developers → Webhooks → + Add endpoint → https://<prod>/api/stripe/webhook, événement checkout.session.completed. Copiez son whsec_… dans Vercel et redéployez.
Variables d’env manquantes = « ça marche en local, blanc en prod » — le piège n°1 du déploiement. Nous déployons ici avec des clés test ; remplacez STRIPE_SECRET_KEY et le secret webhook par vos valeurs live lorsque vous êtes prêts à accepter de vrais paiements.
Les deux sont fournis avec le squelette.
<Refresher> met à jour la page que vous prévisualisez lorsqu’un éditeur enregistre dans l’admin — sans redeploiement. (C’est un aperçu pour l’éditeur ; les visiteurs voient le contenu publié sur la revalidation normale.)?edit_mode=true à n’importe quelle URL pour obtenir les surcouches d’édition. Les visiteurs publics voient la page propre.Ajoutez la route de prévisualisation omission du squelette. L’admin charge son iframe d’aperçu sur
/cms-preview_<path>; sans cette route, chaque preview retourne une 404. Ajoutez-la :// src/app/cms-preview_/[...slug]/page.tsx import { ParametricRoutePreviewPage } from "cms-renderer/lib/renderer"; import { registry } from "../../registry"; // extrayez votre registre dans un module partagé export default async function Page({ params, searchParams }) { const { slug } = await params; Chaque page (ParametricRoutePreviewPage en tant que tout composant) [snip]. const PreviewPage = ParametricRoutePreviewPage as any; // composant serveur async ; types 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} />; }Ajoutez aussi
src/app/cms-preview_/page.tsx(même code,slug: []) pour la racine du segment.
Une boutique Stripe pilotée par le contenu : un catalogue CMS, des pages de liste + détail à partir d’un seul ensemble de routes, et un checkout hébergé opérationnel. L’IA a prérempli le catalogue, câblé le design et écrit le lecteur de catalogue + composants + panier headless ; vous avez géré les composants, les liens de prix Stripe, les trois routes, l’habillage CEL et trois petits fichiers Stripe. CEL lie l’habillage ; les composants récupèrent le catalogue. Et Stripe est resté minimal — un sessions.create et un webhook signé, avec le client qui paie sur la page de Stripe.