Un recorrido práctico: construye un escaparate de Stripe impulsado por contenido en Profound CMS — un catálogo que un comerciante edita sin código, dos rutas paramétricas, un carrito sin cabeza y un checkout alojado en Stripe.
La tienda terminada en movimiento: explora una categoría, abre un producto, agrégalo al carrito y completa la compra.
Un recorrido práctico que construye una tienda impulsada por contenido en Profound CMS: un catálogo de productos (categorías + artículos) modelado en el CMS, páginas de listado y detalle desde un único conjunto de rutas, y un checkout alojado en Stripe entregado como un componente sin cabeza.
La columna vertebral está escrita a mano en Next.js junto con el panel de administración de Profound. Claude Code (a través del MCP de Profound) hace el trabajo pesado en tres tareas: sembrar el catálogo, conectar el sistema de diseño y escribir los componentes del escaparate (incluido el carrito sin cabeza). Tres partes: Configuración, Construcción, Producción.
Pagos en una sola línea. Usamos Stripe-hosted Checkout: el comprador paga en la página de Stripe, no en la tuya. Tu aplicación hace solo dos cosas del lado del servidor: crear una sesión de Checkout y verificar un webhook. Sin campos de tarjeta, sin Stripe Elements, sin carga PCI.
/products/{item_code}, /categories/{category_code}) más un /cart estático, todo desde un único conjunto de componentes.useCart) y checkout alojado en Stripe, con el precio siempre resuelto en el servidor a partir de un Stripe Price ID.curl -fsSL https://bun.sh/install | bashstripe login) para webhooks locales.gh) y una cuenta de Vercel conectada a GitHub.Profound separa el contenido del renderizado:
category, item); uno etiquetado como UI Element puede colocarse en una página (nav, product_grid, …).meta.params.* en CEL, routeParams en React).cms-renderer; Stripe se agrega como rutas API ordinarias.La única regla que guía la construcción: CEL solo enlaza campos de tipo string/number. Así que el cromado escalar (marca de la navegación, pie de página, encabezados) se enlaza con CEL, mientras que cualquier cosa rica o una colección (una cuadrícula de productos, una galería de imágenes, texto enriquecido) se obtiene dentro del componente React por parámetro de ruta. Y Stripe es la fuente de la verdad en la fijación de precios: el price del CMS es solo para mostrar; el cargo siempre se resuelve en el servidor a partir de un Stripe Price ID.
Estado final: un pequeño catálogo publicado, la aplicación conectada para leerlo, Stripe instalado, el diseño en su lugar — nada renderizado todavía.
Regístrate en Profound (autenticación WorkOS). Crea un sitio web llamado store, luego copia su ID del sitio web (el UUID en la URL del administrador) y una API key de nivel de lectura (Deployments → Create API key). La aplicación solo lee; la siembra del catálogo más adelante pasa a través del MCP, que se autentica por separado.
bunx create-profound-next store
cd store
bun add stripe
El esqueleto es un proyecto de App Router de Next.js preconfigurado para Profound (el SDK cms-renderer, una ruta catch-all, un script generate-schemas, un <Refresher>). No incluye estilos. bun add stripe incorpora el SDK del servidor — la única dependencia de pagos que necesita el checkout alojado.
Añade tus valores a .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 # sirve imágenes alojadas en el CMS
# Stripe
STRIPE_SECRET_KEY=sk_test_... # clave de prueba aquí; cámbiala por tu clave en vivo cuando publiques
STRIPE_WEBHOOK_SECRET=whsec_... # se completa en el paso 4 de Construcción
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Obtén STRIPE_SECRET_KEY en Stripe → Developers → API keys. Usamos una clave de prueba (sk_test_…) para que la construcción nunca mueva dinero real; cambia a tu clave en vivo cuando estés listo para aceptar pagos reales. Ejecuta bun dev y abre localhost:3000; el starter se renderiza.
El checkout alojado redirige el navegador a una URL de Stripe, así que la clave secreta del servidor es todo lo que Stripe necesita — sin clave publicable, sin SDK de cliente de Stripe.
category, product_image e itemCrea tres Componentes Personalizados (Components → Create new component) — la fuente de datos, por lo que sin etiqueta de UI Element. Activa cada uno.
El CMS no tiene un campo "array de imagen", así que una galería es un array de referencias a un pequeño componente product_image. Crea category y product_image (y actívalos) antes de item; un campo de referencia solo apunta a componentes activos.
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 de referencias → product_image), price (Number, centavos — solo para mostrar), currency (Select, usd), stripePriceId (Text), category (Reference → category), active (Boolean)Deja todos los campos como opcionales. El administrador convierte los nombres de campo a snake_case en minúsculas ("Stripe Price Id" → stripe_price_id) — eso es lo que tu código usará, así que vuelve a leer los nombres reales con generate-schemas después. Nombramos el identificador enrutable code (no slug): es el Route Slug y la clave para una consulta limpia documents.getByCode más adelante.
El componente item: code como Route Slug, images como referencias a product_image, además de stripePriceId y una referencia category.
bun run generate-schemas
Escribe esquemas y tipos de Zod en generated/cms-schemas.ts (categorySchema/Category, itemSchema/Item). También funciona como comprobación de conexión: si las credenciales son incorrectas, falla aquí.
Instala y autentica el MCP una vez:
claude mcp add --transport http Profound http://107.21.107.99:8081/mcp
Ejecuta mcp__Profound__authenticate, completa el flujo WorkOS y luego pide a Claude:
Genera un pequeño catálogo de ecommerce para una tienda llamada Edison's Inventions — tres categorías y estos productos, con una
descriptionbreve y fiel a la época, unpriceen centavos,currency: "usd"yactive: 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)Cada categoría necesita un
namey esecodeen minúsculas; cada artículo necesita unname, esecodeen minúsculas, ladescription,price(en centavos),currencyyactive. Guárdalo endata/catalog.jsony valídalo contra nuestros componentescategoryeitem. Luego usa el Profound MCP para crear cada uno como documento publicado: crea primero las categorías, captura sus IDs y luego crea los artículos concategoryconfigurado como referencia —{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. DejastripePriceIdvacío por ahora. Haz los artículos en paralelo.
Claude escribe data/catalog.json, lo valida y lanza llamadas create_document en paralelo (status: "published"). Siembra las categorías antes que los artículos para que las referencias apunten a IDs que ya existen.
Las tres categorías sembradas, publicadas y en vivo.
Los ocho productos sembrados, cada uno vinculado a una categoría.
El catálogo está en el CMS; ahora dales a algunos productos un precio real en Stripe — el trabajo del comerciante, hecho en dos paneles de administración, sin código:
price_…).stripePriceId, guarda.El CMS contiene el catálogo; Stripe contiene el precio oficial; el enlace es una cadena que el comerciante pega. (¿Prefieres automatizarlo? El Stripe MCP oficial puede crear los Products/Prices por ti — pega los IDs devueltos de la misma manera).
El esqueleto se entrega sin estilos. Coloca un DESIGN.md (un bloque @theme de Tailwind v4 + tokens) en la raíz del proyecto — uno propio o descárgalo de refero.design. Luego pídele a Claude, limitado solo al estilo:
Lee el archivo de diseño que acabo de añadir. Configura Tailwind si es necesario, luego integra el tema y las fuentes para que el estilo funcione. Usa
next/fontpara las fuentes — no las cargues desde Google en tiempo de ejecución. Solo el estilo — no construyas páginas ni componentes todavía.
Verifica que src/app/globals.css tenga @import "tailwindcss"; más el bloque @theme y que localhost:3000 muestre los tokens. Mantén el prompt concreto (si es muy abierto, un agente puede crear toda una página de inicio) y carga las fuentes con next/font, nunca con una importación de Google en tiempo de ejecución.
Opcional: puedes llegar a un checkout funcional sin imágenes. Para agregarlas, crea un documento product_image por imagen (sube el archivo en su campo image), luego referencia esas imágenes en el array images del producto. Trae tus propias fotos de producto o genera un conjunto coherente con un modelo de imágenes (haz que Claude derive prompts acordes con marca desde DESIGN.md y bloquea un único --sref en Midjourney para que todas las tomas coincidan).
El
cms-rendererindependiente no tiene un helper de URLs de imagen, así que incorporabuildAssetUrlensrc/lib/image.ts(~40 líneas) — anteponeNEXT_PUBLIC_BUNNY_CDN_URLy añade la extensión. Los componentes del paso 3 de Construcción lo usan.
Construye la capa de renderizado y el checkout, terminando con una compra real en modo de prueba.
Cinco componentes, cada uno Activo y etiquetado como UI Element (Settings → Tags), sin Route Slug:
nav → brandproduct_grid → headingproduct_detail → headingcart_summary → headingfooter → text (todos Text)La etiqueta UI Element es lo que hace que un componente aparezca en la lista Add UI Element del Page Builder — estar Activo no es suficiente. Cada campo es escalar (el tipo que CEL enlaza); los datos reales del catálogo no son un campo aquí — ProductGrid/ProductDetail los obtienen por parámetro de ruta (paso 3).
bun run generate-schemas
Un único prompt genera el helper de lectura, los cinco componentes, el carrito y el registro:
Construye nuestro escaparate en
src/, usando el SDKcms-rendererde Profound.
src/lib/catalog.ts— un lector del CMS del lado del servidor. Crea un cliente congetCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! })decms-renderer/lib/cms-api. ExportagetItemByCode(code)→cms.documents.getByCode.query({ websiteId, schemaName: "item", code })que devuelvares.document.published_content. ExportalistItems(categoryCode?)→cms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }), mapeares.documentsa.published_content, filtraactive !== falsey, si se proporcionacategoryCode, conserva los artículos cuyocategory._refcoincide con eldocument.idde la categoría. ExportaresolveImages(refs)que resuelva cada referenciaitem.imagesmediantecms.documents.get.query({ websiteId, id: ref._ref })y convierta su campo de imagen en una URL con elbuildAssetUrlincorporado (Parte 1 paso 8).
src/components/— cinco componentes de elementos de interfaz registrados en el registro de la ruta catch-all por nombre de componente, en snake_case para coincidir con el administrador:{ nav, product_grid, product_detail, cart_summary, footer }.NavyFooterleen su campo escalar del propcontent(tipado comoBlockComponentProps<T>decms-renderer/lib/types).ProductGridyProductDetailson componentes de servidor asíncronos que leenrouteParamsy obtienen datos decatalog.ts:routeParams.<param>es{ value, … }— lee.value, así queProductGridllamalistItems(routeParams.category_code?.value)(las tarjetas enlazan a/products/{code}) yProductDetailllamagetItemByCode(routeParams.item_code?.value)(galería medianteresolveImages, descripción de texto enriquecido, precio, Agregar al carrito).CartSummaryrenderiza el carrito desdeuseCartcon un botón Pagar. ManténformatPriceen unsrc/lib/format.tspuro para que los componentes de cliente no importen elcatalog.tssolo de servidor.
src/components/AddToCartButton.tsx— un botón"use client"que recibe{ code, name, priceLabel }y llamauseCart().addItem({ code, name, priceLabel, quantity: 1 }). Úsalo dentro deProductDetail.
src/lib/useCart.ts— un carrito sin cabeza: líneas{ code, name, priceLabel, quantity }en estado, persistidas enlocalStorage, exponiendoaddItem/removeItem/updateQty/subtotaly uncheckout()que hace POST de{ lines: [{ code, quantity }] }(solo códigos y cantidades — nunca precios) a/api/stripe/checkout, y luego redirige a laurldevuelta.Estiliza todo con nuestro sistema de diseño, como componentes propios — no copies el layout del sitio de origen.
Tres cosas clave tras ejecutarlo:
content ({ content }: BlockComponentProps<T>); si desestructuras los campos como props de primer nivel, el bloque se renderiza en blanco. Los datos del catálogo provienen de routeParams + una obtención en catalog.ts, porque CEL no puede enlazar listas ni galerías. El carrito lleva los códigos de los artículos, nunca los precios.routeParams.<param> es { value, schemaName, document } — lee .value. Las lecturas devuelven published_content, no .content. Las claves del registro están en snake_case para coincidir con el administrador.@types/react/@types/react-dom a v19 — el esqueleto se entrega con v18, que rompe los bloques de componentes de servidor asíncronos en React 19.Tres archivos breves del lado del servidor — el único código de pagos en la app. Reutilizan getItemByCode, así que el cargo se resuelve del lado del servidor.
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 — resuelve cada artículo desde el CMS, cobra el precio de Stripe:
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getItemByCode } from "@/lib/catalog"; // del lado del servidor, clave de solo lectura
export async function POST(req: Request) {
const { lines } = await req.json(); // [{ code, quantity }] — sin precios desde el cliente
const line_items = await Promise.all(
lines.map(async ({ code, quantity }: { code: string; quantity: number }) => {
const item = await getItemByCode(code); // el servidor lo resuelve desde el CMS
return { price: item!.stripe_price_id, quantity }; // precio desde el CMS, nunca del cliente
})
);
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 }); // el cliente redirige aquí
}
src/app/api/stripe/webhook/route.ts — la señal de cumplimiento confiable:
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // cuerpo RAW — requerido para verificar la firma
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") {
// fulfillment: registra el pedido / envía un recibo.
}
return new Response(null, { status: 200 });
}
Corrección necesaria del esqueleto:
src/proxy.tsreenvía cada/api/*al CMS, así que tus rutas de Stripe nunca se ejecutan. Deja que pasen primero: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(); // manejar localmente } return cmsProxy(request as unknown as Parameters<typeof cmsProxy>[0]); }; // deja sin cambios el `export const config = { matcher: [...] }` del esqueletoVerificación:
curl -X POST localhost:3000/api/stripe/webhook -d xdevuelveBad signature.
Ejecuta la Stripe CLI para webhooks locales:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copia el whsec_... en STRIPE_WEBHOOK_SECRET, reinicia bun dev
El whsec_… es por sesión. Dos reglas sostienen la seguridad: el checkout vuelve a derivar el precio desde el CMS por code (un carrito manipulado no puede cambiarlo) y el webhook verifica la firma contra el cuerpo sin procesar.
Administrador → Pages → Create page, tres veces. Mapea cada parámetro a su componente (campo slug code):
/products/{item_code} → item/categories/{category_code} → category/cart — una página estática (introduce literal /cart, no /{cart})Para cada ruta: Page Builder → Add UI Element → Custom → añade los componentes en orden, completa los campos escalares (valor estático o CEL), Publica.
/products/{item_code}: nav, product_detail, footer/categories/{category_code}: nav, product_grid, footer/cart: nav, cart_summary, footerConfigura nav.brand y footer.text con cadenas estáticas; encabezados con etiquetas estáticas.
El Page Builder en la ruta de categoría: product_grid seleccionado, su heading enlazado con CEL.
El Page Builder en la ruta de producto: el elemento product_detail en el enlace de un producto.
Advertencia del Page Builder paramétrico: en las dos rutas paramétricas, añadir elementos de interfaz no persiste (los bloques quedan huérfanos y la página se renderiza en blanco). Hasta que se corrija, conecta los
block_idsde esas páginas directamente mediante el MCP de Profoundupdate_page, luego publica. (El/cartestático se adjunta con normalidad). Por la misma razón,ProductGridderiva su heading de la categoría que obtiene en lugar de usar CEL.
/categories/lighting → la cuadrícula. Haz clic en un producto → detalle + Añadir al carrito. /cart → Pagar./cart?status=success y stripe listen muestra checkout.session.completed.Una página de producto renderizada — galería, precio y Añadir al carrito.
El carrito — líneas de artículos y un único botón Pagar con Stripe.
Solo los artículos con precio son comprables — compra uno de los ~3 que conectaste en el paso 6.
Opcional — internacionaliza. Traduce cada componente (los 35 idiomas a la vez), añade un segmento
/{language}/…mapeado al componente de sistemalanguageintegrado y cambia los campos enlazados por CEL adocuments.translated. Consulta el tutorial del directorio del aeropuerto, Parte 2 paso 7.
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # --public también es válido
Comando de build:
generated/cms-schemas.tsestá en.gitignore, así que fija el build para regenerarlo — añadevercel.json:{ "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun run generate-schemas && next build" }
En Vercel: Add New → Project, importa store y añade las variables de entorno — PROFOUND_API_KEY, NEXT_PUBLIC_PROFOUND_WEBSITE_ID, NEXT_PUBLIC_CMS_API_URL, NEXT_PUBLIC_BUNNY_CDN_URL, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (el valor del endpoint desplegado, más abajo) y NEXT_PUBLIC_SITE_URL (tu URL de producción). Despliega.
Luego conecta el webhook desplegado (el secreto stripe listen local era solo local): Stripe → Developers → Webhooks → + Add endpoint → https://<prod>/api/stripe/webhook, evento checkout.session.completed. Copia su whsec_… en Vercel y vuelve a desplegar.
Variables de entorno faltantes = "funciona localmente, en blanco en producción" — el problema de despliegue #1. Aquí desplegamos con claves de prueba; cambia STRIPE_SECRET_KEY y el secreto del webhook a tus valores en vivo cuando estés listo para aceptar pagos reales.
Ambas funciones vienen con el esqueleto.
<Refresher> actualiza la página que estás previsualizando cuando un editor guarda en el panel — sin redeploy. (Es una vista previa para el editor; los visitantes ven contenido publicado con la revalidación normal).?edit_mode=true a cualquier URL para superposiciones de edición. Los visitantes públicos ven la página limpia.Añade la ruta de vista previa que falta en el esqueleto. El administrador carga su iframe de vista previa en
/cms-preview_<path>; sin esa ruta, cada vista previa devuelve 404. Añádela:// src/app/cms-preview_/[...slug]/page.tsx import { ParametricRoutePreviewPage } from "cms-renderer/lib/renderer"; import { registry } from "../../registry"; // extrae tu registro a un módulo compartido export default async function Page({ params, searchParams }) { const { slug } = await params; ; const PreviewPage = ParametricRoutePreviewPage as any; // componente de servidor async; tipos de 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} />; }Añade también
src/app/cms-preview_/page.tsx(igual,slug: []) para la raíz del segmento.
Un escaparate de Stripe impulsado por contenido: un catálogo en el CMS, páginas de listado y detalle desde un mismo conjunto de rutas, y un checkout alojado funcional. La IA sembró el catálogo, conectó el diseño y escribió el lector del catálogo + componentes + carrito sin cabeza; tú hiciste los componentes, los enlaces de precio de Stripe, las tres rutas, el cromado con CEL y tres archivos breves de Stripe. CEL enlaza el cromado; los componentes obtienen el catálogo. Y Stripe se mantuvo pequeño: una llamada sessions.create y un webhook firmado, con el comprador pagando en la propia página de Stripe.