Basic Starter
Deep dive into the Starter on Routing, Pages, Image optimization and more.
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.
The starter is built on the principle that content drives everything:
Agility CMS (content & structure)
↓
Sitemap + Pages
↓
React Components
↓
Prerendered HTML
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>
}
| 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 |
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),
}))
}
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 },
}
}
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} />
}
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.
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 |
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.
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 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>
}
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
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.
The starter uses Cache Components (cacheComponents: true). In short:
"use cache" and tagged with a stable key — agility-content-{contentID}-{locale}, agility-content-{referenceName}-{locale}, agility-page-{pageID}-{locale}, agility-sitemap-flat-{locale}.revalidateTag(tag, "max") for the tags that changed, so only the affected pages rebuild — editors see updates almost immediately.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 lets editors see unpublished drafts. It's built on Next's draftMode():
agilitypreviewkey. A route (or middleware) validates the key and calls draftMode().enable(), then redirects to the real page.getAgilityContext() reports isPreview: true, so the CMS wrappers take their uncached connection() branch and request the preview API key — returning the latest draft content.PreviewBar client 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.
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>.
// 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).)
The starter is a modern App Router build:
draftMode() preview that bypasses the cache.