profound-logoProfound CMS
⌘K
Admin
Theme
DocsTutorialBlogPhilosophy
DocsTutorialBlogPhilosophy

Tutorials

Build & Ship an Airport DirectoryDeploymentsStripe 店面

CMS 功能

Documentation Site TemplateFeature Template BuilderTranslation ServiceOrganizations & Website HeirarchyConnect Profound CMS to your AI clientSettings Integrations设置 API 密钥Settings UsageSettings Websites
All Systems Operational
Powered Byprofound-logo
Theme

Stripe 店面

一个实践向导:在 Profound CMS 上构建以内容驱动的 Stripe 店面——商家无需写代码即可编辑目录、两条参数化路由、无头购物车以及 Stripe 托管结账。

完成后的商店动态演示——浏览分类、打开商品、加入购物车并结账。

一个实践向导,带你在 Profound CMS 上构建一个以内容驱动的商店:一个在 CMS 中建模的产品目录(分类 + 商品),一组路由即可生成列表页和详情页,以及作为无头组件交付的Stripe 托管结账。

骨架是手写的 Next.js 加上 Profound 管理后台。Claude Code(通过 Profound MCP)承担了三项繁重工作——填充目录、接入设计系统以及编写店面组件(包括无头购物车)。共分三部分:准备、构建、上线。

一行代码即可完成支付。 我们使用 Stripe 托管 Checkout:顾客在 Stripe 的页面上付款,而不是你的页面。你的应用只需完成两个服务端任务——创建 Checkout Session 并验证一个 webhook。没有卡片输入框、没有 Stripe Elements,也无 PCI 负担。

你将构建什么

  • 在 CMS 中发布一个小型目录——三个分类和八个商品(“爱迪生的发明”演示)——商家无需写代码即可编辑每件商品。
  • 两条参数化路由(/products/{item_code}、/categories/{category_code})加一个静态的 /cart,全部复用同一组组件。
  • 一个 无头购物车(useCart)和 Stripe 托管结账,价格始终在服务端通过 Stripe Price ID 解析。
  • 将商店部署到 Vercel,为团队提供实时预览和页面内编辑。

先决条件

  • Bun ≥ 1.3 — curl -fsSL https://bun.sh/install | bash
  • 带有 Profound MCP 的 Claude Code(第一部分会安装)。
  • 一个 Profound CMS 账户。
  • 一个 Stripe 账户。本教程在 测试模式 下运行,在你构建期间不会真正扣款——但流程与正式密钥完全相同,如有需要,可直接使用你的正式账户密钥。(测试模式无需填写企业或银行信息。)
  • Stripe CLI(stripe login),用于本地 webhooks。
  • 用于部署:GitHub CLI(gh)以及一个已连接 GitHub 的 Vercel 账户。

各部分如何协同工作

Profound 将内容与渲染分离:

  • 组件定义内容结构。带有 Route Slug 字段的自定义组件可路由(category、item);带 UI Element 标签的组件可放置在页面上(nav、product_grid 等)。
  • 文档就是内容(一个商品、一个分类)。
  • UI 元素是页面区块;每个标量字段可填静态值或 CEL 表达式,并在渲染时求值。
  • 参数化路由将 URL 映射到文档 + UI 元素,并传递路由参数(在 CEL 中为 meta.params.*,在 React 中为 routeParams)。
  • 你的 Next.js 应用通过 cms-renderer 读取数据;Stripe 则以常规 API 路由接入。

塑造整个构建的核心规则:CEL 只能绑定string/number 字段。因此标量外观(导航品牌、页脚、标题)用 CEL 绑定,而任何富文本或集合(商品网格、图片画廊、富文本)则需在 React 组件内部通过路由参数进行获取。同时,Stripe 是价格的权威来源——CMS 中的 price 仅供展示;实际扣款始终在服务端根据 Stripe Price ID 解析。

第 1 部分 —— 准备

目标状态:一个已发布的小型目录,应用已连通可读取,Stripe 安装完毕,设计就位——暂未渲染任何页面。

1. 注册并创建网站

在 Profound 上注册(WorkOS 认证)。创建一个名为 store 的网站,复制其 Website ID(管理后台 URL 中的 UUID)以及一个读取级别 API 密钥(Deployments → Create API key)。应用只读;之后的目录填充通过 MCP 完成,会单独认证。

2. 脚手架应用、连接并添加 Stripe

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。

3. 定义 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 引用。

4. 将组件拉取为本地类型

bun run generate-schemas

会将 Zod schema 和类型写入 generated/cms-schemas.ts(categorySchema/Category、itemSchema/Item)。这同时也是连通性检查——凭证错误会导致这里失败。

5. 通过 Profound MCP 填充目录

首次安装并认证 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。

八个已填充的商品,每个都关联到一个分类。

6. 创建 Stripe 价格并关联部分商品

目录已在 CMS 中;现在为几个商品配置真实的 Stripe Price——这是商家的工作,在两个管理后台完成,无需写代码:

  1. Stripe 控制台 → Products → + Add product,设定一次性价格,复制 Price ID(price_…)。
  2. 为约 3 个主推商品执行此操作(例如白炽灯、留声机、活动电影放映机)。
  3. Profound 管理后台 → item → Documents → 将每个 Price ID 粘贴到 stripePriceId 中并保存。

CMS 负责维护目录;Stripe 负责价格权威;连接两者的就是商家粘贴的一串 ID。(想自动化?官方 Stripe MCP 可以为你创建 Products/Prices——将返回的 ID 同样粘贴进去即可。)

7. 引入设计系统并借助 AI 接线

脚手架没有样式。将一个包含 Tailwind v4 @theme 块和令牌的 DESIGN.md 放在项目根目录——可以自行编写,也可从 refero.design 下载。然后向 Claude 发出只限定于样式的提示:

阅读我刚添加的设计文件。如果需要,请安装 Tailwind,然后接入主题和字体,使样式生效。字体使用 next/font —— 不要在运行时从 Google 加载。只处理样式——不要构建任何页面或组件。

确认 src/app/globals.css 中包含 @import "tailwindcss"; 和 @theme 块,且 localhost:3000 显示令牌。保持提示简洁(过于开放会让代理搭出完整首页),并通过 next/font 加载字体,绝不要使用运行时的 Google 引入。

8. 添加商品图片(可选)

非必需——即便没有图片也能完成结账。若要添加:为每张图片创建一个 product_image 文档(在其 image 字段上传),然后在商品的 images 数组中引用。可自备商品照片,或使用图像模型生成一组风格统一的素材(让 Claude 根据 DESIGN.md 推导品牌化提示,并固定一个 Midjourney 的 --sref 以保持一致)。

独立的 cms-renderer 没有图片 URL 辅助函数,因此请在 src/lib/image.ts 中引入自定义的 buildAssetUrl(约 40 行)——它会为 NEXT_PUBLIC_BUNNY_CDN_URL 添加前缀并补齐扩展名。第三部分的组件会使用它。

第 2 部分 —— 构建

搭建渲染层和结账流程,最后完成一次真实的测试模式购买。

1. 定义五个 UI 元素组件

创建五个组件,每个都设为 Active 并打上 UI Element 标签(Settings → Tags),不设置 Route Slug:

  • nav → brand
  • product_grid → heading
  • product_detail → heading
  • cart_summary → heading
  • footer → text(全部为文本字段)

UI Element 标签让组件出现在 Page Builder 的 Add UI Element 列表中——仅 Active 还不够。每个字段都是标量(CEL 可绑定的类型);真正的目录数据不在这里——ProductGrid/ProductDetail 会在第 3 步通过路由参数获取。

2. 重新生成类型

bun run generate-schemas

3. 生成目录读取器、组件和无头购物车

使用一个提示构建读取器、五个组件、购物车以及注册表:

在 src/ 中使用 Profound 的 cms-renderer SDK 构建我们的店面。

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/checkout POST { 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 下会导致异步服务端组件区块出错。

4. 编写 Stripe 服务器代码(骨干)

三个简短的服务端文件——这是应用中唯一的支付代码。它们复用 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 会根据原始请求体验证签名。

5. 创建路由

管理后台 → Pages → 创建页面,共三次。将每个参数映射到对应组件(Slug 字段为 code):

  1. /products/{item_code} → item
  2. /categories/{category_code} → category
  3. /cart —— 一个静态页面(填写字面量 /cart,不要使用 /{cart})

6. 添加 UI 元素、绑定 CEL 并发布

对每个路由: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。

7. 渲染并完成支付

  • 访问 /categories/lighting → 商品网格。点击任一商品 → 查看商品详情并添加到购物车。访问 /cart → 点击支付。
  • 支付会跳转到 Stripe 托管结账。使用测试卡 4242 4242 4242 4242,任意未来日期和 CVC。返回 /cart?status=success,stripe listen 会显示 checkout.session.completed。

渲染后的商品页面——画廊、价格以及“加入购物车”按钮。

购物车页面——行项目和一个“Pay with Stripe”按钮。

只有已设定价格的商品可以购买——购买第 6 步设价的那几个即可。

**可选 —— 国际化。**翻译每个组件(一次性支持 35 种语言),添加一个 /{language}/… 段并映射到内置的 language 系统组件,并将 CEL 绑定字段切换为 documents.translated。参见机场目录教程的第 2 部分第 7 步。

第 3 部分 —— 上线

1. 发布:GitHub,然后 Vercel

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 密钥切换为正式值。

2. 实时预览与页面内编辑

两者随脚手架一起提供。

  • 实时预览:<Refresher> 会在编辑者保存时刷新预览页面——无需重新部署。(仅供编辑预览;访客仍会按正常的重新验证流程看到已发布内容。)
  • **页面内编辑:**在任意 URL 后添加 ?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 自己的页面完成支付。

Continue Reading
Previous‹Deployments