Docs
Developer guides

Next.js

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

View as Markdown

The Next.js quickstart 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 wantDo this
Fresh within a minute, nothing else to set uprevalidate: 60 on the client
Fresh the moment an editor publishesTag reads by model, and revalidate the tag from a webhook
Every request reads the APIRead at request time with connection()

Fresh within a minute

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

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

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

// 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.

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:

// 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.

# .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:

// 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

// 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);
}
// 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.

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.