Contentful Live Preview with the Next.js App Router
This guide applies Live Editing Integration Patterns to Contentful: the frontend renders drafts through Next.js draft mode and the Contentful Preview API, and the Live Preview SDK updates components as editors type and lets them click any element to jump to its field.
Contentful’s live preview has two independent features. Live updates push changed entry data from the Contentful web app into the embedded preview, so components re-render without a reload. Inspector mode makes elements in the preview clickable, opening the matching entry and field in the editor. Both run inside the preview iframe and communicate with the Contentful web app through postMessage, which is why the preview route, its security headers and the SDK setup have to agree.
The Problem
A marketing team edits landing pages in Contentful and previews them with a “Open preview” button that loads the Next.js site in a new tab. Every change requires saving, switching tabs and reloading, and editors lose their scroll position each time. They also struggle to find which entry a given block comes from, because the landing page references a dozen nested entries. The team wants the preview inside the Contentful editor, updating as they type, with every block clickable.
Two things usually go wrong on the first attempt. The preview renders published content, because the fetch layer ignores draft mode or uses the delivery token. And the iframe stays blank inside Contentful, because the site sends X-Frame-Options: DENY or a frame-ancestors policy that does not include the Contentful web app.
How the Pieces Fit
The integration has four parts, each with one job:
- A draft-mode route that Contentful calls as the preview URL. It checks a secret, enables Next.js draft mode and redirects to the page.
- A fetch helper that reads draft mode and switches to the Preview API host and token, with caching disabled.
- A client provider that initializes the Live Preview SDK only in draft mode, with the locale and the features you want.
- Client components that wrap entry data with
useContentfulLiveUpdatesfor live data and spread inspector props on elements for click-to-edit.
Server components render the initial draft; only the components that need live updates become client components, and they receive the server-fetched entry as their initial data.
Implementation
// app/api/draft/route.ts: configured as the preview URL in Contentful
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { timingSafeEqual } from "node:crypto";
export async function GET(req: Request): Promise<Response> {
const url = new URL(req.url);
const secret = url.searchParams.get("secret") ?? "";
const slug = url.searchParams.get("slug") ?? "/";
const expected = process.env.CONTENTFUL_PREVIEW_SECRET ?? "";
const ok = secret.length === expected.length && timingSafeEqual(Buffer.from(secret), Buffer.from(expected));
if (!ok || !slug.startsWith("/")) return new Response("Invalid preview request", { status: 401 });
(await draftMode()).enable();
redirect(slug);
}
// lib/contentful.ts: one helper decides host, token and caching
import { draftMode } from "next/headers";
export async function cmsGraphQL<T>(query: string, variables: Record<string, unknown>, tags: string[]): Promise<T> {
const preview = (await draftMode()).isEnabled;
const res = await fetch(`https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}/environments/master`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${preview ? process.env.CONTENTFUL_PREVIEW_TOKEN : process.env.CONTENTFUL_DELIVERY_TOKEN}`,
},
body: JSON.stringify({ query, variables: { ...variables, preview } }),
...(preview ? { cache: "no-store" as const } : { next: { revalidate: 3600, tags } }),
});
const json = (await res.json()) as { data: T; errors?: unknown[] };
if (json.errors?.length) throw new Error(`Contentful GraphQL errors: ${JSON.stringify(json.errors)}`);
return json.data;
}
// app/providers.tsx: SDK only in draft mode
"use client";
import { ContentfulLivePreviewProvider } from "@contentful/live-preview/react";
import type { ReactNode } from "react";
export function PreviewProviders({ enabled, locale, children }: { enabled: boolean; locale: string; children: ReactNode }) {
if (!enabled) return <>{children}</>;
return (
<ContentfulLivePreviewProvider locale={locale} enableInspectorMode enableLiveUpdates debugMode={false}>
{children}
</ContentfulLivePreviewProvider>
);
}
// components/Hero.tsx: live data + click-to-edit
"use client";
import { useContentfulInspectorMode, useContentfulLiveUpdates } from "@contentful/live-preview/react";
interface HeroEntry {
sys: { id: string };
heading: string;
subheading: string;
}
export function Hero({ initial }: { initial: HeroEntry }) {
const hero = useContentfulLiveUpdates(initial);
const inspector = useContentfulInspectorMode({ entryId: hero.sys.id });
return (
<section className="hero">
<h1 {...inspector({ fieldId: "heading" })}>{hero.heading}</h1>
<p {...inspector({ fieldId: "subheading" })}>{hero.subheading}</p>
</section>
);
}
In the root layout, read draftMode() on the server and pass enabled and the locale to PreviewProviders. On published pages the provider renders its children unchanged, the hooks return the initial data, and the inspector props are empty, so the same components serve both audiences. The GraphQL queries must request sys { id } for every entry that uses the hooks, and __typename, because the SDK uses them to match incoming updates to the right data.
Next.js also needs to allow framing by Contentful and keep preview responses out of caches. Add headers for preview requests in middleware or next.config:
// next.config.mjs (excerpt)
export default {
async headers() {
return [
{
source: "/:path*",
has: [{ type: "cookie", key: "__prerender_bypass" }],
headers: [
{ key: "Content-Security-Policy", value: "frame-ancestors 'self' https://app.contentful.com" },
{ key: "Cache-Control", value: "private, no-store" },
{ key: "X-Robots-Tag", value: "noindex" },
],
},
];
},
};
Configuration Reference
| Setting | Where | Value |
|---|---|---|
| Preview URL | Contentful content preview settings | https://www.example.com/api/draft?secret=…&slug=/{entry.fields.slug} |
CONTENTFUL_PREVIEW_TOKEN |
server env | Content Preview API token |
enableLiveUpdates |
provider | Push field changes without reload |
enableInspectorMode |
provider | Click elements to open fields |
locale |
provider | Must match the locale used for the initial fetch |
frame-ancestors |
CSP on preview responses | https://app.contentful.com |
Gotchas & Edge Cases
- Draft mode cookie and
SameSite. Inside the Contentful iframe, the site is a third-party context. Next.js sets the draft-mode cookie with attributes that work in iframes in current versions, but custom session cookies must useSameSite=None; Secure, or the browser drops them and every request looks published. - References in live updates. Live updates carry the edited entry’s fields; for referenced entries, the SDK resolves what it can from its cache. Wrap each referenced entry’s component with its own hook call so edits to nested entries also update in place.
- Server components cannot live-update. Only client components re-render from SDK messages. Keep server components for layout and static parts, and pass entry data into small client components for the parts editors change.
- Rich text fields. Pass the rich text JSON through the hook like any field, and render it with the same renderer as the live site. Inspector props go on the container element, not on individual paragraphs.
- Mismatched locales. If the provider’s
localediffers from the fetch locale, updates arrive for the wrong locale and appear to do nothing. Derive both from the same route parameter.
Verifying the Result
Open an entry in Contentful with the live preview panel enabled. Typing in the heading field should change the rendered heading within a fraction of a second, without a reload. Hovering elements with inspector mode on should outline them, and clicking should focus the matching field in the editor. Then open the same URL in a private window without the draft cookie: it should show published content, carry public caching headers and include no SDK code in the network panel.
Rollout Checklist
- Record which content types editors preview most, and start with those.
- Add the draft-mode route and set it as the preview URL for each content type, including locale.
- Route every Contentful fetch through one helper that reads draft mode.
- Allow framing by the Contentful web app on preview responses only.
- Add the provider in the root layout, enabled only in draft mode.
- Convert editor-facing blocks to small client components with live updates and inspector props.
- Confirm that published pages ship none of the SDK code.
- Walk editors through inspector mode once; most discover click-to-edit only when shown.
Frequently Asked Questions
Does live preview work with Contentful’s REST API instead of GraphQL?
Yes. The hooks accept REST-shaped entries as well, as long as sys.id and the content type information are present. Resolve linked entries into nested objects before passing them in, so the hook can match updates to references.
Do I need the preview secret if Contentful already requires login?
Yes. The preview URL is a normal URL on your site and anyone who obtains it could enable draft mode. The secret, checked with a constant-time comparison, is what restricts draft mode to requests that came from Contentful’s preview configuration.
Can live preview show content from several Contentful spaces?
Each provider is configured for one space’s messages, but the page can fetch from several spaces on the server. Live updates then apply to entries from the space the editor is working in, which is usually what editors expect.
How do I exit draft mode after reviewing?
Add a small route that calls (await draftMode()).disable() and redirects back to the page, and link it from the preview banner. Inside the Contentful panel this rarely matters, but editors who open preview links in a normal tab otherwise stay in draft mode on the public domain until the cookie expires.
What happens if the SDK fails to load?
The page still renders the server-fetched draft, so preview keeps working without live updates. Editors see changes after a reload, which is a graceful degradation rather than a broken preview.