# Caching with Next.js and Agility CMS

> Source: https://agilitycms.com/docs/nextjs/caching-with-next-js-and-agility

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`](https://www.npmjs.com/package/@agility/nextjs) helpers and the underlying [`@agility/content-fetch`](https://www.npmjs.com/package/@agility/content-fetch) fetch SDK. The [Agility Next.js Starter](https://github.com/agility/agilitycms-nextjs-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.

---

## Recommended (Next.js 15.x / 16): Cache Components

### 1. Turn on Cache Components

```ts
// 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

```ts
// 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.

```ts
// 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()` (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):

```ts
// 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)

```ts
// 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:

```ts
agilitySDK.config.fetchConfig = {
  next: {
    tags: [`agility-content-${contentID}-${locale}`],
    revalidate: 3600,
  },
}
```

…and set route-level revalidation in the page:

```ts
// 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.
