Caching with Next.js and Agility CMS

Next.js's caching model has moved on twice — first with the App Router, then again with Cache Components (the "use cache" directive). This guide covers the current, recommended approach for Next.js 15.x / 16: Cache Components with tag-based invalidation, so your site is fully static-fast and updates the instant an editor hits Publish. The older fetch-level approach (Next.js 13–15) is kept at the end for reference.

You'll build on two SDKs — the @agility/nextjs helpers and the underlying @agility/content-fetch fetch SDK. The Agility Next.js Starter is the reference implementation, and Agility's own marketing site and this documentation site both run on exactly the 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.

The whole strategy in one paragraph

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.


1. Turn on Cache Components

// 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 — you no longer hand-tune export const revalidate / dynamic per route.

2. One preview-aware SDK client

// 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,
  })

3. Wrap every CMS read in "use cache" + a tag

Each 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:

WrapperTag
getContentItemagility-content-{contentID}-{locale}
getContentListagility-content-{referenceName}-{locale}
getPage / getAgilityPageagility-page-{pageID}-{locale}
getSitemapFlatagility-sitemap-flat-{locale}

Why connection() for preview? Under Cache Components, an uncached fetch that runs during the prerender pass aborts the build. connection() (from next/server) marks that branch as request-time/dynamic so the uncached preview fetch is allowed. Keep any connection() call inside a <Suspense> boundary, or Next throws a "blocking-route" error.

4. Compose pages from the cached primitives

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.

5. Invalidate instantly on publish (the webhook)

// 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). On Next 15 use the one-argument revalidateTag(tag).
  • Configure it in Agility under Settings → Webhooks: point a publish (and unpublish) webhook at 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."

6. 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 — 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.


Cache Components gotchas

  • Non-determinism aborts the prerender. 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.
  • Preview / dev must bypass the cache. The connection() branch in each wrapper is what lets editors see unpublished drafts on the preview deploy and at npm run dev.

Legacy approach (Next.js 13–15): fetch-level tags

If you're not on Cache Components yet, the App Router still supports tag-based revalidation through the fetch cache. Tag the SDK's fetch options directly:

agilitySDK.config.fetchConfig = {
  next: {
    tags: [`agility-content-${contentID}-${locale}`],
    revalidate: 3600,
  },
}

…and set route-level revalidation in the page:

// app/[...slug]/page.tsx
export const revalidate = 3600        // seconds before the path re-renders
export const dynamic = "force-static"

The same webhook clears tags, but with the one-argument revalidateTag(tag). This still works — Cache Components is simply the newer, more granular, less hand-tuned model, and the recommended target for new builds.


Summary

  1. cacheComponents: true.
  2. One preview-aware SDK client (getAgilitySDK_NonReact).
  3. Every CMS read = a "use cache" wrapper with a stable cacheTag + cacheLife("days"); preview bypasses via connection().
  4. Compose pages from those cached primitives, not getAgilityPageProps.
  5. An Agility publish webhook calls revalidateTag(tag, "max") for the tags that changed.

That's the whole model: long-lived caches, surgical invalidation, instant editor updates.