実践的なチュートリアル:Profound CMS上にコンテンツ駆動型のStripeストアフロントを構築します。マーチャントがコードなしで編集できるカタログ、2つのパラメトリックルート、ヘッドレスカート、Stripeホスト型チェックアウトを作成します。
完成したストアの動作 — カテゴリを閲覧し、商品を開き、カートに追加してチェックアウトします。
Profound CMS上にコンテンツ駆動型ストアを構築する実践的なチュートリアルです。CMSでモデル化した商品カタログ(カテゴリ+商品)、1組のルートから生成する一覧ページと詳細ページ、そしてヘッドレスコンポーネントとして提供するStripeホスト型チェックアウトを作成します。
中心となるのは手書きのNext.jsとProfound管理画面です。Claude Code(Profound MCP経由)が、カタログのシード、デザインシステムの接続、ストアフロントコンポーネント(ヘッドレスカートを含む)の作成という3つの作業を大きく支援します。全体はセットアップ、構築、本番環境の3部構成です。
決済を一言で。 Stripeホスト型Checkoutを使用します。購入者はあなたのページではなく、Stripeのページで支払います。アプリがサーバー側で行うことは、Checkout Sessionの作成と、1つのWebhookの検証だけです。カード入力欄もStripe ElementsもPCI対応の負担もありません。
/products/{item_code}、/categories/{category_code})と、静的な/cart。useCart)とStripeホスト型チェックアウト。価格は常にStripe Price IDからサーバー側で解決します。curl -fsSL https://bun.sh/install | bashstripe login)。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という名前のWebサイトを作成し、管理画面URLのUUIDであるWebサイトIDと、読み取り階層の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=<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 # CMSホスト画像を配信
# Stripe
STRIPE_SECRET_KEY=sk_test_... # ここではテストキー。本番時にライブキーへ変更
STRIPE_WEBHOOK_SECRET=whsec_... # Build step 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コンポーネントを定義する3つのCustom Components(Components → Create new component)を作成します。これらはデータソースなので、UI Elementタグは付けません。それぞれをActiveにします。
CMSには「画像の配列」フィールドがないため、ギャラリーは小さなproduct_imageコンポーネントへの参照の配列にします。参照フィールドはActiveなコンポーネントだけを対象にできるため、itemより先にcategoryとproduct_imageを作成してActiveにしてください。
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(参照の配列 → product_image)、price(Number、セント単位 — 表示専用)、currency(Select、usd)、stripePriceId(Text)、category(Reference → category)、active(Boolean)すべてのフィールドは任意のままにします。管理画面はフィールド名をスネークケースに変換します(「Stripe Price Id」→stripe_price_id)。コードはこの名前を使うため、次にgenerate-schemasから実際の名前を確認します。ルーティング可能なハンドルにはslugではなくcodeを使用します。これはRoute Slugであり、後で簡潔なdocuments.getByCode検索のキーにもなります。
itemコンポーネント — Route Slugとしてのcode、product_imageへの参照としてのimages、さらにstripePriceIdとcategory参照。
bun run generate-schemas
Zodスキーマと型を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というストア向けに、小規模なEコマースカタログを生成してください。3カテゴリと次の商品を含め、各商品に当時の時代背景に合う短い
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" }。現時点ではstripePriceIdは空にしてください。商品は並列で作成してください。
Claudeはdata/catalog.jsonを書き、検証し、並列のcreate_document呼び出し(status: "published")を展開します。参照先のIDがすでに存在するよう、カテゴリを商品より先にシードします。
シードされた3カテゴリ。公開済みでLiveです。
シードされた8商品。それぞれカテゴリにリンクされています。
CMSにカタログができたので、いくつかの商品に実際のStripe価格を設定します。これはコード不要で、管理画面を2つ使ってマーチャントが行う作業です。
price_…)をコピーします。stripePriceIdに貼り付けて保存します。CMSはカタログを、Stripeは正式な価格を保持し、両者の接続はマーチャントが貼り付ける1つの文字列です。(自動化したい場合は、公式のStripe MCPでProducts/Pricesを作成し、返されたIDを同じ方法で貼り付けられます。)
ひな形にはスタイルがありません。プロジェクトルートにDESIGN.md(Tailwind v4の@themeブロック+トークン)を配置します。自作のものでも、refero.designからダウンロードしたものでも構いません。その後、スタイルだけに範囲を限定してClaudeに依頼します。
追加したデザインファイルを読み込んでください。必要ならTailwindを設定し、テーマとフォントを接続してスタイルが動作するようにしてください。フォントには
next/fontを使用し、実行時にGoogleから読み込まないでください。スタイルだけを扱い、まだページやコンポーネントは作成しないでください。
src/app/globals.cssに@import "tailwindcss";と@themeブロックがあり、localhost:3000にトークンが表示されることを確認します。依頼を簡潔に保ち、フォントは必ずnext/fontで読み込んでください。
画像がなくても動作するチェックアウトは構築できます。追加する場合は、画像ごとにproduct_imageドキュメントを作成し(imageフィールドにアップロード)、商品側のimages配列から参照します。商品写真を用意するか、画像モデルで統一感のあるセットを生成します(ClaudeにDESIGN.mdからブランドに合うプロンプトを作らせ、すべての画像で同じMidjourney --srefを固定するとよいでしょう)。
独立した
cms-rendererには画像URLヘルパーがないため、buildAssetUrlをsrc/lib/image.tsに移植します(約40行)。NEXT_PUBLIC_BUNNY_CDN_URLを先頭に付け、拡張子を追加します。Build step 3のコンポーネントがこれを使用します。
レンダリング層とチェックアウトを構築し、テストモードで実際に購入できる状態にします。
5つのコンポーネントをすべてActiveにし、UI Elementタグ(Settings → Tags)を付けます。Route Slugは付けません。
nav → brand · product_grid → heading · product_detail → heading · cart_summary → heading · footer → text(すべてText)UI Elementタグによって、コンポーネントがPage BuilderのAdd UI Element一覧に表示されます。Activeにするだけでは不十分です。各フィールドはCELがバインドできるスカラーです。実際のカタログデータはここでフィールドにせず、ProductGrid/ProductDetailがルートパラメータから取得します(step 3)。
bun run generate-schemas
1つのプロンプトで、読み取りヘルパー、5つのコンポーネント、カート、レジストリを作成します。
cms-rendererSDKを使い、src/にストアフロントを構築してください。
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のdocument.idとcategory._refが一致する商品だけを残します。resolveImages(refs)は、各item.images参照をcms.documents.get.query({ websiteId, id: ref._ref })で解決し、その画像フィールドを、Part 1 step 8で移植したbuildAssetUrlでURLに変換します。
src/components/— 5つのUI要素コンポーネントを、キャッチオールルートのレジストリにコンポーネント名で登録します。管理画面に合わせてスネークケースを使います:{ nav, product_grid, product_detail, cart_summary, footer }。NavとFooterはcontentprop(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でカートを表示し、Payボタンを配置します。クライアントコンポーネントがサーバー専用のcatalog.tsをインポートしないよう、formatPriceは純粋なsrc/lib/format.tsに置きます。
src/components/AddToCartButton.tsx—{ code, name, priceLabel }を受け取り、useCart().addItem({ code, name, priceLabel, quantity: 1 })を呼ぶ"use client"ボタンです。ProductDetail内で使用します。
src/lib/useCart.ts— ヘッドレスカート。状態内の明細は{ code, name, priceLabel, quantity }とし、localStorageに保存します。addItem/removeItem/updateQty/subtotalと、{ lines: [{ code, quantity }] }(コードと数量のみ。価格は絶対に送らない)を/api/stripe/checkoutへPOSTし、返されたurlへリダイレクトするcheckout()を公開します。すべてをデザインシステムでスタイルし、独自コンポーネントとして作成してください。元サイトのレイアウトをコピーしないでください。
実行後に知っておくべきことは3つです。
content({ content }: BlockComponentProps<T>)から渡されます。フィールドをトップレベルのpropsとして分割代入しないと、ブロックは空になります。カタログデータはCELでリストやギャラリーをバインドできないため、routeParams+catalog.tsの取得で読み込みます。カートが保持するのは商品コードであり、価格ではありません。routeParams.<param>は{ value, schemaName, document }です。.valueを読みます。読み取り結果は.contentではなくpublished_contentです。レジストリキーは管理画面に合わせてスネークケースにします。@types/react/@types/react-domをv19に上げます。ひな形はv18を含むため、React 19の非同期サーバーコンポーネントブロックで問題が起きます。短いサーバーファイルを3つ作ります。アプリ内の決済コードはこれだけです。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(); // RAWボディ — 署名検証に必須
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が返ることを確認します。
ローカルWebhook用にStripe CLIを実行します。
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# whsec_...をSTRIPE_WEBHOOK_SECRETにコピーして、bun devを再起動
whsec_…はセッションごとに異なります。セキュリティ上の重要なルールは2つです。チェックアウトではcodeを使ってCMSから価格を再取得するため、改ざんされたカートで価格を変更できません。またWebhookはRAWボディに対して署名を検証します。
Admin → Pages → Create pageを3回実行します。各パラメータをコンポーネント(slugフィールドはcode)に対応付けます。
/products/{item_code} → item/categories/{category_code} → category/cart — 静的ページ(リテラルの/cartを入力し、/{cart}にはしない)各ルートでPage Builder → Add UI Element → Customを選び、コンポーネントを順番に追加します。スカラー値フィールドに静的値またはCELを入力し、Publishします。
/products/{item_code}: nav、product_detail、footer/categories/{category_code}: nav、product_grid、footer/cart: nav、cart_summary、footernav.brandとfooter.textには静的文字列を設定し、見出しにも静的なラベルを設定します。
カテゴリルートのPage Builder — product_grid要素を選択し、見出しをCELでバインドしています。
商品ルートのPage Builder — 商品バインディング上にproduct_detail要素があります。
パラメトリックPage Builderの注意点: 2つのパラメトリックルートでは、UI要素の追加が保存されません(ブロックが孤立し、ページが空になります)。修正されるまでは、Profound MCPの
update_pageでそれらのページのblock_idsを直接設定してから公開してください。(静的な/cartは通常どおり接続されます。)同じ理由で、ProductGridはCELではなく取得したカテゴリから見出しを導出します。
/categories/lighting → グリッド。商品をクリックすると詳細+カート追加が表示されます。/cart → Pay。/cart?status=successに戻り、stripe listenにはcheckout.session.completedが表示されます。レンダリングされた商品ページ — ギャラリー、価格、カート追加。
カート — 明細と1つのStripe決済ボタン。
購入できるのは価格が設定された商品だけです。step 6で価格を設定した約3商品のいずれかを購入してください。
任意 — 国際化。 各コンポーネントを翻訳し(35言語すべてを一度に対応可能)、組み込みの
languageSystem componentに対応付けた/{language}/…セグメントを追加し、CELでバインドしたフィールドをdocuments.translatedに切り替えます。airport directoryチュートリアルのPart 2 step 7を参照してください。
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # --publicでも可
ビルドコマンド:
generated/cms-schemas.tsはgitignoreされているため、ビルド時に再生成するよう固定します。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カタログ、1組のルートから生成する一覧+詳細ページ、そして動作するホスト型チェックアウトが揃いました。AIがカタログをシードし、デザインを接続し、カタログリーダー+コンポーネント+ヘッドレスカートを書きました。あなたが行ったのは、コンポーネント、Stripe価格とのリンク、3つのルート、CELによるUI要素、そして3つの短いStripeファイルです。CELはUI要素をバインドし、コンポーネントはカタログを取得します。 Stripeの実装も小さく、sessions.createの呼び出し1つと署名付きWebhook 1つだけです。購入者はStripe自身のページで支払います。