In a Headless WordPress architecture, a primary impediment to editorial operations is draft isolation: the client-facing Next.js frontend serves users from an immutable static cache (ISR/SSG) on Edge CDNs, while unpublished editorial assets (drafts, scheduled posts, and revisions) are locked within the WordPress database and blocked from unauthenticated public inspection.
To bridge this divide in the modern Next.js 16 (App Router) stack, the framework provides Draft Mode (which succeeds the legacy previewData mechanism from the Pages Router).
Operational Mechanics of Draft Mode:
preview_post_link filter intercepts the default preview link and reroutes the editor's browser to a secured frontend endpoint: /api/draft?secret=...&id=42&slug=my-draft.draftMode().enable().__prerender_bypass configured with SameSite=Lax, Secure, and HttpOnly attributes.__prerender_bypass cookie, Next.js bypasses static caches (both Data Cache and Full Route Cache) exclusively for the editor's active session, switching into on-demand server rendering mode.const { isEnabled } = await draftMode();. When enabled, the component utilizes a privileged machine-to-machine token (Application Password or scoped JWT) to retrieve the freshest draft revision directly from protected WordPress REST endpoints (/wp/v2/posts/{id}/revisions).For enterprise security, the preview pipeline must eliminate Open Redirect vulnerabilities (preventing external phishing redirects via arbitrary slug parameters), enforce token timestamp validation, and furnish a clear escape mechanism (/api/disable-draft).
End-to-end authorized handshake and revision rendering protocol:
+-----------------------------------------------------------------------------------------+
| WORDPRESS ADMIN (Gutenberg) |
+-----------------------------------------------------------------------------------------+
| [Editor clicks "Preview"] |
| | |
| v |
| [Filter: preview_post_link] ---> Generates preview URL with temporary token: |
| https://frontend.com/api/draft?secret=TOKEN&id=42... |
+-----------------------------------------------------------------------------------------+
|
| 1. Browser Navigation (GET)
v
+-----------------------------------------------------------------------------------------+
| NEXT.JS 16 ROUTE HANDLER |
+-----------------------------------------------------------------------------------------+
| [/api/draft/route.ts] |
| | |
| +---> [Secret Verification: crypto.timingSafeEqual()] |
| | |
| +---> [draftMode().enable()] ---> Sets Cookie: __prerender_bypass |
| | |
| +---> [307 Temporary Redirect to /blog/p1] |
+-----------------------------------------------------------------------------------------+
|
| 2. Redirect with __prerender_bypass cookie
v
+-----------------------------------------------------------------------------------------+
| NEXT.JS 16 REACT SERVER COMPONENT (RSC) |
+-----------------------------------------------------------------------------------------+
| [app/blog/[slug]/page.tsx] |
| | |
| +---> const { isEnabled } = await draftMode(); // TRUE! |
| | | |
| | +---> [BYPASS CDN CACHE]: Fetch draft revision via M2M Bearer token |
| | |
| +---> Render unpublished content + Sticky Banner "Preview Active [Exit]" |
+-----------------------------------------------------------------------------------------+Built-in Next.js framework API allowing temporary bypass of static caching layers for authorized sessions without requiring project rebuilds.
Cryptographically signed HTTP cookie set by Next.js upon activating Draft Mode, instructing edge routers to execute dynamic on-demand rendering.
The mechanism of extracting the latest saved autosave or draft revision from the wp_posts database table using elevated server-side CMS credentials.
Rigorous sanitization of destination redirect paths, preventing attackers from injecting external phishing domains via query parameters like slug or redirect.
Isolated service account with minimum read privileges (read_private_posts) used exclusively by the Next.js server to query WordPress API endpoints.
WordPress intercepts the preview button click via the preview_post_link filter, generating a secure frontend URL with a cryptographic token.
The app/api/draft/route.ts handler performs timing-safe secret verification and verifies entity existence against the WordPress API.
Next.js invokes draftMode().enable() and responds with a Set-Cookie header for __prerender_bypass, issuing a 307 redirect to the sanitized path.
Server Components detect draftMode().isEnabled = true and query the latest draft revision using server-side M2M Application Passwords.
The page renders with a sticky 'Preview Mode Active' banner containing an action link to /api/disable-draft to restore standard caching.
If /api/draft unthinkingly redirects to an unvalidated parameter:
// SEVERE VULNERABILITY:
redirect(searchParams.get('slug'));An attacker can engineer links like https://frontend.com/api/draft?secret=VALID&slug=//attacker.com/login, turning your domain into an open redirector for phishing campaigns.
Enterprise Sanitization:
Paths must be strictly relative, starting with a single / and excluding leading double-slashes // or scheme-identifying colons:
function sanitizeRedirectPath(path: string | null): string {
if (!path || !path.startsWith('/') || path.startsWith('//') || path.includes(':')) {
return '/';
}
return path;
}Below is an enterprise-ready implementation consisting of a WordPress PHP filter for generating signed preview links and secure Next.js 16 Route Handlers for enabling and disabling Draft Mode.
// ============================================================================
// PART 1: Next.js 16 Draft Activation Route Handler (app/api/draft/route.ts)
// ============================================================================
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
/**
* Validates relative path redirection to neutralize Open Redirect attacks.
*/
function sanitizeRedirect(path: string | null): string {
if (!path || !path.startsWith('/') || path.startsWith('//') || path.includes(':')) {
return '/';
}
return path;
}
export async function GET(request: NextRequest): Promise<NextResponse | void> {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
const id = searchParams.get('id');
const configuredSecret = process.env.WORDPRESS_PREVIEW_SECRET;
if (!configuredSecret || !secret) {
return NextResponse.json({ message: 'Invalid preview token' }, { status: 401 });
}
// Timing-safe secret comparison
const secretBuffer = Buffer.from(secret, 'utf8');
const expectedBuffer = Buffer.from(configuredSecret, 'utf8');
if (
secretBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(secretBuffer, expectedBuffer)
) {
return NextResponse.json({ message: 'Unauthorized preview access' }, { status: 401 });
}
// 1. Enable Draft Mode (sets __prerender_bypass cookie)
const draft = await draftMode();
draft.enable();
// 2. Safely redirect to target post path
const redirectPath = slug ? `/blog/${slug}` : (id ? `/blog/?p=${id}` : '/');
redirect(sanitizeRedirect(redirectPath));
}
// ============================================================================
// PART 2: Next.js 16 Disable Route Handler (app/api/disable-draft/route.ts)
// ============================================================================
export async function GET(request: NextRequest): Promise<NextResponse | void> {
const { searchParams } = new URL(request.url);
const redirectUrl = sanitizeRedirect(searchParams.get('redirect'));
// Disable Draft Mode (clears __prerender_bypass cookie)
const draft = await draftMode();
draft.disable();
redirect(redirectUrl);
}
// ============================================================================
// PART 3: Sticky Preview Banner RSC (components/PreviewBanner.tsx)
// ============================================================================
export async function PreviewBanner({ currentPath }: { currentPath: string }) {
const { isEnabled } = await draftMode();
if (!isEnabled) {
return null;
}
return (
<aside className="sticky top-0 z-50 flex items-center justify-between bg-accent-amber/90 px-4 py-2 text-xs font-semibold text-black backdrop-blur-md shadow-md">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-red-600 animate-pulse" />
<span>Warning: Preview Mode Active (displaying unpublished revisions)</span>
</div>
<a
href={`/api/disable-draft?redirect=${encodeURIComponent(currentPath)}`}
className="rounded bg-black px-3 py-1 text-white hover:bg-neutral-800 transition-colors"
>
Exit Preview
</a>
</aside>
);
}
// ============================================================================
// PART 4: WordPress Preview Filter (wp-content/mu-plugins/agentic-preview.php)
// ============================================================================
/*
<?php
declare(strict_types=1);
namespace AgenticLab\Integration;
final class AgenticPreviewLinkFilter {
private const string FRONTEND_DRAFT_URL = 'https://frontend.agenticlab.com/api/draft';
public static function init(): void {
add_filter('preview_post_link', [self::class, 'filter_preview_link'], 10, 2);
}
public static function filter_preview_link(string $link, \WP_Post $post): string {
$secret = defined('WORDPRESS_PREVIEW_SECRET') ? WORDPRESS_PREVIEW_SECRET : '';
if (empty($secret)) {
return $link;
}
return add_query_arg([
'secret' => $secret,
'id' => $post->ID,
'slug' => $post->post_name ?: 'draft-' . $post->ID,
], self::FRONTEND_DRAFT_URL);
}
}
add_action('init', [AgenticPreviewLinkFilter::class, 'init']);
*/
Generate a 32-byte secure key using random_bytes(32) and configure WORDPRESS_PREVIEW_SECRET in both backend and frontend environments.
Establishes a mutually authenticated trust channel.
Construct app/api/draft/route.ts and app/api/disable-draft/route.ts with Open Redirect sanitization and draftMode() controls.
Manages the lifecycle of the signed __prerender_bypass cookie.
Hook into preview_post_link to redirect Gutenberg's 'Preview' button to the frontend API route with entity ID and token query parameters.
Provides seamless, native editorial workflows within WordPress.
Incorporate draftMode().isEnabled checks in Server Components, conditionally querying private revisions and rendering the sticky exit banner.
Guarantees accurate draft rendering with an easy path back to public cache.
Delivering a resilient preview architecture in Headless WordPress rests upon three engineering tenets:
draftMode().enable() in the App Router coupled with the secure __prerender_bypass session cookie.