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 dedup —
React.cache()(above). Free, automatic, only catches duplicate calls inside the same render. - Cross-request cache — set
revalidateon 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.