Практичний посібник: створіть контентно-орієнтований Stripe-магазин у Profound CMS — каталог, що мерчант редагує без коду, два параметричні маршрути, безголовий кошик і оформлення Stripe на хостингу.
Готовий магазин у дії — перегляньте категорію, відкрийте товар, додайте до кошика, оформіть оплату.
Практичний посібник, що створює контентно-орієнтований магазин у Profound CMS: каталог товарів (категорії + позиції), змодельований у CMS, сторінки списку й деталей з одного набору маршрутів та оформлення Stripe на хостингу, яке постачається як безголовий компонент.
Основу складають написаний вручну Next.js і адмінка Profound. Claude Code (через Profound MCP) бере на себе основну роботу у трьох завданнях — наповнення каталогу, під’єднання системи дизайну та написання компонентів вітрини (включно з безголовим кошиком). Три частини: Налаштування, Створення, Продакшн.
Платежі в один рядок. Ми використовуємо Stripe-hosted 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) для локальних webhook’ів.gh) і обліковий запис Vercel, під’єднаний до GitHub.Profound розділяє контент і рендеринг:
category, item); компонент із тегом UI Element можна розмістити
на сторінці (nav, product_grid, …).meta.params.* у CEL, routeParams у React).cms-renderer; Stripe додається як звичайні API-маршрути.Єдине правило, що формує збірку: CEL прив’язує лише поля string/number. Тож
скалярна «оболонка» (бренд у навігації, футер, заголовки) прив’язується через CEL, тоді як
все багатше або колекції (сітка товарів, галерея зображень, rich text) отримуються
всередині React-компонента за параметром маршруту. І Stripe — джерело істини для
ціноутворення — поле price у CMS лише для відображення; списання завжди визначається на
сервері за Stripe Price ID.
Результат: невеликий опублікований каталог, застосунок, під’єднаний для його читання, Stripe встановлено, дизайн на місці — але ще нічого не рендериться.
Зареєструйтеся в Profound (аутентифікація WorkOS). Створіть сайт із назвою store, потім
скопіюйте його ID сайту (UUID в URL адмінки) та API-ключ рівня читання (Deployments →
Create API key). Застосунок лише читає; сидування каталогу пізніше відбуватиметься через MCP,
який автентифікується окремо.
bunx create-profound-next store
cd store
bun add stripe
Каркас — це проєкт Next.js App Router із попереднім підключенням до Profound (SDK
cms-renderer, маршрут із перехопленням усіх запитів, скрипт generate-schemas, компонент
<Refresher>). Стилів немає. bun add stripe додає серверний SDK — єдину залежність, яку
потребує hosted checkout.
Додайте свої значення до .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_... # тестовий ключ; замініть на бойовий для production
STRIPE_WEBHOOK_SECRET=whsec_... # заповните на кроці 4 у частині Створення
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Отримайте STRIPE_SECRET_KEY у Stripe → Developers → API keys. Ми використовуємо тестовий
ключ (sk_test_…), щоб під час збірки гроші не рухались; коли будете готові приймати
справжні платежі, перейдіть на бойовий ключ. Запустіть bun dev і відкрийте
localhost:3000 — стартовий проект рендериться.
Hosted checkout перенаправляє браузер на URL Stripe, тож серверного секретного ключа достатньо — жодного публічного ключа чи клієнтського SDK Stripe не потрібно.
category, product_image та itemСтворіть три Custom Components (Components → Create new component) — джерела даних, тож без тегу UI Element. Увімкніть Active для кожного.
У CMS немає поля «масив зображень», тож галерея — це масив посилань на невеликий
компонент product_image. Створіть category та product_image (і активуйте їх)
перед item — поле Reference може посилатися лише на активні компоненти.
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)Залиште всі поля необов’язковими. Адмінка перетворює назви полів на snake_case ("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-схеми та типи у 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:
Згенеруй невеликий e-commerce-каталог для магазину 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 створи кожен документ у статусі published: спочатку створюй категорії, збережи їхні IDs, потім створи товари з полемcategory, що вказує на посилання —{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. ЗалишstripePriceIdпорожнім. Товари можна створювати паралельно.
Claude створює data/catalog.json, валідуючи його, і паралельно викликає create_document
(status: "published"). Спершу сидируйте категорії, щоб посилання в товарах вказували на
вже наявні ID.
Три посіяні категорії, опубліковані та Live.
Вісім посіяних товарів, кожен прив’язаний до категорії.
Каталог уже в CMS; тепер додайте реальну ціну Stripe для кількох товарів — робота мерчанта в двох адмінках, без коду:
price_…).stripePriceId, збережіть.CMS зберігає каталог; Stripe — ціну; зв’язок — один рядок, який мерчант вставляє. (Хочете автоматизувати? Офіційний Stripe MCP може створити Products/Prices за вас — вставляєте отримані IDs так само.)
Каркас без стилів. Додайте DESIGN.md (блок @theme Tailwind v4 + токени) у корінь проєкту —
власний або завантажений із refero.design.
Після цього зверніться до Claude, обмеживши запит лише стилями:
Прочитай дизайн-файл, який я щойно додав. Налаштуй Tailwind за потреби, потім під’єднай тему та шрифти, щоб стилі працювали. Використовуй
next/fontдля шрифтів — не завантажуй їх із Google під час виконання. Лише стилі — не створюй сторінки чи компоненти.
Переконайтеся, що у src/app/globals.css є @import "tailwindcss"; + блок @theme, і що
localhost:3000 показує токени. Тримайте запит вузьким (інакше агент може згенерувати цілу
головну сторінку) і завжди завантажуйте шрифти через next/font, не через runtime-запити.
Необов’язково — без зображень теж можна дійти до робочого checkout’у. Щоб їх додати:
створіть по одному документу product_image на кожне зображення (завантажте файл у поле
image), потім додайте ці посилання до масиву images у товарі. Можете використати власні
фото або згенерувати однорідний набір за допомогою моделі зображень (попросіть Claude вивести
промпти під бренд із DESIGN.md і зафіксуйте один --sref у Midjourney для узгодженості).
Окремий
cms-rendererне має хелпера для URL зображень, тож додайтеbuildAssetUrlуsrc/lib/image.ts(~40 рядків) — він додає префіксNEXT_PUBLIC_BUNNY_CDN_URLі розширення. Компоненти у кроці 3 частини «Створення» його використовують.
Створіть шар рендерингу та checkout, завершіть реальною купівлею в тестовому режимі.
П’ять компонентів, кожен Active і з тегом UI Element (Settings → Tags), без Route Slug:
nav → brandproduct_grid → headingproduct_detail → headingcart_summary → headingfooter → textУсі поля — Text. Тег UI Element показує компонент у списку Add UI Element Page Builder —
Active недостатньо. Кожне поле — скаляр (тип, який може прив’язати CEL); фактичні дані
каталогу не є полем тут — ProductGrid/ProductDetail отримують їх за параметром маршруту
(крок 3).
bun run generate-schemas
Один запит створює хелпер для читання, п’ять компонентів, кошик і реєстр:
Створи нашу вітрину в
src/, використовуючи SDK Profoundcms-renderer.
src/lib/catalog.ts— серверний читач CMS. Створи клієнт за допомогоюgetCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! })ізcms-renderer/lib/cms-api. Експортуй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), який дістає кожне посиланняitem.imagesчерезcms.documents.get.query({ websiteId, id: ref._ref })і перетворює поле зображення на URL, використовуючи доданийbuildAssetUrl(частина 1, крок 8).
src/components/— п’ять компонентів UI, зареєстрованих у реєстрі catch-all маршруту за іменем компонента, snake_case як в адмінці:{ nav, product_grid, product_detail, cart_summary, footer }.NavіFooterчитають свої скалярні поля з пропсаcontent(BlockComponentProps<T>ізcms-renderer/lib/types).ProductGridіProductDetail— асинхронні серверні компоненти, які читаютьrouteParamsі витягують дані зcatalog.ts:routeParams.<param>має вигляд{ value, … }— беріть.value, тожProductGridвикликаєlistItems(routeParams.category_code?.value)(картки ведуть на/products/{code}), аProductDetail—getItemByCode(routeParams.item_code?.value)(галерея черезresolveImages, rich text опис, ціна, кнопка «Додати в кошик»).CartSummaryпоказує кошик ізuseCartі кнопкою Pay. Тримайте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(), який надсилає POST{ lines: [{ code, quantity }] }(лише коди й кількості — жодних цін) на/api/stripe/checkout, потім перенаправляє на поверненийurl.Стилізуй усе нашою дизайн-системою, власними компонентами — не копіюй макет оригінального сайту.
Три речі, які варто пам’ятати після генерації:
content ({ content }: BlockComponentProps<T>) — якщо розпаковувати
поля як верхньорівневі пропси, блок нічого не відобразить. Дані каталогу надходять із
routeParams + запиту в catalog.ts, бо CEL не прив’язує списки чи галереї. Кошик несе
коди товарів, ніколи ціни.routeParams.<param> — це { value, schemaName, document } — використовуйте .value.
Читання повертають published_content, а не .content. Ключі реєстру — snake_case, як в адмінці.@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(); // СИРИЙ body — потрібен для перевірки підпису
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.
Запустіть Stripe CLI для локальних webhook’ів:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# скопіюйте whsec_... у STRIPE_WEBHOOK_SECRET, перезапустіть bun dev
whsec_… діє лише в межах сесії. Два правила забезпечують безпеку: checkout заново визначає
ціну за CMS по code (підроблений кошик не може змінити її), а webhook перевіряє підпис на
сирому тілі.
Admin → Pages → Create page, тричі. Зіставте кожен параметр із компонентом (поле 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, footerЗадайте nav.brand і footer.text як статичні рядки; заголовки — як статичні підписи.
Page Builder на маршруті категорії — вибрано product_grid, його заголовок прив’язано через CEL.
Page Builder на товарному маршруті — product_detail з прив’язкою до конкретного товару.
Особливість Page Builder для параметричних маршрутів: на двох параметричних маршрутах додані UI-елементи не зберігаються (блоки «осиротіють», і сторінка рендериться порожньою). Поки це не виправлено, прив’язуйте
block_idsдля цих сторінок безпосередньо через Profound MCPupdate_page, потім публікуйте. (Статичний/cartприєднується як слід.) Із тієї ж причиниProductGridотримує заголовок із категорії, яку він завантажує, а не через CEL.
/categories/lighting → сітка. Натисність на товар → сторінка деталей + «Додати в кошик». /cart → Pay./cart?status=success, а в stripe listen
з’являється checkout.session.completed.Рендер сторінки товару — галерея, ціна та кнопка «Додати в кошик».
Кошик — позиції та єдина кнопка «Pay with Stripe».
Лише товари з цінами можна купити — придбайте один із ~3, яким ви додали Stripe Price на кроці 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у.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 на live значення.
Обидві можливості входять до каркасу.
<Refresher> оновлює сторінку, яку переглядає редактор, щойно він зберігає
зміни в адмінці — без деплою. (Це прев’ю для редактора; відвідувачі бачать опублікований
контент із нормальною ревалідацією.)?edit_mode=true до будь-якого URL для появи оверлеїв
редагування. Публічні відвідувачі бачать чисту сторінку.Додайте маршрут прев’ю, якого бракує в каркасі. Адмінка завантажує фрейм попереднього перегляду за адресою
/cms-preview_<path>; без цього маршруту кожне прев’ю повертає 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; // async 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, сторінки списку й деталей з одного
набору маршрутів і робочий hosted checkout. ШІ посіяв каталог, під’єднав дизайн і написав
читач каталогу + компоненти + безголовий кошик; ви створили компоненти, пов’язали ціни Stripe,
налаштували три маршрути, CEL-хром і три невеличкі файли Stripe. CEL прив’язує оболонку;
компоненти отримують каталог. І Stripe залишився мінімальним — один виклик sessions.create
і один підписаний webhook, із покупцем, який платить на сторінці самого Stripe.