# Preview in Next.js

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

Turn on edit mode, start the overlay and accept preview links in a Next.js site.

Preview needs `@capacms/sdk` 1.0.0-next.8 or later. The `latest` tag is
older and has no `capaHeaders`, so install the `next` tag:

```sh
pnpm add @capacms/sdk@next
```

Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`, or the legacy key your site
holds) and `CAPA_DRAFT_KEY` (`cap_test_`). Draft reads through the SDK take a
`cap_` key only (see Keys). Then:

```ts
// middleware.ts
import { NextResponse } from "next/server";
import { capaMiddleware } from "@capacms/sdk/nextjs";
export const middleware = capaMiddleware({ NextResponse });

// app/api/capa/preview/route.ts  (and exit/route.ts with exitPreviewRoute)
import { cookies, draftMode } from "next/headers";
import { createPreviewRoute } from "@capacms/sdk/nextjs";
export const GET = createPreviewRoute({ draftMode, cookies });

// next.config.mjs
import { capaHeaders } from "@capacms/sdk/nextjs";
export default { async headers() { return capaHeaders(); } };

// in a page: getCapaClient({ draftMode, headers }), then <h1 {...fieldAttrs(post).title}>
// in the root layout: const edit = await editMode({ draftMode, headers }) from @capacms/sdk/nextjs,
// then {edit ? <CapaOverlay adminOrigins={[...]} /> : null} from @capacms/sdk/nextjs/overlay
```

In Capa, set the project's preview URL to your site, and editors can click
your page. The sections below explain each piece.

The Capa editor can show your site beside the form: focus a field and its spot
on the page is outlined, click the page and the editor jumps to the field, save
and the draft re-renders in place. It needs three things on your side.

## 1. Turn on edit mode and tag what an editor can click

Edit mode is on when Next draft mode is on, or when the request carries a
`capa-edit` token the Capa editor's Published view sends. Work it out once per
request and build the client with it:

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

export async function capa() {
  const draft = (await draftMode()).isEnabled;
  return createClient({
    baseUrl, version: "2026-10-01",
    apiKey: draft ? process.env.CAPA_DRAFT_KEY! : process.env.CAPA_KEY!,
    editMode: await editMode({ draftMode, headers }),
  });
}
```

Then tag fields with `capaAttrs(entry, field)` from `@capacms/sdk/next`. `field`
is the field's namespace, its key in `entry.fields`, and it autocompletes when
the entry is typed. There is no flag to pass: an entry read by an edit-mode
client carries a hidden mark (related entries too), and `capaAttrs` tags only
marked entries. A visitor's page therefore ships no `data-capa-` attributes.

```tsx
import { capaAttrs } from "@capacms/sdk/next";

<h1 {...capaAttrs(article, "title")}>{article.fields.title}</h1>
```

The mark does not survive a spread copy or being passed to a client component,
so tag in the server component that read the entry. `capaAttrs(entry, field,
true)` still forces the tags on and `false` forces them off.

**When the entry crosses to the browser as data, pass the flag.** The mark is
a hidden symbol, so anything that serializes the entry drops it silently: the
Next Pages Router's `getServerSideProps`, SvelteKit and Remix loaders, Nuxt's
payload, or your own JSON endpoint. The page then shows the draft with no
`data-capa-` tags, and the editor has nothing to point at. Nothing errors. Send
the draft or edit state alongside the entry and hand it to `capaAttrs`:

```tsx
// pages/articles/[id].tsx on the Pages Router. A +page.server.ts load or
// useAsyncData on the server hands over the flag the same way.
import type { Entry } from "@capacms/sdk/next";
import type { Articles } from "./capa-types";

export async function getServerSideProps({ draftMode = false }) {
  const entry = (await capa.entries.get<Articles>("articles", "entry-id"))!.data;
  return { props: { entry, edit: draftMode } };
}

// in the component
export default function Page({ entry, edit }: { entry: Entry<Articles>; edit: boolean }) {
  return <h1 {...capaAttrs(entry, "title", edit)}>{entry.fields.title}</h1>;
}
```

Or re-mark the entries where they arrive with `markEditEntries(data)` when the
page is in edit mode. Tested on the Pages Router, SvelteKit and Nuxt.

GraphQL reads are marked the same way, from `getCapaClient`, an edit-mode
`createClient` or `graphql()` given `{ draftMode, headers }`. A node is an
entry when it selected `id` and `model`, and `field` is the field as you
selected it:

```tsx
const { data } = await capa.graphql.query({ articles: { nodes: { id: true, model: true, title: true } } });
<h1 {...capaAttrs(data!.articles!.nodes[0], "title")}>{data!.articles!.nodes[0].title}</h1>
```

A field GraphQL renamed (`hero_image` for `hero-image`) is tagged by its
namespace, which the editor knows it by: in edit mode the client reads the
names of the key's types and fields once a minute to know which fields those
are. That read starts beside the page's read, so the page waits for the
slower of the two, and a document that never says `model` reads no names.
With a typed client, tagging a node that did not select `model` does not
compile.
`toTree` keeps the mark, so its entries tag like REST entries, by namespace.

To accept `capa-edit`, verify it in middleware. The token is checked with Capa,
any forged `x-capa-edit` header is removed, and edit-mode responses are marked
`private, no-store`:

```ts
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { resolveEditRequest } from "@capacms/sdk/nextjs";

export async function middleware(request: NextRequest) {
  const edit = await resolveEditRequest(request, publishedClient());
  const response = NextResponse.next({ request: { headers: edit.headers } });
  if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
  if (edit.robotsTag) response.headers.set("X-Robots-Tag", edit.robotsTag);
  return response;
}
```

## 2. Start the overlay in edit mode

```tsx
// app/layout.tsx
import { CapaOverlay } from "@capacms/sdk/nextjs/overlay";

{edit ? <CapaOverlay adminOrigins={["https://app.capacms.com"]} /> : null}
```

`@capacms/sdk/nextjs/overlay` is a client component and needs `react` and
`next`. Without Next, call `startOverlay({ adminOrigins, onRefresh })` from
`@capacms/sdk/overlay` in your own effect.

Render it from your root layout only in edit mode
(`await editMode({ draftMode, headers })`), so a visitor never downloads it.
`startOverlay` returns a disposer and is safe to call twice. Outside a frame it
does nothing at all, and inside one it only listens to a parent window at one of
`adminOrigins`. Without `onRefresh` a save reloads the page; the scroll position
is kept either way.

## 3. Accept the preview link on any page

The editor loads `<preview base URL><path>?capa-preview=<token>`. Honour the
token on the request itself, not only on a dedicated route: rewrite any request
carrying it to your draft route, verify it with `preview`, enable draft mode and
redirect back.

```ts
// middleware.ts
export function middleware(request: NextRequest) {
  // The editor's Published view: render this one request without draft mode.
  if (request.nextUrl.searchParams.get("capa-view") === "published") {
    const headers = new Headers(request.headers);
    const cookies = request.cookies.getAll().filter((c) => c.name !== "__prerender_bypass");
    headers.set("cookie", cookies.map((c) => `${c.name}=${encodeURIComponent(c.value)}`).join("; "));
    return NextResponse.next({ request: { headers } });
  }
  const token = request.nextUrl.searchParams.get("capa-preview");
  if (!token) return NextResponse.next();
  const target = request.nextUrl.clone();
  target.pathname = "/api/draft";
  target.search = `?token=${encodeURIComponent(token)}&path=${encodeURIComponent(request.nextUrl.pathname)}`;
  return NextResponse.rewrite(target);
}
```

`/api/draft` is the route handler shown under "Open a draft in your own site"
above, reading `token` and `path`.

**Cookies in a frame.** The editor frames your site from another site. A
browser sends a cookie into a cross-site frame only when it is
`SameSite=None; Secure`, and Safari 26.2 and later only when it is
`Partitioned` too. Next sets the draft-mode cookie with neither `Partitioned`
nor `Max-Age`, so it is dropped in Safari's frame and, everywhere else, opens
every draft on the site until the browser closes. Pass Next's `cookies` to
`createPreviewRoute` and `exitPreviewRoute` and they re-set it as
`HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=3600`, and
delete it the same way. `maxAge` changes the hour. In your own route, call
`frameDraftCookie(cookies)` after `enable()` and `clearDraftCookie(cookies)`
after `disable()`. A browser that drops the cookie still gets a fresh token on
every preview load, which step 3 honours on any page.

Leave `redirect` out of both routes. Each then answers with its own 307,
marked `X-Robots-Tag: noindex, nofollow`, `Referrer-Policy: no-referrer` (the
token is in the URL) and `Cache-Control: private, no-store`. Next's
`redirect()` cannot carry headers, so passing it keeps the old redirect.

**Mark drafts, and let Capa frame them.** `capaHeaders()` returns rules for
`next.config`'s `headers()` that send `X-Robots-Tag: noindex, nofollow` and
`Content-Security-Policy: frame-ancestors 'self' https://app.capacms.com` on a
request carrying the draft cookie or a `capa-preview`, `capa-edit` or
`capa-view` query, and on nothing else. A visitor's response, cached or not,
is unchanged. Pass `adminOrigins` to name another admin, such as one running
locally, and pass the same list to `createPreviewRoute`. A browser applies
every CSP it is sent, so if your site sends its own `frame-ancestors` or
`X-Frame-Options`, leave them off draft responses with
`missing: [{ type: "cookie", key: DRAFT_COOKIE }]` on that rule.
`capaMiddleware` marks its edit-mode responses noindex too, and
`resolveEditRequest` returns the value as `robotsTag`.

## Add preview to a live site without changing it

A live Next.js site that reads Capa with its own code keeps that code. Preview
adds a branch that only draft mode switches on, and draft mode is off for
every visitor, at build time and during ISR. Reading
`(await draftMode()).isEnabled` leaves a static page static, so a visitor gets
the same pages, headers and cache as before.

1. **Read drafts in draft mode.** In the helper that fetches from Capa, before
   the existing fetch, read the same URL with a draft key and no cache. The
   data has the same shape, so every page renders unchanged.

   ```ts
   // lib/capa-draft.ts
   import "server-only";
   import { draftMode } from "next/headers";

   export async function isDraft(): Promise<boolean> {
     // draftMode() throws outside a request (generateStaticParams, some build steps).
     try { return (await draftMode()).isEnabled; } catch { return false; }
   }

   // in the fetch helper, before the existing fetch, which stays as it is
   if (await isDraft()) {
     return fetch(`https://cdn.capacms.com/v2/api/${endpoint}${query}`, {
       headers: { "x-api-key": process.env.CAPA_DRAFT_KEY! },
       cache: "no-store",
     }).then(parse); // the helper's existing parse
   }
   ```

   The draft key is any key of yours whose environment is not `production`
   (see Preview is a key, not a flag). Keep it server-only, never in a
   `NEXT_PUBLIC_` variable, and keep the branch in a `server-only` module: a
   helper a client component also imports would bundle it.

2. **Add the preview and exit routes** with `createPreviewRoute({ draftMode,
   cookies })` and `exitPreviewRoute({ draftMode, cookies })`, as in the Quick
   start. Set `CAPA_API_URL` to `https://cdn.capacms.com` and `CAPA_KEY` to
   the production key the site already reads with, server-only: the routes
   check the editor's token with it.

3. **Add the middleware behind a matcher**, so a visitor's request never runs
   it. Next reads `config` from the file itself, so write the matcher out:

   ```ts
   // middleware.ts (proxy.ts on Next 16)
   import { NextResponse } from "next/server";
   import { capaMiddleware } from "@capacms/sdk/nextjs";

   export const middleware = capaMiddleware({ NextResponse });
   export const config = {
     matcher: [
       { source: "/:path*", has: [{ type: "query", key: "capa-preview" }] },
       { source: "/:path*", has: [{ type: "query", key: "capa-edit" }] },
       { source: "/:path*", has: [{ type: "query", key: "capa-view" }] },
       { source: "/:path*", has: [{ type: "cookie", key: "__prerender_bypass" }] },
     ],
   };
   ```

4. **Add `capaHeaders()`** to `next.config`'s `headers()`, as in the Quick
   start: drafts are marked noindex and only the Capa admin can frame them.

5. **Start the overlay and tag fields in draft mode.** In the root layout:

   ```tsx
   const draft = await isDraft();
   {draft ? <CapaOverlay adminOrigins={["https://app.capacms.com"]} /> : null}
   ```

   Tag an editable field with `{...capaAttrs({ id: entry.id }, "title", draft)}`.
   With `draft` false it returns `{}`, so a visitor's HTML gains no
   attribute. The field is its key in the entry, which is its namespace.

6. **Route handlers that set their own `Cache-Control`** must send
   `private, no-store` in draft mode. Otherwise a CDN keeps a draft fetched by
   the editor's browser and serves it to everyone.

Do not call `editMode()`, `getCapaClient()` or `resolveEditRequest()` from a
static or ISR page or layout. Each reads `headers()`, which makes every route
that calls it dynamic: the HTML stays the same, but the site loses ISR and
renders every view. `isDraft()` above is the static-safe check. The editor's
Published view then shows the static page without the overlay.

In Capa, set the project's preview URL to the site's production origin, and
give each model with a page a route pattern, or Preview has no link to open.
