Skip to main content

Patterns

Recipes that come up in real shops.

Homepage fallback

The Propeller convention for a CMS-driven homepage: if the adapter returns null, render a built-in <HomeFallback> instead. This lets a shop launch without a CMS and add one later without touching the homepage route.

// app/page.tsx — Next.js App Router
import { CmsPageRenderer } from 'propeller-v2-cms-react';
import { HomeFallback } from '@/components/home/HomeFallback';
import { adapter } from '@/lib/cms';
import { cmsRenderers } from '@/cms/renderers';

export default async function Home() {
const page = await adapter.getPage('home');
if (!page) return <HomeFallback />;
return <CmsPageRenderer page={page} renderers={cmsRenderers} />;
}

The non-homepage CMS catch-all route uses the opposite convention: if the adapter returns null, call notFound(). There is no generic "page-not-found-in-CMS" fallback.

Catch-all route

// app/(cms)/[...slug]/page.tsx
import { notFound } from 'next/navigation';
import { CmsPageRenderer } from 'propeller-v2-cms-react';
import { adapter } from '@/lib/cms';
import { cmsRenderers } from '@/cms/renderers';

export default async function CmsPage({
params,
}: {
params: { slug: string[] };
}) {
const slug = params.slug.join('/');
const page = await adapter.getPage(slug);
if (!page) notFound();
return <CmsPageRenderer page={page} renderers={cmsRenderers} />;
}

No CMS configured

A shop without a CMS still wraps the tree with <CmsAdapterProvider adapter={null}> so client islands using useCms() are safe. The homepage gets <HomeFallback>, and the catch-all route either is not mounted at all or returns 404 for every slug.

Preview mode

The Strapi adapter (and most others) accept a preview: true option. Wire two adapter instances — one preview, one published — and pick which one to use based on a preview cookie:

// lib/cms.ts
import { cookies } from 'next/headers';

const publishedAdapter = createStrapiAdapter({ endpoint, preview: false });
const previewAdapter = createStrapiAdapter({ endpoint, preview: true });

export function getAdapter() {
const isPreview = cookies().get('cms-preview')?.value === '1';
return isPreview ? previewAdapter : publishedAdapter;
}

Then use getAdapter() instead of a singleton adapter import in your routes. The client-side useCms() hook should still receive the published adapter — preview state is server-only.

Multi-locale

Pass locale through to adapter.getPage:

export default async function CmsPage({
params,
}: {
params: { locale: string; slug: string[] };
}) {
const page = await adapter.getPage(params.slug.join('/'), {
locale: params.locale,
});
if (!page) notFound();
return <CmsPageRenderer page={page} renderers={cmsRenderers} />;
}

The adapter is responsible for falling back to a default locale if the exact one isn't published; the renderer just consumes whatever shape comes back.

Per-block data fetching

Block components are free to fetch their own data — <CmsPageRenderer> does not orchestrate child data:

function ProductCarouselBlock({ data }: { data: { skus: string[] } }) {
const products = useProducts(data.skus); // your own hook
if (!products) return <CarouselSkeleton />;
return <Carousel items={products} />;
}

This keeps the renderer pure and lets blocks evolve independently.