profound-logoProfound CMS
⌘K
Admin
Theme
DocsTutorialBlogPhilosophy
DocsTutorialBlogPhilosophy

Tutorials

Build & Ship an Airport DirectoryDeploymentsBuild & Ship a Stripe Storefront

Feature

Documentation Site TemplateFeature Template BuilderTranslation ServiceOrganizations & Website HeirarchyConnect Profound CMS to your AI clientSettings IntegrationsSettings API KeysSettings UsageSettings Websites
All Systems Operational
Powered Byprofound-logo
Theme

Build & Ship a Stripe Storefront

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.

What you'll build

  • A small published catalog in the CMS β€” three categories and eight products (the "Edison's Inventions" demo) β€” each editable by a merchant without code.
  • Two parametric routes (/products/{item_code}, /categories/{category_code}) plus a static /cart, all from one set of components.
  • A headless cart (useCart) and Stripe-hosted checkout, with the price always resolved server-side from a Stripe Price ID.
  • The store deployed to Vercel with live preview and in-place editing for the team.

Prerequisites

  • Bun β‰₯ 1.3 β€” curl -fsSL https://bun.sh/install | bash
  • Claude Code with the Profound MCP (Part 1 installs it).
  • A Profound CMS account.
  • A Stripe account. This tutorial runs in test mode so nothing charges real money while you build β€” but the flow is identical with live keys, so use your real account keys if you prefer. (Test mode needs no business or bank details.)
  • The Stripe CLI (stripe login) for local webhooks.
  • For deploy: the GitHub CLI (gh) and a Vercel account connected to GitHub.

How the pieces fit together

Profound separates content from rendering:

  • Components define the shape of content. A Custom Component with a Route Slug field is routable (category, item); one tagged UI Element is placeable on a page (nav, product_grid, …).
  • Documents are the content (a product, a category).
  • UI elements are page sections; each scalar field takes a static value or a CEL expression, evaluated at render.
  • Parametric routes map a URL to a document + UI elements, passing route params (meta.params.* in CEL, routeParams in React).
  • Your Next.js app reads it through 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.

Part 1 β€” Setup

End state: a small published catalog, the app wired to read it, Stripe installed, design in place β€” nothing rendered yet.

1. Sign up and create the website

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.

2. Scaffold the app, connect it, and add Stripe

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.

3. Define the category, product_image, and item components

Create 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.

4. Pull the components down to local types

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.

5. Seed the catalog via the Profound MCP

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 description for each, a price in cents, currency: "usd", and active: 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 name and that lowercase code; each item needs a name, that lowercase code, the description, price (in cents), currency, and active. Save it to data/catalog.json and validate it against our category and item components. Then use the Profound MCP to create each as a published document: create the categories first, capture their IDs, then create the items with category set to a reference β€” { "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. Leave stripePriceId empty 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.

6. Create Stripe Prices and connect a few products

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:

  1. Stripe Dashboard β†’ Products β†’ + Add product, set a one-time price, copy the Price ID (price_…).
  2. Do this for ~3 hero products (e.g. Lightbulb, Phonograph, Kinetoscope).
  3. Profound admin β†’ item β†’ Documents β†’ paste each Price ID into 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.)

7. Drop in the design system and wire it with AI

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/font for 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.

8. Add product images (optional)

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-renderer has no image-URL helper, so vendor buildAssetUrl into src/lib/image.ts (~40 lines) β€” it prefixes NEXT_PUBLIC_BUNNY_CDN_URL and adds the extension. The components in Build step 3 use it.

Part 2 β€” Build

Build the rendering layer and the checkout, ending in a real test-mode purchase.

1. Define the five UI element components

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).

2. Regenerate the types

bun run generate-schemas

3. Generate the catalog reader, components, and headless cart

One prompt builds the read helper, the five components, the cart, and the registry:

Build our storefront in src/, using the Profound cms-renderer SDK.

src/lib/catalog.ts β€” a server-side CMS reader. Create a client with getCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! }) from cms-renderer/lib/cms-api. Export getItemByCode(code) β†’ cms.documents.getByCode.query({ websiteId, schemaName: "item", code }) returning res.document.published_content. Export listItems(categoryCode?) β†’ cms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }), map res.documents to .published_content, filter active !== false, and if categoryCode is given keep items whose category._ref equals the category's document.id. Export resolveImages(refs) that resolves each item.images reference via cms.documents.get.query({ websiteId, id: ref._ref }) and turns its image field into a URL with the vendored buildAssetUrl (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 }. Nav and Footer read their scalar field off the content prop (typed BlockComponentProps<T> from cms-renderer/lib/types). ProductGrid and ProductDetail are async server components that read routeParams and fetch from catalog.ts: routeParams.<param> is { value, … } β€” read .value, so ProductGrid calls listItems(routeParams.category_code?.value) (cards link to /products/{code}) and ProductDetail calls getItemByCode(routeParams.item_code?.value) (gallery via resolveImages, rich-text description, price, Add-to-cart). CartSummary renders the cart from useCart with a Pay button. Keep formatPrice in a pure src/lib/format.ts so client components don't import the server-only catalog.ts.

src/components/AddToCartButton.tsx β€” a "use client" button taking { code, name, priceLabel } and calling useCart().addItem({ code, name, priceLabel, quantity: 1 }). Use it inside ProductDetail.

src/lib/useCart.ts β€” a headless cart: line items { code, name, priceLabel, quantity } in state, persisted to localStorage, exposing addItem/removeItem/updateQty/subtotal and a checkout() that POSTs { lines: [{ code, quantity }] } (codes and quantities only β€” never prices) to /api/stripe/checkout, then redirects to the returned url.

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:

  • Scalar chrome comes in on 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.
  • Bump @types/react/@types/react-dom to v19 β€” the scaffold ships v18, which breaks async server-component blocks against React 19.

4. Write the Stripe server code (the spine)

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.ts forwards 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: [...] }` unchanged

Verify: curl -X POST localhost:3000/api/stripe/webhook -d x returns Bad 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.

5. Create the routes

Admin β†’ Pages β†’ Create page, three times. Map each param to its component (slug field code):

  1. /products/{item_code} β†’ item
  2. /categories/{category_code} β†’ category
  3. /cart β€” a static page (enter literal /cart, not /{cart})

6. Add UI elements, wire CEL, publish

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, footer

Set 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_ids directly via the Profound MCP update_page, then publish. (Static /cart attaches normally.) For the same reason, ProductGrid derives its heading from the category it fetches rather than via CEL.

7. Render and buy

  • /categories/lighting β†’ the grid. Click a product β†’ detail + Add to cart. /cart β†’ Pay.
  • Pay redirects to Stripe-hosted checkout. Use test card 4242 4242 4242 4242, any future expiry/CVC. You return to /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-in language System component, and switch CEL-bound fields to documents.translated. See the airport directory tutorial, Part 2 step 7.

Part 3 β€” Production

1. Ship it: GitHub, then Vercel

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.ts is gitignored, so pin the build to regenerate it β€” add vercel.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.

2. Live preview and in-place editing

Both ship with the scaffold.

  • Live preview: the <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.)
  • In-place editing: add ?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.tsx too (same, slug: []) for the segment root.

That's the build

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.

Continue Reading
Previousβ€ΉDeployments