Documentation

Dynamic pages from CMS

The catch-all + AppEngine site/page module — let editors run your site.

You can hand-code every Next.js route, or you can let the AppEngine site/page module own page content and render it through a single [[...slug]] catch-all. The second option is what base-app does — every page on the site is a CMS record, fetched at request time, and rendered through a runtime component loader. Editors add pages without a deploy.

This tutorial walks through the pattern.

What the catch-all does

Next.js's [[...slug]] matches every path that no other route handles. You wire it once and any URL — /, /about, /products/sneakers, /blog/why-we-rebuilt — falls through to the same component, which fetches a page record from AppEngine and renders it.

The route file is small:

// src/app/[[...slug]]/page.tsx
import { notFound } from 'next/navigation';
import { cache } from 'react';
import { headers } from 'next/headers';
import { getPageData } from '@/lib/get-page-data';
import { PageRenderer } from '@/components/page-renderer';
import { SiteNotFound } from '@/components/site-not-found';

const loadPageData = cache(getPageData);

export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug?: string[] }>;
  searchParams: Promise<Record<string, any>>;
}) {
  const resolvedParams = await params;
  const slug = resolvedParams.slug ?? ['index'];
  const headersList = await headers();
  const resolvedSearchParams = await searchParams;

  const { page, site } = await loadPageData(slug, headersList, resolvedSearchParams);

  if (!page) {
    if (site) return <SiteNotFound site={site} />;
    return notFound();
  }

  return <PageRenderer page={page} site={site} />;
}

base-app/src/app/[[...slug]]/page.tsx adds metadata, presentation mode, build-studio integration, and JSON-LD. The skeleton above is the minimum.

Fetching site + page

Two reads, in order: the site (matches the request's host to a site record) and the page (matches the slug under that site). Both are AppEngine calls.

// src/lib/get-page-data.ts
// pattern from base-app/src/lib/get-page-data.ts
import { headers as nextHeaders } from 'next/headers';
import { appengine } from './appmint-client';

export async function getPageData(slug: string[], headers, searchParams?) {
  const headersObj = headers instanceof Promise ? await headers : headers;
  const hostName = headersObj.get('host') || '';
  const requestPath = slug.join('/');

  // 1. Resolve host → site
  let site = null;
  try {
    site = await appengine.request(
      'get',
      `site/by-host/${encodeURIComponent(hostName)}`,
    );
  } catch (err) {
    console.error('Site lookup failed:', err);
  }
  if (!site) return { site: null, page: null };

  const siteOrgId = site.pk.split('|')[0];

  // 2. Resolve slug → page
  let page = null;
  let queryParams = '';
  if (searchParams) {
    const params = await searchParams;
    queryParams = new URLSearchParams(params as any).toString();
  }
  try {
    page = await appengine.request(
      'get',
      `site/${site.data.name}/page?path=${encodeURIComponent(requestPath)}&${queryParams}`,
    );
  } catch (err) {
    console.error('Page lookup failed:', err);
  }

  return {
    site,
    page,
    pageMeta: {
      title: page?.data?.title || site?.data?.title,
      description: page?.data?.description || site?.data?.description,
      keywords: page?.data?.keywords || site?.data?.keywords,
    },
    siteOrgId,
  };
}

The site record carries the org id (it's encoded in pk), the global theme, header/footer config, and feature flags. The page record carries the actual content tree. Caching with React.cache() (in the page component above) makes sure both are fetched only once per request — generateMetadata and Page share the same fetch.

What's in a page record

A CMS page is a BaseModel<Page>. The interesting bits live under data:

{
  pk: "my-org|page",
  sk: "my-org|page|about",
  datatype: "page",
  data: {
    name: "about",
    title: "About us",
    description: "Who we are.",
    path: "/about",
    appType: "regular",        // or "presentation"
    layout: "default",
    content: { /* component tree */ },
    blocks: [ /* alt format: ordered list of blocks */ ],
    seo: { robots: "index,follow", ogImage: "..." },
    publishedAt: "2026-04-22T...",
    state: "published",
  }
}

The content field is a tree of components. Each node carries a type (button, hero, product-grid, custom-block-foo) and a props bag. The renderer's job is to walk the tree, look up the component for each type, and render it.

Build a runtime component loader

A naive switch over types works, but you'll add to it every time the CMS gets a new block. The base-app pattern is a lazy registry — register components once, look them up by name at render time.

// src/components/page-renderer/registry.ts
import dynamic from 'next/dynamic';
import { ComponentType } from 'react';

type Loader = () => Promise<{ default: ComponentType<any> }>;

const registry: Record<string, ComponentType<any>> = {};

export function register(name: string, loader: Loader, opts: { ssr?: boolean } = {}) {
  registry[name] = dynamic(loader, { ssr: opts.ssr ?? true, loading: () => null });
}

export function lookup(name: string): ComponentType<any> | null {
  return registry[name] ?? null;
}

Register your blocks once, in a single file imported by the renderer:

// src/components/page-renderer/blocks.ts
import { register } from './registry';

register('hero', () => import('./blocks/hero'));
register('cta', () => import('./blocks/cta'));
register('product-grid', () => import('./blocks/product-grid'));
register('rich-text', () => import('./blocks/rich-text'));
register('image', () => import('./blocks/image'));
register('section', () => import('./blocks/section'));

next/dynamic chunk-splits each block — pages that don't use the product grid don't pay for it.

The renderer

The renderer walks the content tree and emits the right components.

// src/components/page-renderer/index.tsx
import './blocks';            // register blocks at module load
import { lookup } from './registry';

type Node = {
  type: string;
  id?: string;
  props?: Record<string, any>;
  children?: Node[];
};

export function PageRenderer({ page, site }: { page: any; site: any }) {
  const root: Node | undefined = page?.data?.content;
  if (!root) return null;
  return <RenderNode node={root} ctx={{ site, page }} />;
}

function RenderNode({ node, ctx }: { node: Node; ctx: any }) {
  const Comp = lookup(node.type);
  if (!Comp) {
    if (process.env.NODE_ENV === 'development') {
      return <div className="text-red-600">[unknown block: {node.type}]</div>;
    }
    return null;
  }
  return (
    <Comp {...node.props} ctx={ctx}>
      {node.children?.map((child, i) => (
        <RenderNode key={child.id || i} node={child} ctx={ctx} />
      ))}
    </Comp>
  );
}

A block is a regular React component:

// src/components/page-renderer/blocks/hero.tsx
import Image from 'next/image';

export default function Hero({ heading, subheading, image, ctaHref, ctaLabel }: any) {
  return (
    <section className="py-16 text-center">
      {image && <Image src={image} alt="" width={800} height={400} />}
      <h1 className="text-4xl font-semibold">{heading}</h1>
      {subheading && <p className="mt-2 text-gray-600">{subheading}</p>}
      {ctaHref && <a href={ctaHref} className="inline-block mt-6 px-4 py-2 bg-blue-600 text-white rounded">{ctaLabel}</a>}
    </section>
  );
}

Blocks that need server-only data (a product grid, a feed) can call AppEngine directly — they're rendered inside the RSC tree:

// src/components/page-renderer/blocks/product-grid.tsx
import { appengine } from '@/lib/appmint-client';

export default async function ProductGrid({ category, limit = 8 }: any) {
  const products = await appengine.request<{ data: any[] }>(
    'get',
    `storefront/products?category=${encodeURIComponent(category)}&ps=${limit}`,
  );
  return (
    <ul className="grid grid-cols-4 gap-4">
      {products.data.map(p => (
        <li key={p.sk}><a href={`/products/${p.data.slug}`}>{p.data.name}</a></li>
      ))}
    </ul>
  );
}

Each block decides whether it's a server component or a client component. The renderer doesn't care.

Static path filter

Some paths shouldn't hit the page lookup — /api/..., /_next/..., file extensions, well-known endpoints. Filter them out before the AppEngine call.

// src/lib/is-static-path.ts
export function isStaticPath(slug: string[]): boolean {
  if (!slug || slug.length === 0) return false;
  const path = slug.join('/');
  return (
    path.startsWith('api/') ||
    path.startsWith('_next/') ||
    path.startsWith('public/') ||
    path.includes('.') ||
    path.startsWith('.well-known/')
  );
}

In the catch-all:

if (isStaticPath(slug)) return notFound();

SEO metadata

Generate metadata from the page record so the head matches the body:

// src/app/[[...slug]]/page.tsx
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { cache } from 'react';
import { getPageData } from '@/lib/get-page-data';

const loadPageData = cache(getPageData);

export async function generateMetadata({ params }: any): Promise<Metadata> {
  const resolvedParams = await params;
  const slug = resolvedParams.slug ?? ['index'];
  const headersList = await headers();
  const { pageMeta } = await loadPageData(slug, headersList);
  return {
    title: pageMeta?.title,
    description: pageMeta?.description,
    keywords: pageMeta?.keywords,
  };
}

React.cache(getPageData) makes sure both generateMetadata and the page render share a single fetch.

Cache control

Two layers worth thinking about:

  • In-request dedupReact.cache() (above). Free, automatic, only catches duplicate calls inside the same render.
  • Cross-request cache — set revalidate on the fetch call (Next's data cache) for static-ish pages.

If you're using the axios-based client, the second layer needs fetch() instead of axios. Drop in a fetch path for read-only AppEngine calls when you want Next's cache:

// in get-page-data.ts
const res = await fetch(`${appmintConfig.appengine.host}/site/...`, {
  headers: { orgid: appmintConfig.orgId },
  next: { revalidate: 60, tags: [`site:${hostName}`] },
});
const site = await res.json();

Then revalidate on demand from the admin webhook:

// src/app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const { tag, secret } = await req.json();
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'forbidden' }, { status: 403 });
  }
  revalidateTag(tag);
  return NextResponse.json({ ok: true });
}

Configure the AppEngine site/page record to fire that webhook on publish (admin → Site → webhooks).

Preview mode

Editors want to see drafts before publishing. The admin can append ?preview=<token> to the URL; your renderer detects it and asks AppEngine for the draft instead of the published version:

// in get-page-data.ts
const isPreview = (await searchParams).preview;
const path = isPreview
  ? `site/${site.data.name}/page?path=${requestPath}&preview=true`
  : `site/${site.data.name}/page?path=${requestPath}`;

The page-module's preview query param returns the working draft when the JWT or preview token has the right permissions. Pair with noStore() so Next never caches the preview render.

Multi-site, one Next app

The host-to-site lookup means one Next deployment serves any number of sites — useful for white-label SaaS or multi-brand setups. The AppEngine site record carries the org id; the same Next app routes acme-shop.example.com to the Acme site and widgetco.example.com to the WidgetCo site, each pulling from their own org-scoped pages.

You don't need any per-site config in the Next app — domain assignment lives in admin → Sites → Domains, and the catch-all picks it up automatically.