There is no frustration quite like the "Preview" button failing in a headless WordPress architecture. You spend hours architecting a perfect Next.js frontend, but when your content editors click Preview in the WordPress Admin, they are greeted by a Next.js 404 page, an infinite loading spinner, or worse—the published version of the page instead of their draft.
If you are migrating from the Next.js Pages Router to the App Router (Next.js 13+), the old res.setPreviewData method is dead. The new draftMode() API is powerful but introduces strict architectural requirements regarding cookies, caching, and GraphQL authentication that standard documentation often glosses over.
This guide provides a production-grade implementation to fix broken previews in Headless WordPress using the Next.js App Router and WPGraphQL.
The Root Cause: Why Previews Fail in the App Router
To fix the problem, we must understand the mechanics of the failure. The breakdown usually occurs in the "Handshake" or the "Fetch Strategy."
1. The Cookie Boundary
In the Pages Router, preview data was stored in a signed cookie that persisted across requests. In the App Router, draftMode() also relies on cookies, but the strict boundary between Server Components and Client Components complicates things. If you attempt to access draft content on a page that is statically generated (SSG) without explicitly opting into dynamic rendering for that specific request, Next.js may serve the cached, non-preview HTML.
2. The Slug vs. ID Dilemma
This is the most common technical error. When a post is a Draft, it technically does not have a finalized slug in the WordPress permalink structure. If your Next.js page attempts to fetch the post by uri or slug (e.g., query GetPostBySlug($slug: ID!)), WPGraphQL will often return null because that slug doesn't point to a public node yet.
3. WPGraphQL Authentication
WPGraphQL does not expose draft content to public queries. Even if draftMode is enabled in Next.js, if the fetch request to your GraphQL endpoint doesn't include the correct Authorization header (JWT or Basic Auth), WordPress will deny access to the draft data, resulting in a null response and a subsequent 404 in your component.
The Solution: A Robust Draft Mode Pipeline
We will implement a three-part solution:
- WordPress Config: Setting the correct preview URL.
- Next.js Route Handler: A secure handshake API to set the cookie.
- Data Layer: A fetcher that handles auth headers and
asPreviewlogic.
Prerequisites
Ensure you have the following environment variables in your .env.local file. Do not hardcode secrets.
NEXT_PUBLIC_WORDPRESS_API_URL=https://your-wp-site.com/graphql
# A random string shared between WP and Next.js
WORDPRESS_PREVIEW_SECRET=complex_random_string_here
# Only needed if using JWT authentication (recommended)
WORDPRESS_AUTH_REFRESH_TOKEN=your_refresh_token
Step 1: Configure WordPress Preview URL
By default, WordPress points previews to its own domain. You need to hook into WordPress to point the Preview button to your Next.js API route.
Add this code to your WordPress theme's functions.php or a custom plugin:
add_filter( 'preview_post_link', function ( $link, $post ) {
$headless_frontend_url = 'https://your-nextjs-site.com';
// We point to the API route, not the page directly
return $headless_frontend_url . '/api/draft?secret=complex_random_string_here&id=' . $post->ID;
}, 10, 2 );
Note: In production, ensure the secret matches your WORDPRESS_PREVIEW_SECRET.
Step 2: The Next.js Route Handler (The Handshake)
We need an API route to intercept the request from WordPress, verify the secret, enable draftMode, and redirect the browser to the actual page.
Create app/api/draft/route.ts:
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const id = searchParams.get('id');
// 1. Security Check
if (secret !== process.env.WORDPRESS_PREVIEW_SECRET || !id) {
return new Response('Invalid token', { status: 401 });
}
// 2. Fetch the Post to get the slug (Verification)
// We fetch by ID to ensure the draft actually exists before redirecting.
// This prevents open redirect vulnerabilities.
const postData = await getPreviewPost(id);
if (!postData) {
return new Response('Post not found', { status: 404 });
}
// 3. Enable Draft Mode
// This sets a cookie that bypasses the Data Cache
draftMode().enable();
// 4. Redirect to the actual path
// We use the slug retrieved from WPGraphQL to construct the URL
redirect(`/blog/${postData.slug}`);
}
// Helper fetcher specifically for the handshake
async function getPreviewPost(id: string) {
const query = `
query GetPreviewPost($id: ID!) {
post(id: $id, idType: DATABASE_ID) {
slug
status
}
}
`;
// Note: We need a specialized fetch here that includes Auth headers
// See Step 3 for the full fetch implementation.
const res = await fetch(process.env.NEXT_PUBLIC_WORDPRESS_API_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// CRITICAL: You must authenticate this request to see the draft
'Authorization': `Bearer ${process.env.WORDPRESS_AUTH_REFRESH_TOKEN}`,
},
body: JSON.stringify({
query,
variables: { id },
}),
});
const json = await res.json();
return json.data?.post;
}
Step 3: The Data Fetching Layer
This is where most implementations fail. Your fetch logic must change behavior based on whether draftMode().isEnabled is true.
Create lib/wordpress.ts:
import { draftMode } from 'next/headers';
const API_URL = process.env.NEXT_PUBLIC_WORDPRESS_API_URL as string;
export async function fetchGraphQL(
query: string,
variables: Record<string, any> = {}
) {
const { isEnabled } = draftMode();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// CRITICAL: Inject Auth Header if in Draft Mode
if (isEnabled && process.env.WORDPRESS_AUTH_REFRESH_TOKEN) {
headers['Authorization'] = `Bearer ${process.env.WORDPRESS_AUTH_REFRESH_TOKEN}`;
}
const response = await fetch(API_URL, {
method: 'POST',
headers,
body: JSON.stringify({
query,
variables,
}),
// CRITICAL: Bypass Next.js cache when previewing
cache: isEnabled ? 'no-store' : 'force-cache',
next: { tags: ['wordpress'] },
});
if (!response.ok) {
console.error('Failed to fetch API');
throw new Error('Failed to fetch API');
}
const json = await response.json();
if (json.errors) {
console.error(json.errors);
throw new Error('Failed to fetch API');
}
return json.data;
}
Step 4: The Page Component
Now, update your page component to utilize the updated fetcher.
File: app/blog/[slug]/page.tsx
import { fetchGraphQL } from '@/lib/wordpress';
import { notFound } from 'next/navigation';
import { draftMode } from 'next/headers';
export default async function BlogPost({ params }: { params: { slug: string } }) {
const { isEnabled } = draftMode();
// 1. Adjust Query based on Preview Mode
// If previewing, we often need to query using `asPreview: true`
// depending on your WPGraphQL configuration, though usually
// standard queries with Auth headers work for drafts.
const query = `
query GetPost($slug: ID!, $asPreview: Boolean!) {
post(id: $slug, idType: SLUG, asPreview: $asPreview) {
title
content
date
modified
}
}
`;
const data = await fetchGraphQL(query, {
slug: params.slug,
asPreview: isEnabled,
});
if (!data?.post) {
return notFound();
}
return (
<article className="max-w-4xl mx-auto py-10">
{isEnabled && (
<div className="bg-yellow-200 text-yellow-800 p-2 text-center mb-4 rounded">
Preview Mode Enabled: Showing Draft Content
</div>
)}
<h1 className="text-4xl font-bold mb-4">{data.post.title}</h1>
<div
className="prose lg:prose-xl"
dangerouslySetInnerHTML={{ __html: data.post.content }}
/>
</article>
);
}
Deep Dive: Handling the Auth Token
You might notice the use of WORDPRESS_AUTH_REFRESH_TOKEN. Why is this necessary?
By default, WPGraphQL respects WordPress user roles. A draft post is private. Even if you bypass the cache in Next.js, if you ask WordPress for a draft without proving you are an administrator or editor, WordPress will return null.
You have two options for generating this token:
- WPGraphQL JWT Authentication Plugin: Install this plugin on WordPress. Use a tool like Postman to login once, get a Refresh Token, and store it in your Next.js env variables. This is the long-term, secure method.
- Application Passwords: You can use Basic Auth (base64 encoded
username:application_password), but JWT is generally preferred for stateless API communication.
Common Pitfalls and Edge Cases
1. The "Preview" Loop
If you click preview and the page refreshes endlessly, your cache: 'no-store' directive is missing in the fetcher. Next.js is aggressively caching the 404 response. Ensure that isEnabled triggers a fetch with no caching.
2. Mixed Content Errors
If your WordPress is on HTTP (local) and Next.js is on HTTPS, the cookie might be blocked by browser security policies. Ensure SameSite=None; Secure is handled correctly if domains differ, though Next.js draftMode handles most of this internally.
3. Database ID vs. URI
In the Route Handler (api/draft/route.ts), always query by DATABASE_ID. A draft that has never been published might not have a clean URI yet. Querying by database ID is the immutable reference to the content object.
4. Vercel Protection Bypass
If you host on Vercel and use "Deployment Protection" (password or SSO), the WordPress webhook cannot reach your API route. You must configure the "x-vercel-protection-bypass" header or disable protection for the specific preview route.
Conclusion
Fixing preview mode in the Next.js App Router requires moving away from the "magic" of setPreviewData and adopting a strictly defined authentication pipeline. By ensuring your Route Handler verifies the secret and your Data Layer injects the correct Authentication headers, you bridge the gap between static generation and dynamic content editing.
This architecture not only fixes the 404 errors but lays the groundwork for a secure, enterprise-ready headless CMS implementation.