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
Next.js 16 gives you two ways to cache Agility content and invalidate it on publish, and both are current:
revalidate time, and routes are statically generated. This is what the Agility Next.js Starter does today, and it's covered at the end of this guide."use cache" directive). Each read is a cached function with its own tag, and routes prerender a static shell. It's more granular and less hand-tuned, and it's the model to choose for a new build that doesn't start from the starter.Either way, your site serves static pages and updates the moment an editor hits Publish.
You'll build on two SDKs: the @agility/nextjs helpers and the underlying @agility/content-fetch fetch SDK. The Advanced Next.js Demo Site and this documentation site both run on the Cache Components model described below.
Terminology. In Agility a Layout (a.k.a. Page) is the object that represents a page on your site; you'll see "Layout" and "Page" used interchangeably. Components (a.k.a. Modules) are the content blocks placed into a page's zones.
Fetch every piece of CMS data through a thin cached wrapper that stamps the result with a stable tag (agility-content-{id}-{locale}, agility-page-{pageID}-{locale}, …) and caches it for a long time (cacheLife("days")). When an editor publishes, Agility fires a webhook that calls revalidateTag() for exactly the tags that changed, so the affected pages rebuild from fresh data on the next request, and nothing else is touched. Preview / draft reads skip the cache entirely so editors always see their latest work.
// next.config.ts
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
// Enables the `"use cache"` directive + partial prerendering.
cacheComponents: true,
}
export default nextConfig
With this on, a route prerenders its static shell at build time and streams the dynamic/uncached parts at request time, and you no longer hand-tune export const revalidate / dynamic per route.
// lib/cms/getAgilitySDK.ts
import agility from "@agility/content-fetch"
// Use this variant anywhere you can't read draftMode(): inside `"use cache"`,
// in generateStaticParams, and in webhooks.
export const getAgilitySDK_NonReact = (isPreview: boolean) =>
agility.getApi({
guid: process.env.AGILITY_GUID!,
apiKey: isPreview
? process.env.AGILITY_API_PREVIEW_KEY!
: process.env.AGILITY_API_FETCH_KEY!,
isPreview,
})
"use cache" + a tagEach read has two paths: published content is cached and tagged; preview content bypasses the cache.
// lib/cms/getContentItem.ts
import { cacheTag, cacheLife } from "next/cache"
import { connection } from "next/server"
import { getAgilitySDK_NonReact } from "./getAgilitySDK"
export const getContentItem = async <T>(params: {
contentID: number
languageCode: string
preview?: boolean
contentLinkDepth?: number
}) => {
if (params.preview) {
// preview/dev is NEVER cached: opt into a request so the prerender
// pass doesn't try to (and abort on) this uncached fetch
await connection()
return fetchContentItem<T>(params)
}
return cachedContentItem<T>(params)
}
const cachedContentItem = async <T>(params: any) => {
"use cache"
cacheTag(`agility-content-${params.contentID}-${params.languageCode}`)
cacheLife("days")
return fetchContentItem<T>({ ...params, preview: false })
}
const fetchContentItem = async <T>(params: any) => {
const sdk = getAgilitySDK_NonReact(params.preview === true)
return sdk.getContentItem({
contentID: params.contentID,
languageCode: params.languageCode,
contentLinkDepth: params.contentLinkDepth ?? 1,
})
}
Write the same wrapper for the other read types, each with its own tag:
| Wrapper | Tag |
|---|---|
getContentItem | agility-content-{contentID}-{locale} |
getContentList | agility-content-{referenceName}-{locale} |
getPage / getAgilityPage | agility-page-{pageID}-{locale} |
getSitemapFlat | agility-sitemap-flat-{locale} |
Why
connection()for preview? Under Cache Components, an uncached fetch that runs during the prerender pass aborts the build.connection()(fromnext/server) marks that branch as request-time/dynamic so the uncached preview fetch is allowed. Keep anyconnection()call inside a<Suspense>boundary, or Next throws a "blocking-route" error.
Resolve a page from the cached sitemap → node → page-by-ID rather than getAgilityPageProps (whose fetch options predate Cache Components):
// app/[locale]/[...slug]/page.tsx (sketch)
const sitemap = await getSitemapFlat({ locale, preview }) // cached + tagged
const node = sitemap["/" + slug.join("/")]
if (!node) notFound()
const page = await getPage({ pageID: node.pageID, locale, preview }) // cached + tagged
Each primitive carries its own tag, so a page's static output is tied to the exact content + sitemap tags it consumed. Publish any of them and only this page rebuilds.
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from "next/cache"
import { NextRequest, NextResponse } from "next/server"
export async function POST(request: NextRequest) {
const p = await request.json()
const locale = p.languageCode
const revalidated: string[] = []
// content item / list
if (p.contentID) {
revalidateTag(`agility-content-${p.contentID}-${locale}`, "max")
revalidated.push(String(p.contentID))
}
if (p.referenceName) {
revalidateTag(`agility-content-${p.referenceName.toLowerCase()}-${locale}`, "max")
}
// page / layout
if (p.pageID) {
revalidateTag(`agility-page-${p.pageID}-${locale}`, "max")
revalidateTag(`agility-sitemap-flat-${locale}`, "max")
// resolve the path from a FRESH sitemap, then revalidatePath(node.path)
}
return NextResponse.json({ revalidated, at: new Date().toISOString() })
}
Two things to note:
revalidateTag(tag, "max"): the Next.js 16 two-argument form (stale-while-revalidate profile). The one-argument revalidateTag(tag) is deprecated.POST https://your-site.com/api/revalidate.Keep the tag strings in your lib/cms/* wrappers and in this route in lockstep. They are a contract. A typo means "published but never refreshes."
contentLinkDepth: how granular do you want invalidation?When you fetch a Layout with contentLinkDepth: 0, the page props contain only the contentID of each component, and you then fetch each component through its own cached wrapper. That means a component's content is cached (and invalidated) independently of the page it sits on: edit one component, and only that component's tag clears. Higher depths (the default 1) are simpler but couple a component's freshness to the page fetch. For most sites, depth 0 + per-component caching gives the best editor experience.
Date.now(), Math.random(), or new Date() in a component outside a "use cache" scope fails the build with next-prerender-random. Isolate the non-deterministic read inside a "use cache" function, or push it behind connection().connection() must live inside <Suspense>. Otherwise you get a "blocking-route" error. Wrap request-time chrome (preview scripts, draft-mode header data) in its own boundary.connection() branch in each wrapper is what lets editors see unpublished drafts on the preview deploy and at npm run dev.Without Cache Components, the App Router caches tagged fetch calls. The Agility Next.js Starter sets the tag and a 60-second revalidation on the SDK's fetch options in each lib/cms/ helper:
// lib/cms/getContentItem.ts (starter)
agilitySDK.config.fetchConfig = {
next: {
tags: [`agility-content-${params.contentID}-${params.languageCode || params.locale}`],
revalidate: 60,
},
}
and statically generates its catch-all route, re-rendering a path at most every 60 seconds:
// app/[...slug]/page.tsx (starter)
export const revalidate = 60
export const dynamic = "force-static"
The starter's app/api/revalidate/route.ts webhook clears the same tag names with the two-argument revalidateTag(tag, "max"), so a publish shows up on the next request instead of waiting for the 60 seconds to pass. Don't combine these route segment configs with cacheComponents: true: Next.js rejects them when Cache Components is on.
For Cache Components:
cacheComponents: true.getAgilitySDK_NonReact)."use cache" wrapper with a stable cacheTag + cacheLife("days"); preview bypasses via connection().getAgilityPageProps.revalidateTag(tag, "max") for the tags that changed.That's the whole model: long-lived caches, surgical invalidation, instant editor updates.