一个实践向导:在 Profound CMS 上构建以内容驱动的 Stripe 店面——商家无需写代码即可编辑目录、两条参数化路由、无头购物车以及 Stripe 托管结账。
完成后的商店动态演示——浏览分类、打开商品、加入购物车并结账。
一个实践向导,带你在 Profound CMS 上构建一个以内容驱动的商店:一个在 CMS 中建模的产品目录(分类 + 商品),一组路由即可生成列表页和详情页,以及作为无头组件交付的Stripe 托管结账。
骨架是手写的 Next.js 加上 Profound 管理后台。Claude Code(通过 Profound MCP)承担了三项繁重工作——填充目录、接入设计系统以及编写店面组件(包括无头购物车)。共分三部分:准备、构建、上线。
一行代码即可完成支付。 我们使用 Stripe 托管 Checkout:顾客在 Stripe 的页面上付款,而不是你的页面。你的应用只需完成两个服务端任务——创建 Checkout Session 并验证一个 webhook。没有卡片输入框、没有 Stripe Elements,也无 PCI 负担。
/products/{item_code}、/categories/{category_code})加一个静态的 /cart,全部复用同一组组件。useCart)和 Stripe 托管结账,价格始终在服务端通过 Stripe Price ID 解析。curl -fsSL https://bun.sh/install | bashstripe login),用于本地 webhooks。gh)以及一个已连接 GitHub 的 Vercel 账户。Profound 将内容与渲染分离:
category、item);带 UI Element 标签的组件可放置在页面上(nav、product_grid 等)。meta.params.*,在 React 中为 routeParams)。cms-renderer 读取数据;Stripe 则以常规 API 路由接入。塑造整个构建的核心规则:CEL 只能绑定string/number 字段。因此标量外观(导航品牌、页脚、标题)用 CEL 绑定,而任何富文本或集合(商品网格、图片画廊、富文本)则需在 React 组件内部通过路由参数进行获取。同时,Stripe 是价格的权威来源——CMS 中的 price 仅供展示;实际扣款始终在服务端根据 Stripe Price ID 解析。
目标状态:一个已发布的小型目录,应用已连通可读取,Stripe 安装完毕,设计就位——暂未渲染任何页面。
在 Profound 上注册(WorkOS 认证)。创建一个名为 store 的网站,复制其 Website ID(管理后台 URL 中的 UUID)以及一个读取级别 API 密钥(Deployments → Create API key)。应用只读;之后的目录填充通过 MCP 完成,会单独认证。
bunx create-profound-next store
cd store
bun add stripe
脚手架是一个预先为 Profound 预设好的 Next.js App Router 项目(内置 cms-renderer SDK、捕获全部的路由、generate-schemas 脚本、<Refresher>)。它不包含任何样式。bun add stripe 会引入服务器端 SDK——托管结账所需的唯一支付依赖。
将你的值写入 .env.local:
# CMS
PROFOUND_API_KEY=<你的读取密钥>
NEXT_PUBLIC_PROFOUND_WEBSITE_ID=<你的网站 ID>
NEXT_PUBLIC_CMS_API_URL=https://cms.dev.tryprofound.com
NEXT_PUBLIC_BUNNY_CDN_URL=https://cms-profound.b-cdn.net # 用于提供 CMS 托管的图片
# Stripe
STRIPE_SECRET_KEY=sk_test_... # 在此使用测试密钥;上线时换成正式密钥
STRIPE_WEBHOOK_SECRET=whsec_... # 在构建部分第 4 步填写
NEXT_PUBLIC_SITE_URL=http://localhost:3000
在 Stripe → Developers → API keys 中获取 STRIPE_SECRET_KEY。我们使用测试密钥(sk_test_…),因此构建期间不会真实扣款;准备收款时再切换到正式密钥。运行 bun dev 并打开 localhost:3000 —— 起始页面会显示。
托管结账会将浏览器重定向到 Stripe 的 URL,因此 Stripe 只需要服务器端密钥——无需可发布密钥,也不需要客户端 Stripe SDK。
category、product_image 和 item 组件创建三个自定义组件(Components → Create new component)——作为数据源,所以不要勾选 UI Element 标签。将每个组件设为 Active。
CMS 没有“图片数组”字段,因此画廊使用指向小型 product_image 组件的引用数组。请先创建 category 和 product_image(并设为 Active),然后创建 item——引用字段只能指向已激活的组件。
category —— name(文本)、code(文本,Route Slug)、description(富文本)、heroImage(图片)product_image —— image(图片)item —— name(文本)、code(文本,Route Slug)、description(富文本)、images(指向 product_image 的引用数组)、price(数字,单位为美分——仅显示用)、currency(选择,usd)、stripePriceId(文本)、category(引用 → category)、active(布尔)保持所有字段为可选。管理后台会将字段名转换为蛇形小写(例如 “Stripe Price Id” → stripe_price_id)——你的代码要以此为准,因此稍后请通过 generate-schemas 读取真实字段名。我们将可路由的句柄命名为 code(而非 slug):它既是 Route Slug,也是之后 documents.getByCode 便捷查询的键。
item 组件 —— code 作为 Route Slug,images 引用 product_image,以及 stripePriceId 和 category 引用。
bun run generate-schemas
会将 Zod schema 和类型写入 generated/cms-schemas.ts(categorySchema/Category、itemSchema/Item)。这同时也是连通性检查——凭证错误会导致这里失败。
首次安装并认证 MCP:
claude mcp add --transport http Profound http://107.21.107.99:8081/mcp
运行 mcp__Profound__authenticate,完成 WorkOS 流程,然后对 Claude 发出提示:
为名为 Edison's Inventions 的商店生成一个小型电商目录——三个分类和以下商品,每件商品附带一段符合时代背景的
description、以美分为单位的price、currency: "usd",以及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)每个分类需要
name和对应的小写code;每个商品需要name、对应的小写code、description、price(单位为美分)、currency和active。保存到data/catalog.json并根据我们的category和item组件验证。然后使用 Profound MCP 将它们创建为已发布文档:先创建分类,记录其 ID,再创建商品并将category设为引用——{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }。商品可并行创建。
Claude 会写入 data/catalog.json、完成验证,并并行发起 create_document 调用(status: "published")。务必先创建分类再创建商品,这样引用能指向已存在的 ID。
三个已填充的分类,均已发布且为 Live。
八个已填充的商品,每个都关联到一个分类。
目录已在 CMS 中;现在为几个商品配置真实的 Stripe Price——这是商家的工作,在两个管理后台完成,无需写代码:
price_…)。stripePriceId 中并保存。CMS 负责维护目录;Stripe 负责价格权威;连接两者的就是商家粘贴的一串 ID。(想自动化?官方 Stripe MCP 可以为你创建 Products/Prices——将返回的 ID 同样粘贴进去即可。)
脚手架没有样式。将一个包含 Tailwind v4 @theme 块和令牌的 DESIGN.md 放在项目根目录——可以自行编写,也可从 refero.design 下载。然后向 Claude 发出只限定于样式的提示:
阅读我刚添加的设计文件。如果需要,请安装 Tailwind,然后接入主题和字体,使样式生效。字体使用
next/font—— 不要在运行时从 Google 加载。只处理样式——不要构建任何页面或组件。
确认 src/app/globals.css 中包含 @import "tailwindcss"; 和 @theme 块,且 localhost:3000 显示令牌。保持提示简洁(过于开放会让代理搭出完整首页),并通过 next/font 加载字体,绝不要使用运行时的 Google 引入。
非必需——即便没有图片也能完成结账。若要添加:为每张图片创建一个 product_image 文档(在其 image 字段上传),然后在商品的 images 数组中引用。可自备商品照片,或使用图像模型生成一组风格统一的素材(让 Claude 根据 DESIGN.md 推导品牌化提示,并固定一个 Midjourney 的 --sref 以保持一致)。
独立的
cms-renderer没有图片 URL 辅助函数,因此请在src/lib/image.ts中引入自定义的buildAssetUrl(约 40 行)——它会为NEXT_PUBLIC_BUNNY_CDN_URL添加前缀并补齐扩展名。第三部分的组件会使用它。
搭建渲染层和结账流程,最后完成一次真实的测试模式购买。
创建五个组件,每个都设为 Active 并打上 UI Element 标签(Settings → Tags),不设置 Route Slug:
nav → brandproduct_grid → headingproduct_detail → headingcart_summary → headingfooter → text(全部为文本字段)UI Element 标签让组件出现在 Page Builder 的 Add UI Element 列表中——仅 Active 还不够。每个字段都是标量(CEL 可绑定的类型);真正的目录数据不在这里——ProductGrid/ProductDetail 会在第 3 步通过路由参数获取。
bun run generate-schemas
使用一个提示构建读取器、五个组件、购物车以及注册表:
在
src/中使用 Profound 的cms-rendererSDK 构建我们的店面。
src/lib/catalog.ts—— 一个服务端 CMS 读取器。使用cms-renderer/lib/cms-api中的getCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! })创建客户端。导出getItemByCode(code)→cms.documents.getByCode.query({ websiteId, schemaName: "item", code }),返回res.document.published_content。导出listItems(categoryCode?)→cms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }),将res.documents映射到.published_content,过滤active !== false,若传入categoryCode,仅保留其category._ref等于该分类document.id的商品。导出resolveImages(refs),通过cms.documents.get.query({ websiteId, id: ref._ref })解析每个item.images引用,并使用前面引入的buildAssetUrl(第 1 部分第 8 步)将图片字段转换为 URL。
src/components/—— 五个 UI 元素组件,以组件名注册到捕获全部路由的注册表中,key 为蛇形命名以匹配后台:{ nav, product_grid, product_detail, cart_summary, footer }。Nav和Footer从content属性(类型为cms-renderer/lib/types中的BlockComponentProps<T>)读取其标量字段。ProductGrid和ProductDetail是异步服务端组件,它们读取routeParams并调用catalog.ts:routeParams.<param>为{ value, … }—— 使用.value,因此ProductGrid调用listItems(routeParams.category_code?.value)(卡片链接到/products/{code}),ProductDetail调用getItemByCode(routeParams.item_code?.value)(通过resolveImages加载画廊、展示富文本描述和价格,并提供加入购物车按钮)。CartSummary使用useCart渲染购物车并提供付款按钮。将formatPrice放在纯函数文件src/lib/format.ts,避免客户端组件导入仅限服务端的catalog.ts。
src/components/AddToCartButton.tsx—— 一个"use client"按钮,接收{ code, name, priceLabel },并调用useCart().addItem({ code, name, priceLabel, quantity: 1 })。在ProductDetail中使用它。
src/lib/useCart.ts—— 一个无头购物车:状态中存储{ code, name, priceLabel, quantity }的行项目,并持久化到localStorage,暴露addItem/removeItem/updateQty/subtotal以及checkout()。checkout()会向/api/stripe/checkoutPOST{ lines: [{ code, quantity }] }(只包含商品编码和数量——绝不传价格),然后重定向到返回的url。使用我们的设计系统为所有内容设置样式,构建我们自己的组件——不要复制源站的布局。
完成后需了解三点:
content({ content }: BlockComponentProps<T>)——若将字段解构为顶层 props,区块会显示为空。目录数据则通过 routeParams 加 catalog.ts 中的请求获取,因为 CEL 无法绑定列表或画廊。购物车只携带商品编码,永不携带价格。routeParams.<param> 是 { value, schemaName, document }——读取 .value。读取结果为 published_content,不是 .content。注册表键必须是蛇形命名以匹配后台。@types/react/@types/react-dom 升级至 v19 —— 脚手架默认 v18,在 React 19 下会导致异步服务端组件区块出错。三个简短的服务端文件——这是应用中唯一的支付代码。它们复用 getItemByCode,因而在服务端解析扣款。
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 —— 从 CMS 解析商品并以 Stripe 价格结算:
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getItemByCode } from "@/lib/catalog"; // 服务端读取,使用读取级密钥
export async function POST(req: Request) {
const { lines } = await req.json(); // [{ code, quantity }] —— 客户端不传价格
const line_items = await Promise.all(
lines.map(async ({ code, quantity }: { code: string; quantity: number }) => {
const item = await getItemByCode(code); // 服务端从 CMS 解析
return { price: item!.stripe_price_id, quantity }; // 价格来自 CMS,而非客户端
})
);
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 }); // 客户端重定向至此
}
src/app/api/stripe/webhook/route.ts —— 可信赖的履约信号:
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // 原始请求体——签名验证必须
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") {
// 履约:记录订单 / 发送收据。
}
return new Response(null, { status: 200 });
}
**脚手架必需修复:**脚手架的
src/proxy.ts会将所有/api/*代理到 CMS,导致你的 Stripe 路由无法执行。请先让它们通过: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(); // 留给本地处理 } return cmsProxy(request as unknown as Parameters<typeof cmsProxy>[0]); }; // 保持脚手架中的 `export const config = { matcher: [...] }` 不变验证:
curl -X POST localhost:3000/api/stripe/webhook -d x应返回Bad signature。
为本地 webhooks 运行 Stripe CLI:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# 将 whsec_... 填入 STRIPE_WEBHOOK_SECRET,并重启 bun dev
whsec_… 为每次会话独有。两条规则保障安全:结账会在服务端根据 CMS 的 code 重新解析价格(被篡改的购物车无法更改价格);webhook 会根据原始请求体验证签名。
管理后台 → Pages → 创建页面,共三次。将每个参数映射到对应组件(Slug 字段为 code):
/products/{item_code} → item/categories/{category_code} → category/cart —— 一个静态页面(填写字面量 /cart,不要使用 /{cart})对每个路由:Page Builder → Add UI Element → Custom → 按顺序添加组件,填写标量字段(静态值或 CEL),并发布。
/products/{item_code}:nav、product_detail、footer/categories/{category_code}:nav、product_grid、footer/cart:nav、cart_summary、footer将 nav.brand 和 footer.text 设为静态字符串,标题设为静态标签。
分类路由的 Page Builder —— product_grid 元素被选中,标题通过 CEL 绑定。
商品路由的 Page Builder —— product_detail 元素绑定到一个商品。
**参数化 Page Builder 的注意事项:**在两个参数化路由上,添加 UI 元素后不会持久化(区块会孤立,页面渲染为空白)。在修复之前,请通过 Profound MCP 的
update_page直接写入这些页面的block_ids,然后发布。(静态的/cart可以正常附加。)出于同样原因,ProductGrid会根据它获取的分类自行派生标题,而不是依赖 CEL。
/categories/lighting → 商品网格。点击任一商品 → 查看商品详情并添加到购物车。访问 /cart → 点击支付。/cart?status=success,stripe listen 会显示 checkout.session.completed。渲染后的商品页面——画廊、价格以及“加入购物车”按钮。
购物车页面——行项目和一个“Pay with Stripe”按钮。
只有已设定价格的商品可以购买——购买第 6 步设价的那几个即可。
**可选 —— 国际化。**翻译每个组件(一次性支持 35 种语言),添加一个
/{language}/…段并映射到内置的language系统组件,并将 CEL 绑定字段切换为documents.translated。参见机场目录教程的第 2 部分第 7 步。
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # 也可以使用 --public
构建命令:
generated/cms-schemas.ts被忽略在 git 之外,因此需确保构建时重新生成——添加vercel.json:{ "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun run generate-schemas && next build" }
在 Vercel 中:Add New → Project,导入 store,并添加环境变量——PROFOUND_API_KEY、NEXT_PUBLIC_PROFOUND_WEBSITE_ID、NEXT_PUBLIC_CMS_API_URL、NEXT_PUBLIC_BUNNY_CDN_URL、STRIPE_SECRET_KEY、STRIPE_WEBHOOK_SECRET(用于部署端点,见下),以及 NEXT_PUBLIC_SITE_URL(你的生产 URL)。部署。
然后配置线上 webhook(本地 stripe listen 的密钥仅限本地):Stripe → Developers → Webhooks → + Add endpoint → https://<prod>/api/stripe/webhook,事件选择 checkout.session.completed。将获得的 whsec_… 填入 Vercel 并重新部署。
缺少环境变量 = “本地可用,线上空白”——部署问题的首要原因。此处我们在测试密钥下部署;准备正式收款时,将 STRIPE_SECRET_KEY 和 webhook 密钥切换为正式值。
两者随脚手架一起提供。
<Refresher> 会在编辑者保存时刷新预览页面——无需重新部署。(仅供编辑预览;访客仍会按正常的重新验证流程看到已发布内容。)?edit_mode=true 即可显示编辑遮罩。普通访客仍看到干净页面。**补上脚手架遗漏的预览路由。**管理后台会在
/cms-preview_<path>加载预览 iframe;若缺少该路由,预览会 404。请添加:// src/app/cms-preview_/[...slug]/page.tsx import { ParametricRoutePreviewPage } from "cms-renderer/lib/renderer"; import { registry } from "../../registry"; // 将注册表提取到共享模块 export default async function Page({ params, searchParams }) { const { slug } = await params; const PreviewPage = ParametricRoutePreviewPage as any; // 异步 RSC;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} />; }同时添加
src/app/cms-preview_/page.tsx(同样逻辑,slug: [])作为段根。
一个内容驱动的 Stripe 店面:CMS 目录、一组路由即可生成列表和详情页,以及可用的托管结账。AI 完成了目录填充、设计接线、目录读取器+组件+无头购物车;你完成了组件、Stripe 价格关联、三条路由、CEL 外观以及三份简短的 Stripe 文件。**CEL 绑定外观;组件获取目录。**而 Stripe 始终简洁——一次 sessions.create 调用和一个带签名的 webhook,顾客在 Stripe 自己的页面完成支付。