The first complaint after launching a headless WordPress site is almost always the same: "Preview doesn't work." In a classic WordPress site, the Preview button renders an unpublished post with the theme. In a headless site the public pages come from Next.js, so the button either shows an old theme or a blank page, and editors lose the ability to check their work before publishing.
The fix has four parts: a secret preview route in Next.js, Draft Mode, authenticated requests for unpublished content, and a filter in WordPress that points the Preview button at the frontend.
How Draft Mode worksLink to this section
Next.js Draft Mode sets a signed cookie in the visitor's browser. While that cookie is present, pages that would normally be served from the cache are rendered on every request instead, so your page code can fetch draft content. Other visitors are unaffected; they still get the cached, published page.
The flow:
Editor clicks Preview in WordPress
→ https://www.example.com/api/preview?secret=…&id=123
Next.js checks the secret, enables Draft Mode, redirects to /blog/preview/123
→ The page sees draftMode().isEnabled and fetches the draft with credentialsStep 1: the preview routeLink to this section
// app/api/preview/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);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const secret = searchParams.get("secret") ?? "";
const id = Number(searchParams.get("id"));
if (!process.env.PREVIEW_SECRET || !safeEqual(secret, process.env.PREVIEW_SECRET) || !Number.isInteger(id)) {
return new Response("Invalid preview link", { status: 401 });
}
(await draftMode()).enable();
redirect(`/blog/preview/${id}`);
}Redirect by ID, not slug. Drafts often have no slug yet, and editors change slugs while writing.
A matching route turns Draft Mode off, so editors can see the published version again:
// app/api/preview/exit/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET() {
(await draftMode()).disable();
redirect("/");
}Step 2: fetch unpublished content securelyLink to this section
The REST API only returns drafts to authenticated users. The simplest secure option is an Application Password for a dedicated WordPress user with the Editor role (Users → Profile → Application Passwords). Store it only in server environment variables:
# .env.local (never NEXT_PUBLIC_)
WP_PREVIEW_USER=preview-bot
WP_PREVIEW_APP_PASSWORD="abcd efgh ijkl mnop qrst uvwx"
PREVIEW_SECRET=a-long-random-stringApplication Passwords use HTTP Basic authentication, which means a Base64-encoded user:password header. You can check the header value with the Base64 encoder while debugging:
// lib/wordpress-preview.ts (server-only)
import "server-only";
const WP = process.env.WORDPRESS_API_URL;
const auth = "Basic " + Buffer.from(`${process.env.WP_PREVIEW_USER}:${process.env.WP_PREVIEW_APP_PASSWORD}`).toString("base64");
export async function getPreviewPost(id: number) {
// The latest autosave holds unsaved changes to a published post;
// for drafts, the post itself is the latest content.
const [postRes, autosavesRes] = await Promise.all([
fetch(`${WP}/wp-json/wp/v2/posts/${id}?context=edit&_embed`, { headers: { Authorization: auth }, cache: "no-store" }),
fetch(`${WP}/wp-json/wp/v2/posts/${id}/autosaves?context=edit`, { headers: { Authorization: auth }, cache: "no-store" }),
]);
if (!postRes.ok) return null;
const post = await postRes.json();
const autosaves = autosavesRes.ok ? await autosavesRes.json() : [];
const latest = [...autosaves].sort((a, b) => Date.parse(b.modified_gmt) - Date.parse(a.modified_gmt))[0];
if (latest && post.status === "publish" && Date.parse(latest.modified_gmt) > Date.parse(post.modified_gmt)) {
return { ...post, title: latest.title, content: latest.content, excerpt: latest.excerpt };
}
return post;
}Two details matter:
cache: "no-store"ensures preview data is never written to the shared fetch cache.- Autosaves. When an editor previews changes to an already published post, WordPress saves them as an autosave rather than updating the post. Reading the newest autosave shows the editor what they actually typed.
Note that application passwords require HTTPS on the WordPress site, which you should have anyway.
Step 3: the preview pageLink to this section
// app/blog/preview/[id]/page.tsx
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import { getPreviewPost } from "@/lib/wordpress-preview";
import { ArticleView } from "@/components/ArticleView";
export const dynamic = "force-dynamic";
export const metadata = { robots: { index: false, follow: false } };
export default async function PreviewPage({ params }: { params: Promise<{ id: string }> }) {
if (!(await draftMode()).isEnabled) notFound();
const post = await getPreviewPost(Number((await params).id));
if (!post) notFound();
return (
<>
<p className="preview-banner">
Preview of an unpublished version. <a href="/api/preview/exit">Exit preview</a>
</p>
<ArticleView post={post} />
</>
);
}Reuse the same component as the public article page so the preview is faithful. Run the draft's HTML through the same sanitizer too; unpublished content is no more trustworthy than published content. A visible banner prevents editors from sharing a preview link thinking it's live.
Step 4: point the Preview button at the frontendLink to this section
In the WordPress theme or a small plugin:
add_filter( 'preview_post_link', function ( $link, $post ) {
if ( ! defined( 'HI_FRONTEND_URL' ) || ! defined( 'HI_PREVIEW_SECRET' ) ) {
return $link;
}
return add_query_arg(
array(
'secret' => rawurlencode( HI_PREVIEW_SECRET ),
'id' => $post->ID,
),
HI_FRONTEND_URL . '/api/preview'
);
}, 10, 2 );Define both constants in wp-config.php. The block editor uses this filter for its Preview menu, so editors don't need to learn anything new.
The secret appears in the preview URL, so anyone the editor shares the link with can open Draft Mode. That's acceptable for most teams because the draft content itself still requires the server-side credentials, but rotate the secret if a link leaks, and never reuse it for revalidation webhooks.
Caching rules for previewsLink to this section
- Preview routes and pages must be dynamic (
force-dynamic) and fetch withno-store. - Draft Mode bypasses the cache for the editor's browser only; nothing changes for visitors.
- Don't call
revalidateTagorrevalidatePathfrom previews. Publishing triggers revalidation through your normal webhook, as described in the headless WordPress guide.
SEO and security checklistLink to this section
- Preview pages send
noindexand are excluded from the sitemap. - The preview user has the least privilege needed (Editor, or a custom role that can read drafts).
- Credentials live only in server environment variables.
- The preview secret is compared in constant time and is different from other secrets.
- Draft HTML is sanitized like published HTML.
Testing previews before editors rely on themLink to this section
Previews involve WordPress, the frontend, cookies and caching, so test the full journey before handing it to editors:
- Draft post. Create a new draft, click Preview, and confirm the frontend shows the draft title and content, with a visible "Preview mode" banner and an exit link.
- Changes to a published post. Edit a published post without updating it, click Preview, and confirm the autosaved changes appear, while the public URL in a private window still shows the published version.
- Expiry and exit. Use the exit link and confirm normal pages return. Wait past the token lifetime and confirm an old preview link no longer works.
- Wrong secret. Change one character of the preview URL's secret and confirm the route returns 401 rather than enabling Draft Mode.
- Non-editors. Log in as a subscriber, or not at all, and confirm the preview route refuses to show unpublished content.
- Custom post types and fields. Repeat the draft test for each post type that editors use, including any custom fields shown on the page.
- Caching. Check the response headers of a preview request show
no-storeorprivate, and that the CDN does not cache it.
Automate the security cases (2, 4 and 5) as integration tests if you can. They are the ones that leak unpublished content when they break, and they are easy to break when someone refactors the data layer.
Explaining it to editorsLink to this section
Editors don't need the mechanics, but they do need to know three things: the Preview button opens the real site in a special mode, a banner shows when that mode is on, and they should exit preview when they're finished. A short note in the dashboard, or a help panel added with the block editor's plugin API, prevents most support questions.
Common problemsLink to this section
- 401 from the REST API. The Application Password is wrong, the user can't edit posts, or a security plugin or the server strips the
Authorizationheader. On Apache, addSetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1to.htaccess. - Preview shows the old version of a published post. You're reading the post, not its newest autosave.
- Preview works once, then shows cached content. A fetch somewhere in the page tree still uses the default cache. Make every preview fetch
no-store. - Editors land on a 404. The draft has no slug; make sure you redirect by ID.
- Custom fields missing. Register meta with
show_in_rest, and requestcontext=editso protected fields are included.
Frequently asked questionsLink to this section
Do I need WPGraphQL for previews?Link to this section
No. The REST API with Application Passwords is enough. WPGraphQL has its own preview support, which is convenient if you already use it; the WPGraphQL vs REST comparison covers when that's worth it.
Can I use JWT authentication instead?Link to this section
Yes, with a JWT plugin. Tokens must stay on the server and should be short-lived. If previews fail, decode the token in the JWT decoder to check its expiry and claims.
Does Draft Mode work with static export?Link to this section
No. Draft Mode needs a running Next.js server, because the page must be rendered per request.
Can previews show other post types?Link to this section
Yes. Use the same route with a type parameter and fetch from the matching REST endpoint, such as /wp/v2/pages or a custom post type registered with show_in_rest.
Need previews, revalidation or a full headless build? Let's talk.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.