Previewing Strapi Drafts in Next.js
Within Strapi Self-Hosted Setup, this guide connects Strapi 5’s preview feature to a Next.js App Router frontend. Editors click the preview button in the content manager and see the unpublished version of an entry rendered by the real frontend, with the real layout, components and styles, while readers keep seeing the published version. It covers the preview handler in Strapi’s admin configuration, the draft mode route in Next.js, fetching drafts with the status parameter, caching, and the security rules that keep drafts private.
Strapi 5 stores each document in a draft and a published version. The REST API returns the published version by default, and status=draft returns the draft instead. Preview therefore needs three things: a way for the admin panel to open the right frontend URL, a way for the frontend to know it is in preview, and a server-side fetch path that asks for drafts with a token and never caches the result for other visitors.
The Problem
An events company previewed drafts through a separate “staging” deployment that read from the same Strapi instance with published content only, so editors had to publish to see anything. They published half-finished event pages late at night to check layouts, then unpublished them, and a newsletter job that ran at midnight occasionally picked up an event that was meant to be invisible. The fix was a proper draft preview: editors see their draft on the production frontend, only they can see it, and nothing needs to be published to be checked.
How Preview Works
A preview handler in Strapi. Strapi 5’s admin configuration accepts a preview handler: a function that receives the content type’s uid, the document id, the locale and the status, and returns the frontend URL to open, or null for types without a page. The admin panel shows the preview button for types with a URL.
A draft mode route in Next.js. The URL points at a route handler that validates a shared secret, enables Next.js draft mode, which sets a cookie for this browser only, and redirects to the page.
Draft-aware fetching. Every fetch checks draft mode. In draft mode it adds status=draft, uses the preview token and disables caching; otherwise it fetches the published version with the normal read token and cache tags.
Exit. A second route disables draft mode, and the site shows a banner with an exit link whenever draft mode is on.
Implementation
In Strapi, configure the preview handler in the admin configuration. It maps content types to frontend paths and passes the secret.
// config/admin.ts (excerpt, Strapi 5)
export default ({ env }) => ({
// ...auth, apiToken and transfer settings
preview: {
enabled: true,
config: {
allowedOrigins: [env("CLIENT_URL")],
async handler(uid: string, { documentId, locale, status }) {
const doc = await strapi.documents(uid as any).findOne({ documentId, locale, status, fields: ["slug"] });
const pathByType: Record<string, (d: any) => string> = {
"api::article.article": (d) => `/blog/${d.slug}`,
"api::page.page": (d) => `/${d.slug}`,
};
const toPath = pathByType[uid];
if (!doc || !toPath) return null; // no preview button for types without pages
const params = new URLSearchParams({
url: toPath(doc),
secret: env("PREVIEW_SECRET"),
status,
...(locale ? { locale } : {}),
});
return `${env("CLIENT_URL")}/api/draft?${params}`;
},
},
},
});
In Next.js, the draft route validates the secret and the target path, then enables draft mode.
// app/api/draft/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { timingSafeEqual } from "node:crypto";
function safeEqual(a: string, b: string) {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}
export async function GET(req: Request) {
const p = new URL(req.url).searchParams;
const url = p.get("url") ?? "/";
if (!safeEqual(p.get("secret") ?? "", process.env.PREVIEW_SECRET!)) return new Response("invalid secret", { status: 401 });
if (!url.startsWith("/") || url.startsWith("//")) return new Response("invalid url", { status: 400 }); // no open redirects
const dm = await draftMode();
if (p.get("status") === "published") dm.disable(); else dm.enable();
redirect(url);
}
A single fetch helper applies the right settings everywhere, including layouts and metadata:
// lib/strapi.ts
import { draftMode } from "next/headers";
import qs from "qs";
export async function strapiFetch<T>(path: string, query: Record<string, unknown>, tags: string[] = []): Promise<T> {
const isDraft = (await draftMode()).isEnabled;
const q = qs.stringify({ ...query, status: isDraft ? "draft" : "published" }, { encodeValuesOnly: true });
const res = await fetch(`${process.env.STRAPI_URL}/api/${path}?${q}`, {
headers: { Authorization: `Bearer ${isDraft ? process.env.STRAPI_PREVIEW_TOKEN : process.env.STRAPI_READ_TOKEN}` },
...(isDraft ? { cache: "no-store" as const } : { next: { tags } }),
});
if (!res.ok) throw new Error(`Strapi ${res.status} for ${path}`);
return res.json() as Promise<T>;
}
Pages call the helper as usual. In draft mode, a banner in the root layout shows that the page is a preview and links to /api/draft/disable, a route that calls (await draftMode()).disable() and redirects home.
Previewing inside the admin panel
Strapi 5 can show the preview in a panel next to the edit form, loading the frontend in an iframe. For that to work, the frontend must allow framing by the Strapi admin origin on draft responses, with Content-Security-Policy: frame-ancestors listing that origin, and the draft mode cookie must be sent inside a cross-site iframe, which requires SameSite=None; Secure. Next.js sets its draft mode cookie accordingly when served over HTTPS; check it in the browser if the side panel shows the published page instead of the draft. Keep frame-ancestors restrictive on normal responses, so only draft mode pages can be framed by the admin.
Draft relations and components
Relations in a draft point to documents, and status=draft returns the draft versions of related documents as well, where they exist. That is usually what editors want: a new article linking to a new, unpublished author previews correctly. It also means that a page can look complete in preview while its published version would lack the unpublished relation. Show a small warning in the preview banner when a related document has no published version, so editors publish related entries together.
Configuration Reference
| Item | Recommendation | Why |
|---|---|---|
| Preview handler | returns URL per type, null otherwise |
Button only where a page exists. |
| Secret | long random, compared in constant time | Only Strapi can enable draft mode. |
| Redirect target | relative paths only | No open redirect. |
| Draft fetch | status=draft, preview token, no-store |
Fresh drafts, never cached for readers. |
| Preview token | separate from the read token, server-side | Draft access revocable on its own. |
| Framing | frame-ancestors admin origin, draft only |
Side panel works, site stays unframeable. |
| Robots | noindex on draft responses |
Previews never indexed. |
Gotchas & Edge Cases
- Forgotten fetch paths. A layout, navigation or
generateMetadatathat fetches without the helper shows published data inside a draft page. Route every Strapi request through the helper. - Static pages. Pages generated at build time render dynamically in draft mode in Next.js; make sure the dynamic path fetches correctly and does not rely on build-time props.
- Locales. Pass the locale from the preview handler to the route and on to the fetch, or editors previewing a German draft see the English one.
- New documents without slugs. A draft without a slug has no path; return
nullfrom the handler until the slug is set, or preview by document id on a dedicated route. - Shared caches. A CDN in front of Next.js must not cache responses that carry the draft mode cookie; bypass the cache when the cookie is present.
Worked Example
The events company added the preview handler for events, venues and landing pages, the draft route and the fetch helper, and removed the separate staging deployment for editors. Editors now preview drafts in the admin panel’s side panel and in a full browser tab. Late-night publish-and-unpublish checks stopped entirely, so the newsletter job no longer picked up unfinished events. The team measured the time from opening a draft to seeing it rendered: under three seconds, against several minutes for the old publish, wait for rebuild and check routine.
Sharing Previews with Reviewers
Editors often need a reviewer without Strapi access, such as a legal team or a client, to see a draft. Draft mode cookies are tied to one browser, so the preview URL itself cannot be forwarded safely: it contains the secret. Instead, add a share action that creates a signed, expiring link for one document and locale, for example a short-lived token stored server-side with the document id and an expiry of a few days. The frontend’s share route validates the token, enables draft mode for that one path, and refuses other paths. Revoke links when the document is published, and log every use, since shared previews are the one place where unpublished content leaves the editorial team. This keeps the preview secret private while still letting reviewers see exactly what will go live.
Rollout Checklist
- Configure the preview handler in
config/admin.tsfor every type with a page. - Validate the secret and restrict redirects to relative paths in the draft route.
- Route every Strapi fetch through a helper that respects draft mode.
- Use a separate preview token and
no-storein draft mode. - Allow framing by the admin origin on draft responses only.
- Show a preview banner with an exit link, and send
noindexin draft mode.
Frequently Asked Questions
Does preview work with Strapi 4?
Strapi 4 has no built-in preview handler. Add a custom preview button with a plugin or a custom field, and fetch drafts with publicationState=preview; the Next.js side stays the same.
Can readers ever see drafts?
Only with the draft mode cookie, which the route sets after validating the secret. Without it, every fetch asks for published content with the normal token.
Why a separate preview token?
So draft access can be revoked or rotated without touching the token that serves every reader, and so a leaked read token never exposes unpublished work.
Does preview trigger rebuilds or webhooks?
No. Saving a draft does not publish anything, and preview fetches drafts on demand, so builds and revalidation webhooks only run on publish.