Sheraz AhmadSenior Web Developer
Let's talk

Headless WordPress Preview Mode Next.js: Setup Guide

A complete, production-tested implementation of headless WordPress preview mode in Next.js using App Router Draft Mode and secure WordPress preview links.

Implementing a working headless wordpress preview mode nextjs workflow is often the hardest part of decoupling content from the front end. Out of the box, clicking "Preview" in WordPress routes the editor to the backend URL or a broken 404, while draft revisions remain hidden behind WordPress authentication. This guide covers how to wire the WordPress preview link to the Next.js App Router using Draft Mode, cryptographic tokens, and authenticated REST API requests.

Quick answer

  • Hook into WordPress using the preview_post_link filter to rewrite draft links directly toward your Next.js preview route (/api/draft).
  • Generate a timed HMAC signature in PHP containing the post ID and status so Next.js can verify the request came from an authenticated editor.
  • Enable draftMode().enable() in Next.js to set a secure cookie that disables Static Site Generation (SSG) caches for that session.
  • Fetch the draft or latest revision from the WordPress REST API using an Application Password or bearer token.
  • Render an exit banner in your layout that calls an API route to invoke draftMode().disable() when the editor finishes previewing.

How Headless WordPress Preview Mode in Next.js Actually Works

Previewing drafts across two separate domains requires bridging identity and caching boundaries without leaking unpublished content.

WordPress Admin                Next.js (/api/draft)             Next.js Page ([slug])
      |                                 |                                 |
      |-- 1. Click Preview Link ------->|                                 |
      |   (?id=123&secret=token)        |-- 2. Verify HMAC token          |
      |                                 |-- 3. Set draftMode() cookie     |
      |                                 |-- 4. Redirect to post path ---->|
      |                                 |                                 |-- 5. draftMode().isEnabled
      |<-- 6. Fetch /wp/v2/posts/123 ---|---------------------------------|      Bypasses Next cache
      |    with Basic Auth credentials  |                                 |
      |-------------------------------->|-------------------------------->|-- 7. Renders draft

The sequence flows predictably: 1. An editor clicks "Preview" inside Gutenberg or the Classic Editor. 2. WordPress calculates a secure HMAC token valid for 60 seconds and points the browser to your Next.js API route. 3. Next.js validates the secret, enables Draft Mode by writing a cookie, and redirects the browser to the actual post URL. 4. When rendering the page, Next.js inspects draftMode().isEnabled. If true, it uses an authenticated fetch to pull the draft revision directly from WordPress, bypassing fetch cache layers.

Step 1: Rewrite the WordPress Preview Link

By default, WordPress generates a preview URL pointing to /?p=123&preview=true. You must intercept this URL and route the editor to your Next.js deployment.

Add this code to a custom functionality plugin or your theme's functions.php file:

<?php
declare(strict_types=1);

add_filter('preview_post_link', function (string $preview_link, \WP_Post $post): string {
    if (!current_user_can('edit_post', $post->ID)) {
        return $preview_link;
    }

    $frontend_url = defined('HEADLESS_FRONTEND_URL') 
        ? rtrim(HEADLESS_FRONTEND_URL, '/') 
        : 'https://example.com';

    $secret = defined('HEADLESS_PREVIEW_SECRET') 
        ? HEADLESS_PREVIEW_SECRET 
        : '';

    // Token expires in 60 seconds to prevent replay attacks
    $time = time();
    $token = hash_hmac('sha256', "{$post->ID}:{$time}", $secret);

    return add_query_arg([
        'id'     => $post->ID,
        'time'   => $time,
        'token'  => $token,
        'status' => $post->post_status,
    ], "{$frontend_url}/api/draft");
}, 10, 2);

Define the constants in your wp-config.php:

define('HEADLESS_FRONTEND_URL', 'https://your-nextjs-site.com');
define('HEADLESS_PREVIEW_SECRET', 'replace-with-a-64-character-random-hex-string');

This implementation checks current_user_can('edit_post') before generating the link and adds a cryptographic timestamp check to prevent link sharing.

Step 2: Create the Next.js Draft Mode Route Handler

In the Next.js App Router, route handlers run on the server and manage cookies. Create the file at app/api/draft/route.ts.

This handler verifies the HMAC token, checks the post's permalink via the WordPress REST API, enables draft mode, and redirects the user to the draft's canonical URL.

import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const id = searchParams.get('id');
  const time = searchParams.get('time');
  const token = searchParams.get('token');

  const secret = process.env.HEADLESS_PREVIEW_SECRET;
  if (!id || !time || !token || !secret) {
    return new NextResponse('Missing preview parameters', { status: 400 });
  }

  // Check expiration: reject tokens older than 60 seconds
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(time, 10)) > 60) {
    return new NextResponse('Preview token expired', { status: 401 });
  }

  // Verify signature
  const expectedToken = crypto
    .createHmac('sha256', secret)
    .update(`${id}:${time}`)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
    return new NextResponse('Invalid signature', { status: 401 });
  }

  // Fetch the slug from WordPress using an Application Password
  const wpUrl = process.env.WORDPRESS_API_URL;
  const auth = Buffer.from(
    `${process.env.WP_APPLICATION_USER}:${process.env.WP_APPLICATION_PASSWORD}`
  ).toString('base64');

  const res = await fetch(`${wpUrl}/wp/v2/posts/${id}?context=edit`, {
    headers: { Authorization: `Basic ${auth}` },
    cache: 'no-store',
  });

  if (!res.ok) {
    return new NextResponse('Post not found', { status: 404 });
  }

  const post = await res.json();

  // Enable Draft Mode (writes the bypass cookie)
  const draft = await draftMode();
  draft.enable();

  // Redirect to the actual frontend path
  const slug = post.slug || id;
  redirect(`/posts/${slug}`);
}
Never trust post IDs or slugs sent as raw query parameters without validating them against the WordPress API. An unverified ID in the preview route allows attackers to force your server into rendering unauthorized drafts or leaking revisions through Draft Mode.

Step 3: Fetch Draft Data and Latest Revisions

When draftMode().isEnabled is true, regular public REST API calls will fail to return unpublished drafts. You must supply credentials to view drafts, and you must check for revisions if the post has already been published.

Create a helper function lib/wordpress.ts to manage preview fetches:

import { draftMode } from 'next/headers';

interface PostResponse {
  id: number;
  title: { rendered: string };
  content: { rendered: string };
  slug: string;
  acf?: Record<string, any>;
}

export async function getPostBySlug(slug: string): Promise<PostResponse | null> {
  const { isEnabled } = await draftMode();
  const wpUrl = process.env.WORDPRESS_API_URL;

  const headers: HeadersInit = { 'Content-Type': 'application/json' };
  const fetchOptions: RequestInit = {};

  if (isEnabled) {
    // Draft Mode: bypass Next.js cache and add Basic Auth
    const auth = Buffer.from(
      `${process.env.WP_APPLICATION_USER}:${process.env.WP_APPLICATION_PASSWORD}`
    ).toString('base64');
    headers['Authorization'] = `Basic ${auth}`;
    fetchOptions.cache = 'no-store';
  } else {
    // Production: cache aggressively with tags
    fetchOptions.next = { tags: [`post-${slug}`], revalidate: 3600 };
  }

  // 1. Fetch the post by slug
  const res = await fetch(
    `${wpUrl}/wp/v2/posts?slug=${encodeURIComponent(slug)}&status=any`, 
    { headers, ...fetchOptions }
  );

  if (!res.ok) return null;
  const posts: PostResponse[] = await res.json();
  if (!posts.length) return null;

  const post = posts[0];

  // 2. If previewing a published post with an unpublished revision, get the latest revision
  if (isEnabled) {
    const revRes = await fetch(`${wpUrl}/wp/v2/posts/${post.id}/revisions?per_page=1`, {
      headers,
      cache: 'no-store',
    });

    if (revRes.ok) {
      const revisions = await revRes.json();
      if (revisions.length > 0) {
        const latestRevision = revisions[0];
        return {
          ...post,
          title: latestRevision.title,
          content: latestRevision.content,
        };
      }
    }
  }

  return post;
}

If you use Advanced Custom Fields (ACF), make sure "Show in REST API" is checked in the field group settings. Under Draft Mode, ACF fields are attached under the acf key on the parent post object.

Step 4: Render the Page with an Exit Preview Banner

When Draft Mode is active, display a persistent notification so content editors know they are viewing unreleased changes and can exit when finished.

Here is the page component at app/posts/[slug]/page.tsx:

import { draftMode } from 'next/headers';
import { notFound } from 'next/navigation';
import { getPostBySlug } from '@/lib/wordpress';

interface Props {
  params: Promise<{ slug: string }>;
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  const { isEnabled: isDraft } = await draftMode();

  if (!post) {
    notFound();
  }

  return (
    <article className="max-w-3xl mx-auto py-12 px-4">
      {isDraft && (
        <aside className="mb-6 p-4 bg-amber-100 border border-amber-300 rounded flex justify-between items-center text-amber-900">
          <span className="font-medium text-sm">
            Preview Mode Active (Viewing Draft / Latest Revision)
          </span>
          <a
            href="/api/disable-draft"
            className="text-xs underline bg-amber-200 hover:bg-amber-300 px-2.5 py-1 rounded"
          >
            Exit Preview
          </a>
        </aside>
      )}

      <h1
        className="text-4xl font-bold tracking-tight mb-4"
        dangerouslySetInnerHTML={{ __html: post.title.rendered }}
      />
      <div
        className="prose lg:prose-xl"
        dangerouslySetInnerHTML={{ __html: post.content.rendered }}
      />
    </article>
  );
}

Now create the exit handler at app/api/disable-draft/route.ts:

import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';

export async function GET() {
  const draft = await draftMode();
  draft.disable();
  redirect('/');
}

Step 5: Configure Application Passwords in WordPress

To allow Next.js to fetch drafts and revisions via REST API without requiring visitors to log into WordPress, generate an Application Password:

  1. Log into your WordPress admin dashboard.
  2. Go to Users > Profile (or select an editor-level administrative user).
  3. Scroll down to Application Passwords.
  4. Enter an application name (e.g., NextJS Headless Preview) and click Add New Application Password.
  5. Copy the generated string (e.g., abcd 1234 efgh 5678).

Store these credentials in your Next.js .env.local file:

WORDPRESS_API_URL="https://backend.example.com/wp-json"
HEADLESS_PREVIEW_SECRET="your-64-character-random-hex-string"
WP_APPLICATION_USER="headless_service_user"
WP_APPLICATION_PASSWORD="abcd 1234 efgh 5678"

For production, create a dedicated WordPress user with the Editor role rather than an Administrator role to observe the principle of least privilege.

Troubleshooting Common Preview Mode Failures

1. Preview Shows the Old Content Instead of the Revision If a post is already published and an editor makes edits, WordPress does not update wp_posts immediately; it creates a row in wp_posts with post_type = 'revision'. A standard request to /wp/v2/posts/{id} will continue returning the published content. Ensure your code queries /wp/v2/posts/{id}/revisions when draftMode().isEnabled is active.

2. Cookies Disappear Across Domains (SameSite Issues) Next.js sets the __prerender_bypass cookie when draft.enable() is called. If your WordPress backend and Next.js frontend are hosted on different top-level domains, ensure your Next.js domain maintains secure cookies (SameSite=Lax or SameSite=None; Secure). Draft Mode handles this automatically provided Next.js is served over HTTPS.

3. Infinite Redirect Loops on Preview Click If /api/draft redirects to /posts/my-slug, but my-slug does not exist in the public post list because it is currently an unpublished draft, your frontend fetch must supply status=any with an Authorization header. If it does not, WordPress returns a 404 or empty array, throwing notFound() in Next.js.

4. Gutenberg Iframe Block Previews Fail If using the newer WordPress Site Editor or Gutenberg desktop previews that load inside an iframe, check your Content Security Policy (CSP). You must allow your Next.js site to frame or be framed by the WordPress origin by setting frame-ancestors correctly in your headers:

// In next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: "frame-ancestors 'self' https://backend.example.com",
          },
        ],
      },
    ];
  },
};

Checklist

  • Define HEADLESS_PREVIEW_SECRET and HEADLESS_FRONTEND_URL in wp-config.php.
  • Filter preview_post_link to append post ID, timestamp, and HMAC signature.
  • Create a dedicated WordPress user with an Application Password for headless REST API requests.
  • Enable "Show in REST API" on all ACF Field Groups intended for preview.
  • Implement app/api/draft/route.ts to validate signature timing and activate draftMode().
  • Implement app/api/disable-draft/route.ts to clear draft mode.
  • Conditionally fetch the latest post revision in your data layer when draftMode().isEnabled is true.
  • Add an exit banner to post layouts indicating preview mode status to editors.
  • Verify both new drafts (status=draft) and updates to published posts (status=publish with revisions) render correctly on the frontend.

Frequently asked questions

Why does my headless WordPress preview show published content instead of my draft edits?

When editing an already published post, WordPress saves unsaved changes to a revision record rather than the main post. Your Next.js data-fetching function must check if Draft Mode is enabled and explicitly fetch the latest entry from the `/wp/v2/posts/<id>/revisions` endpoint using valid authentication.

What is the difference between Preview Mode and Draft Mode in Next.js?

Preview Mode (`res.setPreviewData`) is legacy functionality used in the Next.js Pages Router. Draft Mode (`draftMode().enable()`) is the App Router standard. It bypasses Static Site Generation (SSG) caches using lightweight, secure cookies without serializing custom data into the cookie payload.

Do I need a plugin to handle headless previews in WordPress?

No. While plugins like WPGraphQL or Next.js preview plugins exist, you can implement the entire flow using core WordPress filters (`preview_post_link`), standard REST API endpoints, and core Application Passwords. This approach reduces plugin dependency and gives you direct control over token validation.

Reference documentation

Drafted with AI assistance. Code targets current WordPress, WooCommerce and Next.js APIs; test changes on a staging site before production.

Need a fast, modern website?

I'm Sheraz Ahmad, a senior WordPress and headless Next.js developer with 7+ years of shipping high-performance sites. Open to permanent roles, contracts and freelance projects.

Let's talk