# Next.js

Source: https://capacms.com/docs/guides/nextjs

Keep a Next.js site fresh after every publish, tag reads by model, and show drafts to editors.

The [Next.js quickstart](https://capacms.com/docs/get-started/nextjs) gets a site reading Capa. This guide covers what a production site needs next: how fresh each page is, refreshing on publish, and a draft view for editors.

The examples use Next.js 16 and the App Router, with `@capacms/sdk`.

## Two caches

Every published read passes through two caches:

1. **Capa's CDN.** Published reads are cached at the edge, and Capa purges them when an entry changes. You do not configure it.
2. **Next's data cache.** Next keeps what a page fetched, for as long as you tell it to.

The CDN is always current within seconds of a publish. How fresh your site is depends on the second cache, so choose how Next keeps each read.

| You want                                      | Do this                                                   |
| --------------------------------------------- | --------------------------------------------------------- |
| Fresh within a minute, nothing else to set up | `revalidate: 60` on the client                            |
| Fresh the moment an editor publishes          | Tag reads by model, and revalidate the tag from a webhook |
| Every request reads the API                   | Read at request time with `connection()`                  |

## Fresh within a minute

Give the client a fetch that Next keeps for a fixed time:

```ts
// lib/capa.ts
import { createClient } from "@capacms/sdk/next";
import { withCache } from "@capacms/sdk/nextjs";

export const capa = createClient({
  baseUrl: process.env.CAPA_API_URL!,
  apiKey: process.env.CAPA_KEY!,
  version: "2026-10-01",
  fetch: withCache(fetch, { revalidate: 60 }),
});
```

`withCache` adds Next's `next: { revalidate }` option to every request the client sends. A page built from these reads is regenerated at most once a minute.

## Fresh on publish

Tag each read with the models it shows. When an entry of a model changes, revalidate that tag.

### Tag reads by model

`tagsFor({ namespace })` builds one Next tag per model, plus a tag for media. Build a client for the models a page reads:

```ts
// lib/capa.ts
import { createClient } from "@capacms/sdk/next";
import { tagsFor, withCache } from "@capacms/sdk/nextjs";

/** A client whose reads Next keeps until one of these models changes. */
export function capaFor(...namespaces: string[]) {
  return createClient({
    baseUrl: process.env.CAPA_API_URL!,
    apiKey: process.env.CAPA_KEY!,
    version: "2026-10-01",
    fetch: withCache(fetch, {
      tags: tagsFor({ namespace: namespaces }),
      revalidate: 3600, // a safety net if a webhook is ever missed
    }),
  });
}
```

```tsx
// app/page.tsx
import { capaFor } from "@/lib/capa";

export default async function Home() {
  // The list expands each article's author, so it reads both models.
  const capa = capaFor("article", "author");
  const articles = await capa.entries.list("article", {
    select: ["title", "slug", { author: ["name"] }],
    sort: ["-publishedAt"],
  });

  return (
    <ul>
      {articles.data.map((article) => (
        <li key={article.id}>{String(article.fields.title)}</li>
      ))}
    </ul>
  );
}
```

Name every model a read touches, including the ones it expands. A page that shows an author's name should refresh when the author changes too.

### Revalidate from a webhook

Add a route that Capa calls on every publish, unpublish and delete:

```ts
// app/api/capa/route.ts
import { revalidateTag } from "next/cache";
import { verifyWebhookSignature } from "@capacms/sdk";
import { revalidateFromWebhook } from "@capacms/sdk/nextjs";

export async function POST(request: Request) {
  const raw = await request.text();
  const ok = await verifyWebhookSignature({
    payload: raw,
    header: request.headers.get("capa-signature") ?? "",
    secret: process.env.CAPA_WEBHOOK_SECRET!,
  });
  if (!ok) return new Response("bad signature", { status: 400 });

  await revalidateFromWebhook({
    payload: JSON.parse(raw),
    revalidateTag: (tag) => revalidateTag(tag, { expire: 0 }),
  });
  return new Response("ok");
}
```

`revalidateFromWebhook` reads the model and entry the event names and revalidates every tag that covers them. On Next.js 15, pass `revalidateTag` itself: it takes one argument there.

Then register the route in **Developers > Webhooks**: choose **Add endpoint**, enter `https://<your-site>/api/capa`, and keep the default events. Copy the signing secret into `CAPA_WEBHOOK_SECRET`. See [Webhooks](https://capacms.com/docs/webhooks).

## Every request reads the API

Some pages must never be cached by Next: search results, or a page that reads the request's cookies. Read at request time:

```tsx
// app/search/page.tsx
import { connection } from "next/server";
import { createClient } from "@capacms/sdk/next";

const capa = createClient({
  baseUrl: process.env.CAPA_API_URL!,
  apiKey: process.env.CAPA_KEY!,
  version: "2026-10-01",
});

export default async function Search({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
  await connection(); // render on every request, never at build time
  const { q = "" } = await searchParams;
  const results = await capa.entries.list("article", {
    select: ["title"],
    filter: q ? { title: { contains: q } } : undefined,
    limit: 10,
  });

  return <p>{results.data.length} results</p>;
}
```

A plain client with no `withCache` is not kept by Next, but a page that uses no request data can still be rendered once at build time. `await connection()` stops that. The read still comes from Capa's CDN, so it stays fast.

## Show drafts to editors

Editors want to see a draft on the real site before they publish. Next's draft mode does this: a cookie switches the page to a client that reads drafts.

You need a **draft key**. In **Developers > Keys**, create a key with **Environment** set to **Draft** and **Read only** grants. It starts with `cap_test_`. Never send it to the browser: it reads every unpublished entry.

```bash
# .env.local
CAPA_DRAFT_KEY=cap_test_...
DRAFT_SECRET=a-long-random-string
```

### Pick the client per request

`draftClient` returns the draft client under draft mode and the published one otherwise:

```ts
// lib/capa.ts
import { draftMode } from "next/headers";
import { draftClient, tagsFor, withCache } from "@capacms/sdk/nextjs";

export function capaFor(...namespaces: string[]) {
  return draftClient({
    production: {
      baseUrl: process.env.CAPA_API_URL!,
      apiKey: process.env.CAPA_KEY!,
      version: "2026-10-01",
      fetch: withCache(fetch, { tags: tagsFor({ namespace: namespaces }), revalidate: 3600 }),
    },
    draft: {
      baseUrl: process.env.CAPA_API_URL!,
      apiKey: process.env.CAPA_DRAFT_KEY!,
      version: "2026-10-01",
    },
    isDraft: async () => (await draftMode()).isEnabled,
  });
}
```

The draft client has no `withCache`, so drafts are never kept. Capa's CDN never caches a draft key's reads either. Pages now call `await capaFor("article")`.

### Turn draft mode on and off

```ts
// app/api/draft/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";

export async function GET(request: Request) {
  const url = new URL(request.url);
  if (url.searchParams.get("secret") !== process.env.DRAFT_SECRET) {
    return new Response("Invalid secret", { status: 401 });
  }
  const path = url.searchParams.get("path") ?? "/";
  // Only paths on this site, so the route cannot send anyone elsewhere.
  if (!path.startsWith("/") || path.startsWith("//")) {
    return new Response("Invalid path", { status: 400 });
  }
  (await draftMode()).enable();
  redirect(path);
}
```

```ts
// app/api/draft/off/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";

export async function GET() {
  (await draftMode()).disable();
  redirect("/");
}
```

An editor opens `/api/draft?secret=…&path=/articles/winter-field-guide-2026` to see that page with drafts, and `/api/draft/off` to leave.

Keep `DRAFT_SECRET` among your editors. Anyone who has it sees your drafts.

## Errors

A read that fails throws `CapaError`, with the API's `status`, `code`, `hint` and `requestId`. `entries.get` returns `null` for a missing entry instead, so `notFound()` covers it.

```ts
import { CapaError } from "@capacms/sdk/next";

try {
  await capa.entries.list("article", { limit: 500 });
} catch (error) {
  if (error instanceof CapaError) console.error(error.code, error.hint, error.requestId);
  throw error;
}
```

Every code has a page under [Errors](https://capacms.com/docs/errors).

## Related

* [Caching](https://capacms.com/docs/concepts/caching) for what a publish purges at the CDN.
* [Drafts and publishing](https://capacms.com/docs/concepts/drafts-and-publishing) for what each key sees.
* [SDK reference](https://capacms.com/docs/sdk) for `graphql()` in a server component, which takes `tags` per call.
