A hands-on walkthrough: build a content-driven Stripe storefront on Profound CMS β a catalog a merchant edits without code, two parametric routes, a headless cart, and Stripe-hosted checkout.
The finished store in motion β browse a category, open a product, add to cart, check out.
A hands-on walkthrough that builds a content-driven store on Profound CMS: a product catalog (categories + items) modeled in the CMS, listing and detail pages from one set of routes, and Stripe-hosted checkout shipped as a headless component.
The spine is hand-written Next.js plus the Profound admin. Claude Code (via the Profound MCP) does the heavy lifting on three jobs β seeding the catalog, wiring the design system, and writing the storefront components (including the headless cart). Three parts: Setup, Build, Production.
Payments in one line. We use Stripe-hosted Checkout: the shopper pays on Stripe's page, not yours. Your app does just two server-side things β create a Checkout Session and verify one webhook. No card fields, no Stripe Elements, no PCI burden.
/products/{item_code}, /categories/{category_code}) plus a
static /cart, all from one set of components.useCart) and Stripe-hosted checkout, with the price always
resolved server-side from a Stripe Price ID.curl -fsSL https://bun.sh/install | bashstripe login) for local webhooks.gh) and a Vercel account connected to GitHub.Profound separates content from rendering:
category, item); one tagged UI Element is placeable on a page
(nav, product_grid, β¦).meta.params.* in CEL, routeParams in React).cms-renderer; Stripe is added as ordinary API routes.The one rule that shapes the build: CEL binds string/number fields only. So
scalar chrome (nav brand, footer, headings) is bound with CEL, while anything rich or a
collection (a product grid, an image gallery, rich text) is fetched inside the React
component by route param. And Stripe is the pricing source of truth β the CMS price
is display-only; the charge always resolves server-side from a Stripe Price ID.
End state: a small published catalog, the app wired to read it, Stripe installed, design in place β nothing rendered yet.
Sign up at Profound (WorkOS auth). Create a website named store, then copy its website
ID (the UUID in the admin URL) and a read-tier API key (Deployments β Create API key).
The app only reads; the catalog seed later goes through the MCP, which authenticates
separately.
bunx create-profound-next store
cd store
bun add stripe
The scaffold is a Next.js App Router project pre-wired for Profound (the cms-renderer SDK,
a catch-all route, a generate-schemas script, a <Refresher>). It ships no styling.
bun add stripe pulls the server SDK β the only payments dependency hosted checkout needs.
Add your values to .env.local:
# CMS
PROFOUND_API_KEY=<your read key>
NEXT_PUBLIC_PROFOUND_WEBSITE_ID=<your website id>
NEXT_PUBLIC_CMS_API_URL=https://cms.dev.tryprofound.com
NEXT_PUBLIC_BUNNY_CDN_URL=https://cms-profound.b-cdn.net # serves CMS-hosted images
# Stripe
STRIPE_SECRET_KEY=sk_test_... # test key here; swap to your live key when you go live
STRIPE_WEBHOOK_SECRET=whsec_... # filled in Build step 4
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Grab STRIPE_SECRET_KEY from Stripe β Developers β API keys. We use a test key
(sk_test_β¦) so the build never moves real money; switch to your live key when you're ready
to take real payments. Run bun dev and open localhost:3000 β the starter renders.
Hosted checkout redirects the browser to a Stripe URL, so the server secret key is all Stripe needs β no publishable key, no client Stripe SDK.
category, product_image, and item componentsCreate three Custom Components (Components β Create new component) β the data source, so no UI Element tag. Set each Active.
The CMS has no "array of image" field, so a gallery is an array of references to a small
product_image component. Create category and product_image (and set them Active)
before item β a reference field only targets Active components.
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 β display only), currency (Select, usd), stripePriceId (Text), category (Reference β category), active (Boolean)Leave all fields optional. The admin lower-snake-cases field names ("Stripe Price Id" β
stripe_price_id) β that's what your code keys off, so read the real names back from
generate-schemas next. We name the routable handle code (not slug): it's the Route
Slug and the key for a clean documents.getByCode lookup later.
The item component β code as the Route Slug, images as references to product_image, plus stripePriceId and a category reference.
bun run generate-schemas
Writes Zod schemas + types to generated/cms-schemas.ts (categorySchema/Category,
itemSchema/Item). Doubles as a connection check β wrong credentials fail here.
Install and authenticate the MCP once:
claude mcp add --transport http Profound http://107.21.107.99:8081/mcp
Run mcp__Profound__authenticate, complete the WorkOS flow, then prompt Claude:
Generate a small ecommerce catalog for a store called Edison's Inventions β three categories and these products, with a short period-accurate
descriptionfor each, apricein cents,currency: "usd", andactive: 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)Each category needs a
nameand that lowercasecode; each item needs aname, that lowercasecode, thedescription,price(in cents),currency, andactive. Save it todata/catalog.jsonand validate it against ourcategoryanditemcomponents. Then use the Profound MCP to create each as a published document: create the categories first, capture their IDs, then create the items withcategoryset to a reference β{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. LeavestripePriceIdempty for now. Do the items in parallel.
Claude writes data/catalog.json, validates it, and fans out parallel create_document
calls (status: "published"). Seed categories before items so the references point at
IDs that already exist.
The three seeded categories, published and Live.
The eight seeded products, each linked to a category.
The catalog's in the CMS; now give a few products a real Stripe price β the merchant's job, done in two admin panels, no code:
price_β¦).stripePriceId, save.The CMS holds the catalog; Stripe holds the price of record; the link is one string the merchant pastes. (Prefer to automate it? The official Stripe MCP can create the Products/Prices for you β paste the returned IDs the same way.)
The scaffold ships unstyled. Put a DESIGN.md (a Tailwind v4 @theme block + tokens) at
the project root β your own, or download one from refero.design.
Then prompt Claude, scoped to styling only:
Read the design file I just added. Set up Tailwind if needed, then wire in the theme and fonts so the styling works. Use
next/fontfor fonts β don't load them from Google at runtime. Just the styling β don't build any pages or components yet.
Verify src/app/globals.css has @import "tailwindcss"; + the @theme block and that
localhost:3000 shows the tokens. Keep the prompt tight (open-ended, an agent scaffolds a
whole homepage), and load fonts via next/font, never a runtime Google import.
Optional β you can reach a working checkout without images. To add them: create a
product_image document per image (upload into its image field), then reference those
from the product's images array. Bring your own product photos, or generate a cohesive
set with an image model (have Claude derive on-brand prompts from DESIGN.md and lock one
Midjourney --sref so every shot matches).
The standalone
cms-rendererhas no image-URL helper, so vendorbuildAssetUrlintosrc/lib/image.ts(~40 lines) β it prefixesNEXT_PUBLIC_BUNNY_CDN_URLand adds the extension. The components in Build step 3 use it.
Build the rendering layer and the checkout, ending in a real test-mode purchase.
Five components, each Active and tagged UI Element (Settings β Tags), no Route Slug:
nav β brand Β· product_grid β heading Β· product_detail β heading Β·
cart_summary β heading Β· footer β text (all Text)The UI Element tag is what makes a component appear in the Page Builder's Add UI
Element list β Active alone isn't enough. Each field is a scalar (the kind CEL binds); the
actual catalog data isn't a field here β ProductGrid/ProductDetail fetch it by route
param (step 3).
bun run generate-schemas
One prompt builds the read helper, the five components, the cart, and the registry:
Build our storefront in
src/, using the Profoundcms-rendererSDK.
src/lib/catalog.tsβ a server-side CMS reader. Create a client withgetCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! })fromcms-renderer/lib/cms-api. ExportgetItemByCode(code)βcms.documents.getByCode.query({ websiteId, schemaName: "item", code })returningres.document.published_content. ExportlistItems(categoryCode?)βcms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }), mapres.documentsto.published_content, filteractive !== false, and ifcategoryCodeis given keep items whosecategory._refequals the category'sdocument.id. ExportresolveImages(refs)that resolves eachitem.imagesreference viacms.documents.get.query({ websiteId, id: ref._ref })and turns its image field into a URL with the vendoredbuildAssetUrl(Part 1 step 8).
src/components/β five UI element components registered in the catch-all route's registry by component name, snake_case to match the admin:{ nav, product_grid, product_detail, cart_summary, footer }.NavandFooterread their scalar field off thecontentprop (typedBlockComponentProps<T>fromcms-renderer/lib/types).ProductGridandProductDetailare async server components that readrouteParamsand fetch fromcatalog.ts:routeParams.<param>is{ value, β¦ }β read.value, soProductGridcallslistItems(routeParams.category_code?.value)(cards link to/products/{code}) andProductDetailcallsgetItemByCode(routeParams.item_code?.value)(gallery viaresolveImages, rich-text description, price, Add-to-cart).CartSummaryrenders the cart fromuseCartwith a Pay button. KeepformatPricein a puresrc/lib/format.tsso client components don't import the server-onlycatalog.ts.
src/components/AddToCartButton.tsxβ a"use client"button taking{ code, name, priceLabel }and callinguseCart().addItem({ code, name, priceLabel, quantity: 1 }). Use it insideProductDetail.
src/lib/useCart.tsβ a headless cart: line items{ code, name, priceLabel, quantity }in state, persisted tolocalStorage, exposingaddItem/removeItem/updateQty/subtotaland acheckout()that POSTs{ lines: [{ code, quantity }] }(codes and quantities only β never prices) to/api/stripe/checkout, then redirects to the returnedurl.Style everything with our design system, as our own components β don't copy the source site's layout.
Three things to know after it runs:
content ({ content }: BlockComponentProps<T>) β destructure fields as
top-level props and the block renders blank. Catalog data comes from routeParams + a catalog.ts
fetch, because CEL can't bind lists or galleries. The cart carries item codes, never prices.routeParams.<param> is { value, schemaName, document } β read .value. Reads return
published_content, not .content. Registry keys are snake_case to match the admin.@types/react/@types/react-dom to v19 β the scaffold ships v18, which breaks async
server-component blocks against React 19.Three short server files β the only payment code in the app. They reuse getItemByCode, so
the charge resolves 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 β resolve each item from the CMS, charge the Stripe
price:
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getItemByCode } from "@/lib/catalog"; // server-side, read-tier key
export async function POST(req: Request) {
const { lines } = await req.json(); // [{ code, quantity }] β no prices from the client
const line_items = await Promise.all(
lines.map(async ({ code, quantity }: { code: string; quantity: number }) => {
const item = await getItemByCode(code); // server resolves from the CMS
return { price: item!.stripe_price_id, quantity }; // price from CMS, never 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 redirects here
}
src/app/api/stripe/webhook/route.ts β the trusted fulfillment signal:
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // RAW body β required for signature verification
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: record the order / send a receipt.
}
return new Response(null, { status: 200 });
}
Required scaffold fix: the scaffold's
src/proxy.tsforwards every/api/*to the CMS, so your Stripe routes never run. Let them pass through first: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(); // handle locally } return cmsProxy(request as unknown as Parameters<typeof cmsProxy>[0]); }; // keep the scaffold's `export const config = { matcher: [...] }` unchangedVerify:
curl -X POST localhost:3000/api/stripe/webhook -d xreturnsBad signature.
Run the Stripe CLI for local webhooks:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copy the whsec_... into STRIPE_WEBHOOK_SECRET, restart bun dev
The whsec_β¦ is per-session. Two rules carry the security: checkout re-derives the price
from the CMS by code (a tampered cart can't change it), and the webhook verifies the
signature against the raw body.
Admin β Pages β Create page, three times. Map each param to its component (slug field code):
/products/{item_code} β item/categories/{category_code} β category/cart β a static page (enter literal /cart, not /{cart})For each route: Page Builder β Add UI Element β Custom β add components in order, fill scalar fields (static value or CEL), Publish.
/products/{item_code}: nav, product_detail, footer/categories/{category_code}: nav, product_grid, footer/cart: nav, cart_summary, footerSet nav.brand and footer.text to static strings; headings to static labels.
The Page Builder on the category route β the product_grid element selected, its heading bound by CEL.
The Page Builder on the product route β the product_detail element on a product binding.
Parametric Page Builder gotcha: on the two parametric routes, adding UI elements doesn't persist (blocks orphan and the page renders blank). Until it's fixed, wire those pages'
block_idsdirectly via the Profound MCPupdate_page, then publish. (Static/cartattaches normally.) For the same reason,ProductGridderives its heading from the category it fetches rather than via CEL.
/categories/lighting β the grid. Click a product β detail + Add to cart. /cart β Pay./cart?status=success, and stripe listen shows
checkout.session.completed.A rendered product page β gallery, price, and Add to cart.
The cart β line items and a single Pay-with-Stripe button.
Only priced items are buyable β buy one of the ~3 you priced in step 6.
Optional β internationalize. Translate each component (all 35 languages at once), add a
/{language}/β¦segment mapped to the built-inlanguageSystem component, and switch CEL-bound fields todocuments.translated. See the airport directory tutorial, Part 2 step 7.
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # --public is fine too
Build command:
generated/cms-schemas.tsis gitignored, so pin the build to regenerate it β addvercel.json:{ "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun run generate-schemas && next build" }
In Vercel: Add New β Project, import store, and add the env vars β PROFOUND_API_KEY,
NEXT_PUBLIC_PROFOUND_WEBSITE_ID, NEXT_PUBLIC_CMS_API_URL, NEXT_PUBLIC_BUNNY_CDN_URL,
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (the deployed-endpoint value, below), and
NEXT_PUBLIC_SITE_URL (your prod URL). Deploy.
Then wire the deployed webhook (the local stripe listen secret was local-only): Stripe
β Developers β Webhooks β + Add endpoint β https://<prod>/api/stripe/webhook, event
checkout.session.completed. Copy its whsec_β¦ into Vercel and redeploy.
Missing env vars = "works locally, blank in prod" β the #1 deploy gotcha. We deploy with
test keys here; switch STRIPE_SECRET_KEY and the webhook secret to your live values
when you're ready to accept real payments.
Both ship with the scaffold.
<Refresher> updates the page you're previewing when an editor saves
in the admin β no redeploy. (It's a preview for the editor; visitors see published content
on normal revalidation.)?edit_mode=true to any URL for edit overlays. Public visitors
get the clean page.Add the preview route the scaffold omits. The admin loads its preview iframe at
/cms-preview_<path>; without that route every preview 404s. Add it:// src/app/cms-preview_/[...slug]/page.tsx import { ParametricRoutePreviewPage } from "cms-renderer/lib/renderer"; import { registry } from "../../registry"; // extract your registry to a shared module export default async function Page({ params, searchParams }) { const { slug } = await params; const PreviewPage = ParametricRoutePreviewPage as any; // async RSC; React 19 types 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} />; }Add
src/app/cms-preview_/page.tsxtoo (same,slug: []) for the segment root.
A content-driven Stripe storefront: a CMS catalog, listing + detail pages from one set of
routes, and a working hosted checkout. AI seeded the catalog, wired the design, and wrote the
catalog reader + components + headless cart; you did the components, the Stripe price links,
the three routes, the CEL chrome, and three short Stripe files. CEL binds the chrome;
components fetch the catalog. And Stripe stayed small β one sessions.create call and one
signed webhook, with the shopper paying on Stripe's own page.