What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
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 on Next.js 16, and it caches Agility data with fetch-level cache tags on statically generated routes, one of the two models described in Caching with Next.js and Agility. Rendering basics are in Rendering & Data Fetching with Next.js.
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. generateStaticParams builds its own SDK client rather than calling the lib/cms/ helpers, tags the sitemap fetch, and skips redirects, folders and the first node (the homepage, which app/page.tsx serves by re-exporting this route):
// app/[...slug]/page.tsx (abridged)
import agilitySDK from "@agility/content-fetch"
import { SitemapNode } from "lib/types/SitemapNode"
export async function generateStaticParams() {
const isDevelopmentMode = process.env.NODE_ENV === "development"
const isPreview = isDevelopmentMode
const apiKey = isPreview ? process.env.AGILITY_API_PREVIEW_KEY : process.env.AGILITY_API_FETCH_KEY
const agilityClient = agilitySDK.getApi({ guid: process.env.AGILITY_GUID, apiKey, isPreview })
const languageCode = process.env.AGILITY_LOCALES || "en-us"
agilityClient.config.fetchConfig = {
next: { tags: [`agility-sitemap-flat-${languageCode}`], revalidate: 60 },
}
const sitemap: { [path: string]: SitemapNode } = await agilityClient.getSitemapFlat({
channelName: process.env.AGILITY_SITEMAP || "website",
languageCode,
})
return Object.values(sitemap)
.filter((node, index) => node.redirect === null && node.isFolder !== true && index !== 0)
.map((node) => ({ slug: node.path.split("/").slice(1) }))
}
Each page produces SEO metadata via generateMetadata. It reads the locale, sitemap channel and preview flags from getAgilityContext(), loads the page, and hands both to resolveAgilityMetaData in lib/cms-content/, which builds the title, description, keywords and Open Graph images from the page's SEO fields:
// app/[...slug]/page.tsx
export async function generateMetadata(props: PageProps, parent: ResolvingMetadata): Promise<Metadata> {
const { params } = props
const { locale, sitemap, isDevelopmentMode, isPreview } = await getAgilityContext()
const agilityData = await getAgilityPage({ params })
if (!agilityData.page) return {}
return await resolveAgilityMetaData({ agilityData, locale, sitemap, isDevelopmentMode, isPreview, parent })
}
getAgilityPage({ params }) (in lib/cms/getAgilityPage.ts) awaits the route params, takes isPreview and locale from getAgilityContext(), and calls getAgilityPageProps with contentLinkDepth: 0. The page then looks up its template by name:
// app/[...slug]/page.tsx
export default async function Page({ params }: PageProps) {
const agilityData = await getAgilityPage({ params })
if (!agilityData.page) notFound()
const AgilityPageTemplate = getPageTemplate(agilityData.pageTemplateName || "")
return (
<div data-agility-page={agilityData.page?.pageID} data-agility-dynamic-content={agilityData.sitemapNode.contentID}>
{AgilityPageTemplate ? (
<AgilityPageTemplate {...agilityData} />
) : (
<InlineError message={`No template found for page template name: ${agilityData.pageTemplateName}`} />
)}
</div>
)
}
The catch-all route is statically generated (export const dynamic = "force-static" and export const revalidate = 60), and every CMS read is a tagged fetch (see Caching below), so a published page is served from the static cache. When an editor publishes, Agility's webhook calls /api/revalidate, which marks the changed tags stale with revalidateTag(tag, "max"). The next request to an affected page triggers a regeneration with fresh content, without waiting for the 60-second revalidation window.
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.server.tsx (server, abridged): fetches the first page
const PostListing = async ({ module, languageCode }: UnloadedModuleProps) => {
const { sitemap, locale } = await getAgilityContext()
const { posts } = await getPostListing({ sitemap, locale, take: 10, skip: 0 })
// server action the client calls for the next page
const getNextPosts = async ({ skip, take }: GetNextPostsProps) => {
"use server"
const postsRes = await getPostListing({ sitemap, locale, skip, take })
return postsRes.posts
}
return <PostListingClient {...{ posts, sitemap, locale, getNextPosts }} />
}
// PostsListing.client.tsx (client, abridged): infinite scroll
"use client"
const PostListingClient = ({ posts, locale, sitemap, getNextPosts }: Props) => {
const [items, setItems] = useState(posts)
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 wrapper that sets a cache tag and a 60-second revalidation on the SDK's fetch options, then calls @agility/content-fetch:
// lib/cms/getContentList.ts
import getAgilitySDK from "lib/cms/getAgilitySDK"
import { ContentListRequestParams } from "@agility/content-fetch/dist/methods/getContentList"
export const getContentList = async (params: ContentListRequestParams) => {
const agilitySDK = await getAgilitySDK()
agilitySDK.config.fetchConfig = {
next: {
tags: [`agility-content-${params.referenceName}-${params.languageCode || params.locale}`],
revalidate: 60,
},
}
return await agilitySDK.getContentList(params)
}
getAgilitySDK() chooses the preview or the fetch API key (see Preview Mode). getContentItem, getSitemapFlat and getSitemapNested in lib/cms/ follow the same pattern, each with its own tag.
Domain layer — app-specific shaping (compose CMS reads, add computed URLs/excerpts):
// lib/cms-content/getPostListing.ts (abridged)
export const getPostListing = async ({ sitemap, locale, skip, take }: LoadPostsProp) => {
const sitemapNodes = await getSitemapFlat({ channelName: sitemap, languageCode: locale })
const rawPosts: ContentList = await getContentList({
referenceName: "posts",
languageCode: locale,
contentLinkDepth: 2,
take,
skip,
locale,
})
// map each post's contentID to its dynamic page path in the sitemap
const dynamicUrls = resolvePostUrls(sitemapNodes, rawPosts.items)
const posts: IPostMin[] = rawPosts.items.map((post: any) => ({
contentID: post.contentID,
title: post.fields.title,
date: DateTime.fromJSDate(new Date(post.fields.date)).toFormat("LLL. dd, yyyy"),
url: dynamicUrls[post.contentID] || "#",
category: post.fields.category?.fields.title || "Uncategorized",
image: post.fields.image,
}))
return { posts }
}
Component layer — just renders what the domain layer returns.
The starter does not enable Cache Components (there's no cacheComponents in next.config.js) and uses no "use cache". It relies on Next.js fetch caching with tags:
lib/cms/ wrapper sets agilitySDK.config.fetchConfig = { next: { tags: [...], revalidate: 60 } } before it fetches.getAgilityPageProps from @agility/nextjs/node, which tags its sitemap and page fetches the same way, with a revalidation time from AGILITY_FETCH_CACHE_DURATION (60 seconds if unset).app/[...slug]/page.tsx and app/page.tsx export revalidate = 60 and dynamic = "force-static", and generateStaticParams prerenders the paths in the flat sitemap at build time./api/revalidate webhook calls revalidateTag(tag, "max") for the tags that changed (see API Routes), so only the affected pages regenerate.| Tag | Set by |
|---|---|
agility-content-{contentID}-{locale} | lib/cms/getContentItem.ts |
agility-content-{referenceName}-{locale} | lib/cms/getContentList.ts |
agility-page-{pageID}-{locale} | getAgilityPageProps (@agility/nextjs/node) |
agility-sitemap-flat-{locale} | lib/cms/getSitemapFlat.ts, getAgilityPageProps, generateStaticParams |
agility-sitemap-nested-{locale} | lib/cms/getSitemapNested.ts |
The wrappers and the webhook must use exactly the same tag strings, or a publish never refreshes the page. For the alternative Cache Components model (cacheComponents: true with "use cache", cacheTag and cacheLife), see Caching with Next.js and Agility, which also explains why it can't be combined with the starter's revalidate and dynamic route exports.
Preview lets editors see unpublished content. It's built on Next's draftMode():
agilitypreviewkey query parameter. proxy.ts sees the key and rewrites the request to /api/preview.app/api/preview/route.ts checks the key with validatePreview() from @agility/nextjs/node, resolves the page URL (with getDynamicPageURL() when a ContentID is passed), calls (await draftMode()).enable(), and redirects to the page with ?preview=1.lib/cms/getAgilityContext.ts reports isPreview: true when draft mode is enabled or NODE_ENV is development. lib/cms/getAgilitySDK.ts makes the same check and, in preview, calls the API with AGILITY_API_PREVIEW_KEY and isPreview: true instead of AGILITY_API_FETCH_KEY, so reads return staging content. getAgilityPage passes the same flag to getAgilityPageProps.proxy.ts redirects requests that carry AgilityPreview=0 to /api/preview/exit, which calls (await draftMode()).disable() and redirects back to the page. The PreviewBar client component, rendered in app/layout.tsx, shows the current mode and sends you to the same exit route.Because npm run dev always counts as preview, you see staging content locally without entering draft mode.
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>.
Abridged from the starter's app/api/revalidate/route.ts:
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache"
import { NextRequest, NextResponse } from "next/server"
export async function POST(req: NextRequest) {
const data = await req.json()
//only process publish events
if (data.state === "Published") {
if (data.referenceName) {
//content item change: the list tag and the item tag
revalidateTag(`agility-content-${data.referenceName}-${data.languageCode}`, "max")
revalidateTag(`agility-content-${data.contentID}-${data.languageCode}`, "max")
} else if (data.pageID !== undefined && data.pageID > 0) {
//page change: the page tag and both sitemaps
revalidateTag(`agility-page-${data.pageID}-${data.languageCode}`, "max")
revalidateTag(`agility-sitemap-flat-${data.languageCode}`, "max")
revalidateTag(`agility-sitemap-nested-${data.languageCode}`, "max")
}
}
return NextResponse.json({ message: "OK" }, { status: 200 })
}
Configure it in Agility under Settings → Webhooks pointing at POST /api/revalidate. revalidateTag(tag, "max") is the Next.js 16 two-argument form; the one-argument revalidateTag(tag) is deprecated.
The starter is a modern App Router build:
revalidateTag(tag, "max").draftMode() preview (plus dev mode) that switches reads to the preview API key.