See Agility CMS in action. Watch a product demo
How the Next.js Starter Works
This is a deep dive into how the Agility Next.js Starter is put together — routing, rendering, components, data fetching, caching, and preview. It's built on the App Router with React Server Components and Cache Components, the same model described in Rendering & Data Fetching with Next.js and Caching with Next.js and Agility.
Core Concepts
Content-Driven Architecture
The starter is built on the principle that content drives everything:
Agility CMS (content & structure)
↓
Sitemap + Pages
↓
React Components
↓
Prerendered HTML
- Editors control page structure in Agility CMS.
- Developers define component behavior in React.
- Next.js prerenders optimized pages.
- Users get fast page loads.
Server-First Rendering
By default, everything is a React Server Component:
// Default: Server Component (async) — fetch directly, ship no JS
export default async function MyComponent({ module }) {
const data = await fetchData()
return <div>{data.title}</div>
}
// Only when you need interactivity: a Client Component
"use client"
export default function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
Separation of Concerns
| Layer | Location | Responsibility |
|---|---|---|
| Presentation | components/ | UI rendering |
| Domain logic | lib/cms-content/ | App-specific content shaping |
| CMS utilities | lib/cms/ | Generic, cached CMS reads |
| Types | lib/types/ | TypeScript interfaces |
| Routing | app/ | Next.js routing |
The Page Lifecycle
1. Static params
At build time, Next.js asks which pages exist. The starter answers from the Agility sitemap:
// app/[...slug]/page.tsx
export async function generateStaticParams() {
const sitemap = await getSitemapFlat({ locale: "en-us", preview: false })
return Object.values(sitemap).map((node) => ({
slug: node.path.split("/").filter(Boolean),
}))
}
2. Metadata
Each page produces SEO metadata via generateMetadata:
export async function generateMetadata({ params }) {
const { slug } = await params
const { page } = await getAgilityPage({ slug: slug ?? [], locale: "en-us", preview: false })
return {
title: page.title,
description: page.seo?.metaDescription,
openGraph: { title: page.title, description: page.seo?.metaDescription },
}
}
3. Rendering
export default async function Page({ params }) {
const { slug } = await params
const { page } = await getAgilityPage({ slug: slug ?? [], locale: "en-us", preview: false })
const Template = getPageTemplate(page.templateName)
return <Template page={page} />
}
4. In production
Because every CMS read is cached and tagged (see Caching below), a published page is served from the prerendered cache — instantly. When an editor publishes, Agility's webhook invalidates exactly the tags that changed, and the affected pages rebuild on the next request. There's no fixed revalidate timer to wait out.
Dynamic Routing
Instead of a file per page, the starter uses a single catch-all route:
app/
├─ layout.tsx # root layout
└─ [...slug]/
├─ page.tsx # handles /, /about, /blog, /blog/post-1, …
├─ error.tsx # error boundary
└─ not-found.tsx # 404
[...slug] matches any path; getAgilityPage resolves it against the Agility sitemap to the right page, template, and components.
| URL | slug param | CMS page |
|---|---|---|
/ | [] | Homepage |
/about | ['about'] | About |
/blog/my-post | ['blog','my-post'] | Blog post |
Component Architecture
Component Registry
Agility Components are mapped to React components by name:
// components/agility-components/index.ts
import { Module } from "@agility/nextjs"
import Heading from "./Heading"
import RichTextArea from "./RichTextArea"
const allModules: Module[] = [
{ name: "Heading", module: Heading },
{ name: "RichTextArea", module: RichTextArea },
]
export const getModule = (name: string) =>
allModules.find((m) => m.name.toLowerCase() === name.toLowerCase())?.module || null
The
@agility/nextjsAPI still calls these "modules" in code — in the Agility UI they're Components. Same thing.
Component Props
import { UnloadedModuleProps } from "@agility/nextjs"
interface IMyComponent { heading: string; content: string }
export default async function MyComponent({ module, page, languageCode }: UnloadedModuleProps) {
const { fields } = module as { fields: IMyComponent }
return (
<section>
<h2>{fields.heading}</h2>
<div>{fields.content}</div>
</section>
)
}
Server by default, client when interactive
Server Components can fetch directly; add "use client" only where you need state/effects. A common pattern is a Server Component that fetches and hands data to a small Client Component for interactivity:
// PostsListing.tsx (server) — fetches
export default async function PostsListing({ module }) {
const { posts } = await getPostListing({ take: 10 })
return <PostsListingClient initialPosts={posts} />
}
// PostsListing.client.tsx (client) — filtering / infinite scroll
"use client"
export default function PostsListingClient({ initialPosts }) {
const [posts, setPosts] = useState(initialPosts)
return <div>{/* interactive UI */}</div>
}
Page Templates
Templates define layout and render Components into named zones with <ContentZone>:
// components/agility-pages/MainTemplate.tsx
import { ContentZone } from "@agility/nextjs"
import { getModule } from "../agility-components"
export default function MainTemplate({ page }) {
return (
<div className="max-w-7xl mx-auto">
<ContentZone name="MainContent" page={page} getModule={getModule} />
</div>
)
}
<ContentZone> looks up page.zones.MainContent, resolves each Component via getModule, and renders it. Templates can declare multiple zones (e.g. MainContent + Sidebar), and are resolved by name through a template registry:
// components/agility-pages/index.ts
export const getPageTemplate = (name: string) =>
({ MainTemplate, TwoColumnTemplate } as Record<string, any>)[name] || MainTemplate
Data Fetching Strategy
Content flows through three tiers, so business logic stays out of both the SDK and your components:
Component (what to display)
↓ getPostListing()
Domain lib/cms-content/ (how to build "blog posts with URLs")
↓ getContentList()
CMS lib/cms/ (cached, tagged Agility reads)
↓ @agility/content-fetch
Agility CMS API
CMS layer — a thin cached wrapper (this is the Cache Components pattern; see the caching guide):
// lib/cms/getContentItem.ts
import { cacheTag, cacheLife } from "next/cache"
import { connection } from "next/server"
export const getContentItem = async <T>(params) => {
if (params.preview) {
await connection() // preview is never cached
return fetchContentItem<T>(params)
}
return cachedContentItem<T>(params)
}
const cachedContentItem = async <T>(params) => {
"use cache"
cacheTag(`agility-content-${params.contentID}-${params.languageCode}`)
cacheLife("days")
return fetchContentItem<T>({ ...params, preview: false })
}
Domain layer — app-specific shaping (compose CMS reads, add computed URLs/excerpts):
// lib/cms-content/getPostListing.ts
export async function getPostListing({ take = 10, skip = 0 }) {
const { items } = await getContentList<IPost>({ referenceName: "posts", languageCode: "en-us", take, skip })
const sitemap = await getSitemapFlat({ locale: "en-us", preview: false })
const blog = Object.values(sitemap).find((n) => n.name === "Blog")
return items.map((p) => ({ ...p.fields, url: `${blog?.path}/${p.fields.slug}` }))
}
Component layer — just renders what the domain layer returns.
Caching & Performance
The starter uses Cache Components (cacheComponents: true). In short:
- Every CMS read is wrapped in
"use cache"and tagged with a stable key —agility-content-{contentID}-{locale},agility-content-{referenceName}-{locale},agility-page-{pageID}-{locale},agility-sitemap-flat-{locale}. - Pages prerender to a static shell and are served from cache.
- On publish, Agility's webhook calls
revalidateTag(tag, "max")for the tags that changed, so only the affected pages rebuild — editors see updates almost immediately. - Preview/draft reads bypass the cache entirely via
connection().
The complete setup, the tag contract, and the gotchas are in Caching with Next.js and Agility — the canonical reference. Don't hand-tune export const revalidate / dynamic per route; Cache Components handles that.
Preview Mode
Preview lets editors see unpublished drafts. It's built on Next's draftMode():
- Agility opens a preview URL with an
agilitypreviewkey. A route (or middleware) validates the key and callsdraftMode().enable(), then redirects to the real page. - With draft mode on,
getAgilityContext()reportsisPreview: true, so the CMS wrappers take their uncachedconnection()branch and request the preview API key — returning the latest draft content. - A small
PreviewBarclient component shows preview is active and can exit draft mode.
Because preview is uncached and per-request, drafts always reflect the editor's latest save. See Rendering & Data Fetching with Next.js.
Image Optimization
Use AgilityPic for Agility images — it renders a responsive <picture> backed by Agility's image API:
import { AgilityPic } from "@agility/nextjs"
<AgilityPic image={fields.image} fallbackWidth={800} className="rounded-lg" />
See Using the AgilityPic Component for the full API. For non-CMS images, use Next's own <Image>.
API Routes
Revalidate (the publish webhook)
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache"
export async function POST(request: Request) {
const p = await request.json()
const locale = p.languageCode
if (p.contentID) revalidateTag(`agility-content-${p.contentID}-${locale}`, "max")
if (p.referenceName) revalidateTag(`agility-content-${p.referenceName.toLowerCase()}-${locale}`, "max")
if (p.pageID) {
revalidateTag(`agility-page-${p.pageID}-${locale}`, "max")
revalidateTag(`agility-sitemap-flat-${locale}`, "max")
}
return Response.json({ revalidated: true })
}
Configure it in Agility under Settings → Webhooks pointing at POST /api/revalidate. (revalidateTag(tag, "max") is the Next 16 form; on Next 15 use revalidateTag(tag).)
Summary
The starter is a modern App Router build:
- Server-first rendering (RSC) for performance.
- Catch-all routing driven by the Agility sitemap.
- A component + template registry for editor-composed pages.
- Cache Components caching with tag-based, publish-driven invalidation.
draftMode()preview that bypasses the cache.- Type safety throughout.