Rich product pages
Render rich product description pages from sanitized HTML, compose multi-column layouts with the cimplify-* helpers, and generate bespoke pages per slug, including with AI.
<ProductPage productId={slug} /> fetches a product and renders a complete, responsive detail page. It picks the right layout automatically and wires up variants, scheduling, and a live-priced cart. It also renders the product's description as sanitized rich HTML (not plain text): headings, lists, tables, figures, quotes, code, collapsible sections and host-locked video embeds all display correctly. The same renderer (<RichText>) is exported for use anywhere.
Built-in layouts & resolution
You don't choose a layout; <ProductPage> resolves one from the product's shape. Resolution runs in priority order:
- Per-slug
pages: a custom component mapped to a product's slug. Highest priority. - Per-type
templates: your override for a template key (see below). - Built-in layout: one of seven shipped layouts, inferred from the product.
DefaultProductLayout: the fallback.
resolveTemplateKey() picks the built-in layout:
| Layout | Chosen when |
|---|---|
Bundle | product.type === "bundle" |
Composite | product.type === "composite" |
Wholesale | quantity_pricing.length > 1 |
Service | product.type === "service" |
Digital | product.type === "digital" |
Food | render_hint === "food" |
Default | everything else (physical / fallback) |
To force a layout from data, set metadata.page_template on the product (e.g. "wholesale"); it wins over the inferred type.
Override a whole product type
Swap the built-in layout for every product of a kind with templates:
import { ProductPage, ProductTemplate } from "@cimplify/sdk/react";
import { MyServicePage } from "@/product-pages/service";
<ProductPage
productId={slug}
templates={{ [ProductTemplate.Service]: MyServicePage }}
/>;Both pages and templates components receive ProductLayoutProps (product, onAddToCart, renderImage, relatedProducts, …).
The shared customizer
Every built-in layout renders <ProductCustomizer>, the purchase engine. From the product's shape it renders the variant selector (text options, or color/image swatches when the axis values carry color_hex/image_url), add-ons, billing plans, volume-pricing tiers, service scheduling (date + time slots grouped by time of day with capacity-aware availability, a staff picker from the slot's available staff, and a live <BookingSummary> that splits deposit-due from the balance), customer input fields, quantity, and a live-priced Add to Cart that quotes through the API. Wrap your store in <CartDrawerProvider openOnAdd> so adds open a slide-in <CartDrawer>, or use <ProductSheet> for an in-place quick view.
Responsive
Layouts are responsive out of the box: a single stacked column on phones and tablets, two columns (media / details) at lg and up. Swatches, slot grids, and the image gallery reflow with the viewport. For a mobile quick-buy, render <ProductSheet> inside your own bottom sheet; <CartDrawer> handles the add-to-cart confirmation.
Rendering: SSG / ISR (recommended)
A PDP is SEO-critical, so render it on the server and pre-build every slug. Don't fetch it in a client component. <ProductPage> is built for this: pass a server-fetched product to the product prop and it skips the client fetch entirely.
The canonical Server Component (this is what cimplify init scaffolds):
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ProductPage } from "@cimplify/sdk/react";
import { getServerClient, tags } from "@cimplify/sdk/server";
import { WirelessEarbudsPage } from "@/product-pages/wireless-earbuds";
// Pre-enumerate every slug at build time so the route emits cacheable
// (s-maxage) responses instead of being treated as fully dynamic.
export async function generateStaticParams() {
const r = await getServerClient().catalogue.getProducts({ limit: 10_000 });
return r.ok ? r.value.items.map((p) => ({ slug: p.slug ?? p.id })) : [];
}
export const revalidate = 3600; // ISR: re-validate hourly (and on webhook tag)
async function load(slug: string) {
return getServerClient().catalogue.getProductBySlug(slug, {
cacheOptions: { revalidate: 3600, tags: [tags.product(slug)] },
});
}
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> },
): Promise<Metadata> {
const { slug } = await params;
const r = await load(slug);
if (!r.ok) return {};
return { title: r.value.name, description: r.value.description ?? undefined };
}
export default async function Page(
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const r = await load(slug);
if (!r.ok) {
if (r.error.code === "NOT_FOUND") notFound();
throw new Error(r.error.message); // transient; ISR retries next request
}
return (
<ProductPage
product={r.value}
pages={{ "wireless-earbuds": WirelessEarbudsPage }}
/>
);
}generateStaticParams plus export const revalidate give you static-at-build, revalidated-on-demand pages. generateMetadata and a <script type="application/ld+json"> Product block (see the scaffolded template) cover SEO. Pair with tag-based revalidation so a deploy or price change invalidates the exact slug. On Cloudflare Workers, stay on ISR: don't enable cacheComponents / 'use cache', and use the SDK's cacheOptions.
Note: The client form,
<ProductPage productId={slug} />inside a"use client"component withuseParams, is a quick start only. It renders nothing on the server and ships no metadata, so reach for it only in app-shell contexts that can't server-render.
Rich descriptions
Beyond the layout, the product's description is rendered as rich HTML. There are two ways to produce that content, depending on whether it's data (changes without a deploy) or code (a bespoke layout):
descriptionHTML: stored on the product, rendered by<RichText>. Edit it and it's live instantly. Covers the large majority of rich product content.- Per-slug component (
pages): a custom React layout for a specific product. Maximum control, but it ships with your storefront build.
What description HTML supports
Descriptions are sanitized on every render (on the Cloudflare Workers edge during SSR, and again in the browser), so untrusted markup is safe. The allowlist covers:
- Text:
p,h1–h6,strong/b,em/i,u,s/del,mark,sub,sup,code - Lists:
ul,ol,li, and definition listsdl/dt/dd - Tables:
table,thead,tbody,tr,th,td,caption(ideal for spec sheets and comparison charts) - Media:
img,figure/figcaption, and host-lockediframe(YouTube / Vimeo only) - Structure:
blockquote,pre,hr,details/summary,div,section - Inline CSS is filtered to a safe property allowlist (
text-align,color, sizing).position,z-index,url(), event handlers,<script>andjavascript:URLs are stripped.
<th>/<td> get bordered, theme-aware table styling; iframes render responsively (aspect-video); figures get captions, all wired to your storefront's design tokens.
Layout helpers for multi-column modules
Tailwind classes you put inside a description string won't be styled: the storefront's Tailwind build only scans its own source, never your runtime content. To build image-and-text modules, grids and banners from description HTML, use the namespaced cimplify-* classes shipped in @cimplify/sdk/styles.css (always present, no purge):
| Class | Effect |
|---|---|
cimplify-cols-2 / -3 / -4 | Responsive grid (1 col → 2 → N) |
cimplify-media | Two-column image + text module |
cimplify-media cimplify-media--reverse | Same, image on the right |
cimplify-card | Bordered, padded card for grid items |
cimplify-banner + cimplify-banner__content | Image with overlaid text |
Make sure your globals.css imports the SDK stylesheet:
@import "@cimplify/sdk/styles.css";An image-and-text module plus a feature grid, authored as plain HTML in the description:
<div class="cimplify-media">
<img src="https://cdn.example.com/hero.jpg" alt="In use outdoors" />
<div>
<h3>Built for the trail</h3>
<p>IP68 dust and water resistance, 40-hour battery, and a titanium frame.</p>
</div>
</div>
<div class="cimplify-cols-3">
<div class="cimplify-card"><h4>40h battery</h4><p>All-week endurance.</p></div>
<div class="cimplify-card"><h4>IP68</h4><p>Dust and water sealed.</p></div>
<div class="cimplify-card"><h4>Titanium</h4><p>Lighter, stronger.</p></div>
</div>A comparison chart is just a table:
<table>
<thead><tr><th>Feature</th><th>Pro</th><th>Standard</th></tr></thead>
<tbody>
<tr><td>Battery</td><td>40h</td><td>24h</td></tr>
<tr><td>Water resistance</td><td>IP68</td><td>IP54</td></tr>
</tbody>
</table>Using <RichText> directly
import { RichText } from "@cimplify/sdk/react";
export function Article({ html }: { html: string }) {
return <RichText html={html} className="text-base" />;
}sanitizeRichTextHtml(html) is also exported if you need the cleaned string without rendering.
Bespoke pages per slug (and AI generation)
For flagship products that need a pixel-perfect, layout-heavy page, pass a per-slug component via pages. It takes top priority over every built-in layout, and it's just another prop on the server-rendered <ProductPage> shown above:
<ProductPage
product={product}
pages={{ "wireless-earbuds": WirelessEarbudsPage }}
/>Each page component receives ProductLayoutProps (product, onAddToCart, renderImage, relatedProducts, and more), so it has the full product and cart wiring. Because the product is fetched on the server, the bespoke page server-renders too.
Which path for AI?
- Generate
descriptionHTML when content should update without a redeploy and at scale across the catalogue. The AI emits the HTML andcimplify-*classes above; it's stored on the product and rendered safely. This is the default target. - Generate a per-slug component only for a handful of hero products that need a custom layout. The output is
.tsxthat ships with the storefront build.
Both are first-class. Most catalogues are best served almost entirely by generated description HTML, reserving bespoke components for the few pages that truly need them.
Component catalog
90+ React components ship in `@cimplify/sdk/react`. This page covers the page components you mount on routes, the cart drawer, and the most-used primitives. Every component is also [ejectable](/docs/cli/components) via ` cimplify add <name>` when you need full control.
React hooks
30+ hooks from `@cimplify/sdk/react`: typed, cached, and provider-driven. Each requires a `<CimplifyProvider>` ancestor.