title: >- Headless WP + Next.js 16 Architecture: On-Demand ISR, HMAC Webhooks, and Draft Mode description: >- Architecting a resilient decoupled bridge between WordPress 7.0+ (Origin CMS) and Next.js 16 App Router: granular cache invalidation via revalidateTag and cacheTag, cryptographically signed HMAC-SHA256 webhooks on wp_after_insert_post, draft previews via draftMode(), and origin security isolation. module: rest-api-headless order: 5 difficulty: advanced estimatedTime: 42 min topics:
Adopting Headless WordPress (Decoupled CMS) segregates the monolith into two distinct operational circuits: the Content Origin (WordPress 7.0+ on PHP 8.4+), where editorial staff curate content, assets are uploaded, and business validation rules run; and the Edge Presentation Layer (Next.js 16 App Router), dedicated to instant rendering, global CDN edge delivery, and interactive client islands.
However, naive implementations of Headless architectures predictably collapse at two points:
cache: 'no-store'), every visitor request penetrates Next.js completely and strikes WordPress PHP-FPM workers directly, defeating the performance benefits of decoupled hosting. If configured with static generation (SSG) on long timers, editors publish changes in wp-admin and wait hours to see updates reflected on the live site.draft / autosave), because public APIs block access to unpublished posts and the frontend is unaware of revision IDs.The industry-standard solution is Event-Driven Tag Revalidation (On-Demand ISR): the frontend caches data indefinitely or with generous stale boundaries using tagged caches (cacheTag('post-42', 'collection-posts')), while WordPress emits an asynchronous, cryptographically signed HMAC-SHA256 webhook on post persistence (wp_after_insert_post) to a Next.js 16 Route Handler calling revalidateTag(). Editorial workflows are restored using Next.js 16 draftMode() authenticated via signed single-use preview tokens.
Conceive the WordPress 7.0+ and Next.js 16 pairing as a Central Mint (WordPress Origin) and a Global Network of Armored Showcases (Next.js Edge Data Cache). Public users never enter the central mint—they inspect instant snapshots at local showcases (TTFB < 20ms). The mint never serves consumer traffic directly; it merely broadcasts cryptographically verified dispatches (HMAC-SHA256 Webhooks) to showcases: "Item post-42 and taxonomy category-architecture were modified—revalidate exactly those two snapshots in the background".
[Editor in WP Admin] ──► Saves post #42 (hook: wp_after_insert_post)
│
▼
[WP 7.0+ Webhook Dispatcher] ──► Computes HMAC-SHA256(payload, WEBHOOK_SECRET)
Dispatches POST /api/revalidate (timeout: 2s)
│
▼
[Next.js 16 Route Handler] ──► Validates X-Agentic-Signature-256 (timingSafeEqual)
Executes revalidateTag('post:42', 'default')
Executes revalidateTag('posts:list', 'default')
│
▼
[Next.js 16 Edge Cache] ──► Marks tags as Stale immediately
and background-refetches fresh JSON from WP OriginServer-side queries in Next.js 16 tag each WP REST/GraphQL fetch with granular markers: post:{slug}, post:{id}, term:{id}.
Once WordPress commits post rows, postmeta (ACF), and taxonomy links atomically, the dispatcher assembles the invalidation payload.
The webhook body is signed with a shared secret (hash_hmac('sha256', ...)) and dispatched in the X-Agentic-Signature-256 header.
The Next.js 16 Route Handler verifies the signature with crypto.timingSafeEqual and invalidates targeted tags without global site rebuilds.
Clicking 'Preview' in Gutenberg redirects the editor to /api/draft?postId=42&secret=..., issuing the __prerender_bypass cookie.
In the architecture of Fullstack Agentic Lab, the patterns in this lesson represent the foundation of production Headless deployments:
< 300ms from wp-admin click to global Edge delivery) without requiring full site rebuilds.layout:navigation or term:architecture tags across all dependent Next.js routes.draftMode()): Editors inspect unsaved drafts in the real Next.js 16 design environment prior to publication.stale-while-revalidate).Compare three generations of synchronization patterns between WordPress and Next.js:
Below is a two-sided production artifact (WordPress 7.0+ / PHP 8.4+ and Next.js 16 App Router / TypeScript 5.8) implementing a cryptographically signed content synchronization bus and secure draft preview mode (draftMode()).
<?php
/**
* PART 1: WordPress 7.0+ (PHP 8.4+) — HMAC Webhook Dispatcher & Draft Mode Link Builder.
* File: wp-content/mu-plugins/agentic-headless-sync.php
*/
declare(strict_types=1);
namespace AgenticLab\Headless;
use WP_Post;
defined('ABSPATH') || exit;
final readonly class NextJsEdgeSyncDispatcher
{
public function __construct(
private string $nextWebhookUrl,
private string $nextDraftUrl,
private string $webhookSecret,
) {}
public function registerHooks(): void
{
// CRITICAL: use wp_after_insert_post instead of save_post,
// ensuring postmeta and taxonomies are committed to MySQL prior to Next.js querying!
add_action('wp_after_insert_post', $this->dispatchPostRevalidation(...), 20, 4);
add_action('edited_term', $this->dispatchTermRevalidation(...), 20, 3);
add_filter('preview_post_link', $this->rewriteGutenbergPreviewLink(...), 10, 2);
}
/**
* Dispatches signed HMAC-SHA256 webhook when a post is published or updated.
*/
public function dispatchPostRevalidation(
int $postId,
WP_Post $post,
bool $update,
?WP_Post $postBefore
): void {
// Ignore autosaves, revisions, and internal technical post types
if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
return;
}
// Revalidate only if post is published now OR was published prior to modification (e.g., unpublished)
$wasPublished = $postBefore instanceof WP_Post && $postBefore->post_status === 'publish';
$isPublished = $post->post_status === 'publish';
if (!$isPublished && !$wasPublished) {
return;
}
$tags = [
"post:{$post->ID}",
"post:slug:{$post->post_name}",
"collection:{$post->post_type}",
];
// If the slug changed on a published post, invalidate the previous slug as well
if ($wasPublished && $postBefore !== null && $postBefore->post_name !== $post->post_name) {
$tags[] = "post:slug:{$postBefore->post_name}";
}
$this->sendSignedPayload([
'event' => 'post.updated',
'postId' => $post->ID,
'postType' => $post->post_type,
'slug' => $post->post_name,
'tags' => array_values(array_unique($tags)),
'paths' => ["/{$post->post_name}", '/'],
'timestamp' => time(),
]);
}
/**
* Purges taxonomy tags when categories or tags are updated.
*/
public function dispatchTermRevalidation(int $termId, int $ttId, string $taxonomy): void
{
$this->sendSignedPayload([
'event' => 'term.updated',
'termId' => $termId,
'taxonomy' => $taxonomy,
'tags' => ["term:{$termId}", "taxonomy:{$taxonomy}"],
'paths' => [],
'timestamp' => time(),
]);
}
/**
* Rewrites Gutenberg Preview button links targeting Next.js 16 Draft Mode.
*/
public function rewriteGutenbergPreviewLink(string $previewLink, WP_Post $post): string
{
$signature = hash_hmac('sha256', "{$post->ID}:{$post->post_type}", $this->webhookSecret);
return add_query_arg([
'postId' => $post->ID,
'postType' => $post->post_type,
'signature' => $signature,
], $this->nextDraftUrl);
}
/**
* Signs JSON payload with HMAC-SHA256 and dispatches non-blocking HTTP request.
*
* @param array<string, mixed> $payload
*/
private function sendSignedPayload(array $payload): void
{
$body = (string) wp_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$signature = 'sha256=' . hash_hmac('sha256', $body, $this->webhookSecret);
wp_remote_post($this->nextWebhookUrl, [
'timeout' => 2.0,
'redirection' => 0,
'blocking' => false, // Do not block Gutenberg editorial response
'headers' => [
'Content-Type' => 'application/json',
'X-Agentic-Signature-256' => $signature,
'User-Agent' => 'AgenticLab-WP-Origin/7.0',
],
'body' => $body,
]);
}
}
/*
* ============================================================================
* PART 2: Next.js 16 App Router (TypeScript 5.8) — Route Handler /app/api/revalidate/route.ts
* ============================================================================
*
* import { createHmac, timingSafeEqual } from 'node:crypto';
* import { revalidatePath, revalidateTag } from 'next/cache';
* import { NextRequest, NextResponse } from 'next/server';
*
* export async function POST(request: NextRequest): Promise<NextResponse> {
* const rawBody = await request.text();
* const signatureHeader = request.headers.get('x-agentic-signature-256') ?? '';
* const secret = process.env.WP_WEBHOOK_SECRET ?? '';
*
* const expectedSig = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
* const sigBuffer = Buffer.from(signatureHeader);
* const expectedBuffer = Buffer.from(expectedSig);
*
* if (sigBuffer.length !== expectedBuffer.length || !timingSafeEqual(sigBuffer, expectedBuffer)) {
* return NextResponse.json({ error: 'Invalid HMAC signature' }, { status: 401 });
* }
*
* const payload = JSON.parse(rawBody) as { tags: string[]; paths: string[]; timestamp: number };
*
* // Replay attack defense (5 minute allowable window)
* if (Math.abs(Math.floor(Date.now() / 1000) - payload.timestamp) > 300) {
* return NextResponse.json({ error: 'Expired webhook timestamp' }, { status: 408 });
* }
*
* for (const tag of payload.tags) {
* revalidateTag(tag, 'default');
* }
* for (const path of payload.paths) {
* revalidatePath(path);
* }
*
* return NextResponse.json({ revalidated: true, tags: payload.tags });
* }
*/
Standardize tag conventions across PHP and TypeScript: post:{id}, post:slug:{slug}, collection:{post_type}, term:{id}, global:settings.
Generate a 256-bit secret WP_WEBHOOK_SECRET in wp-config.php and Next.js .env.production, validating signatures with timingSafeEqual.
Override preview_post_link in WordPress so clicking 'Preview' in Gutenberg activates Next.js draftMode() with authenticated tokens.
Inject X-Robots-Tag: noindex on the CMS domain, rewrite media attachments (wp_get_attachment_url) to CDN domains, and restrict APIs behind Cloudflare WAF.