Docs

Preview in Next.js

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

View as Markdown

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:

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:

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

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

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:

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

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:

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

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

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.

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

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

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

    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.