You publish a post in WordPress. Five seconds later, a client visits your Next.js site and still sees the old version. That delay is the central frustration of headless WordPress — and webhooks are what closes it.
In a traditional WordPress setup, there is no delay: a page request hits PHP, which reads the database and returns fresh HTML. In a headless setup, Next.js serves a pre-rendered HTML file from a CDN. That file is fast, but it does not automatically update when you change content in WordPress. Webhooks are the bridge — they let WordPress notify your frontend the instant a post changes, so Next.js can discard its cached version and regenerate just that page.
This guide covers the complete implementation: the WordPress side (triggering the webhook on save_post), the Next.js side (Pages Router and App Router), HMAC security to verify the payload came from your server, and how to test the whole pipeline locally with ngrok before deploying.
For the broader headless architecture context — including how the REST API exposes your WordPress content to Next.js — the REST API vs GraphQL comparison is the right starting point. This guide assumes that data-fetching layer is already in place.
Why Headless Content Goes Stale (and Why Polling Doesn’t Fix It)
Next.js Incremental Static Regeneration (ISR) solves part of the staleness problem: you set a revalidate interval in getStaticProps, and Next.js regenerates the page in the background after that interval passes. But ISR is time-based — a page set to revalidate every 60 seconds can show content up to 60 seconds old. For most editorial sites, that’s fine. For a store where a product just went out of stock, or a news site with a correction, it’s too slow.
Build hooks — where WordPress pings a Vercel or Netlify build URL on every save — solve the staleness problem but create a scaling one. A full site rebuild regenerates every page, even the ones that didn’t change. On a site with 500+ posts, a single typo fix can trigger a five-minute rebuild just to update one paragraph.
On-demand revalidation, introduced in Next.js 12.2, is the right answer for most headless WordPress sites: WordPress fires a webhook when a specific post is saved, Next.js receives it, calls revalidatePath or res.revalidate for just that URL, and the page is fresh within seconds — no full rebuild, no 60-second lag.
| Strategy | Freshness | Build cost | Complexity | Best for |
|---|---|---|---|---|
| Full rebuild (SSG) | Instant after build | Entire site | Low | Sites with <50 pages that rarely update |
| Time-based ISR | After interval (30s–5min) | Per-page, lazy | Medium | Blogs where a 60-second lag is acceptable |
| On-demand revalidation | Seconds after publish | One page only | Higher (initial setup) | Any site needing near-real-time updates |
Step 1 — WordPress Side: Fire a Webhook on Post Save
WordPress fires the save_post action every time a post is saved, whether from the block editor, classic editor, the REST API, or a bulk update. Hook into it in functions.php (or a small site-specific plugin) to send an HTTP POST to your Next.js revalidation endpoint.
The function below fires on save, skips revisions and autosaves, checks that the post is published, then sends the post slug to Next.js along with an HMAC-SHA256 signature. The signature is how Next.js verifies the request actually came from your WordPress server, not from an attacker who found the endpoint URL.
// functions.php
add_action( 'save_post', 'ak_notify_nextjs_on_save', 10, 3 );
function ak_notify_nextjs_on_save( $post_id, $post, $update ) {
// Skip revisions, autosaves, and non-published posts
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
if ( $post->post_status !== 'publish' ) {
return;
}
$frontend_url = 'https://yoursite.com/api/revalidate';
$secret_token = getenv( 'NEXTJS_REVALIDATION_SECRET' );
$slug = $post->post_name;
// HMAC-SHA256 signature so Next.js can verify the payload came from WP
$payload = json_encode( [ 'slug' => $slug ] );
$signature = hash_hmac( 'sha256', $payload, $secret_token );
wp_remote_post( $frontend_url, [
'timeout' => 5,
'headers' => [
'Content-Type' => 'application/json',
'x-wp-signature' => $signature,
'x-reval-secret' => $secret_token,
],
'body' => $payload,
'data_format' => 'body',
] );
}
Store NEXTJS_REVALIDATION_SECRET as a server environment variable — not hardcoded in functions.php. On most managed hosts (Kinsta, WP Engine, Cloudways), you set environment variables in the hosting dashboard. On a VPS, add it to the server’s shell environment or use a .htaccess SetEnv directive. A value generated with openssl rand -hex 32 gives you a 64-character cryptographically random secret.
If you prefer a plugin over custom code, WP Webhooks provides a UI for configuring outbound webhooks without touching functions.php. For more granular control over which endpoints fire and when, the custom REST endpoints guide covers registering dedicated webhook dispatch endpoints via the WordPress REST API.
Step 2a — Next.js Pages Router: The /api/revalidate Endpoint
In the Pages Router (Next.js 12 and earlier, or any project still on pages/), the revalidation handler lives in pages/api/revalidate.js. It verifies the secret, verifies the HMAC signature, and calls res.revalidate() for the changed path.
// pages/api/revalidate.js
import crypto from 'crypto';
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
const secret = process.env.NEXTJS_REVALIDATION_SECRET;
const tokenSent = req.headers['x-reval-secret'];
// Constant-time comparison to prevent timing attacks
if (!crypto.timingSafeEqual(Buffer.from(tokenSent), Buffer.from(secret))) {
return res.status(401).json({ message: 'Unauthorized' });
}
// HMAC signature verification
const body = JSON.stringify(req.body);
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
const received = req.headers['x-wp-signature'] ?? '';
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return res.status(401).json({ message: 'Invalid signature' });
}
try {
const { slug } = req.body;
await res.revalidate(`/posts/${slug}`);
return res.json({ revalidated: true, slug });
} catch (err) {
console.error('[revalidate]', err);
return res.status(500).json({ message: 'Error revalidating' });
}
}
Two security checks run before any revalidation: a constant-time secret comparison (using crypto.timingSafeEqual to prevent timing attacks) and an HMAC signature check. Both must pass. A failed check returns a 401 without revealing which check failed.
Add your shared secret to .env.local on the Next.js side:
# .env.local (Next.js)
NEXTJS_REVALIDATION_SECRET=your-long-random-secret-here
# On WordPress: set as server environment variable (not hardcoded in functions.php)
# cPanel → Software → PHP Config → Environment Variables, or:
putenv('NEXTJS_REVALIDATION_SECRET=your-long-random-secret-here');
Step 2b — Next.js App Router (Next.js 13+): The Route Handler Version
The App Router uses Route Handlers instead of API routes. The file becomes app/api/revalidate/route.js and res.revalidate() is replaced with revalidatePath imported from next/cache.
// app/api/revalidate/route.js (Next.js 13+ App Router)
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import crypto from 'crypto';
export async function POST(request) {
const secret = process.env.NEXTJS_REVALIDATION_SECRET;
const tokenSent = request.headers.get('x-reval-secret') ?? '';
if (!crypto.timingSafeEqual(Buffer.from(tokenSent), Buffer.from(secret))) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
// Read body as text FIRST - calling request.json() would consume the stream
const body = await request.text();
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
const received = request.headers.get('x-wp-signature') ?? '';
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return NextResponse.json({ message: 'Invalid signature' }, { status: 401 });
}
const { slug } = JSON.parse(body);
revalidatePath(`/posts/${slug}`);
return NextResponse.json({ revalidated: true, slug });
}
The critical difference from the Pages Router version: the App Router’s request object is a standard Web API Request, and its body is a readable stream that can only be consumed once. Call request.text() to read it as a string first — use that string for HMAC verification, then call JSON.parse(body) to get the slug. If you call request.json() first, the stream is consumed and HMAC verification fails every time.
For sites already tuned for Core Web Vitals, the Next.js headless performance guide covers how on-demand revalidation integrates with your full caching strategy without introducing LCP regressions.
Security: Why HMAC Matters More Than a Secret Token Alone
A secret token in a header proves the requester knows the secret — but it doesn’t prove the payload body wasn’t tampered with in transit. HMAC-SHA256 signs the body with the secret, so Next.js can verify both that the request came from your WordPress server and that the payload arrived intact.
Three security layers combined:
- Constant-time comparison —
crypto.timingSafeEqualprevents timing attacks where an attacker guesses the secret one character at a time by measuring response time differences - HMAC signature — signs the JSON body; a modified payload produces a different hash that won’t match, making payload injection impossible
- Environment variables — the secret never appears in source code or git history on either the WordPress or Next.js side
IP whitelisting (only accepting requests from your WordPress server’s IP) is an optional fourth layer. It’s worth adding to your Vercel Edge Config or Next.js middleware if your WordPress host has a stable IP — a determined attacker with the HMAC secret still can’t spoof the source IP.
The JWT authentication guide covers a related security pattern — securing the data-fetching layer with signed tokens — if you want consistent auth patterns across your whole headless stack rather than bespoke secrets per endpoint.
Testing Locally with ngrok
WordPress running on your production server can’t reach localhost:3000 on your laptop. ngrok solves this by creating a public HTTPS tunnel to your local Next.js dev server, so you can test the full webhook pipeline end-to-end before deploying.
Install ngrok, start your Next.js dev server, then run:
ngrok http 3000
ngrok prints a forwarding URL like https://abc123.ngrok.io. Temporarily replace the $frontend_url in functions.php with that URL, save a test post in WordPress, then open the ngrok inspector at http://localhost:4040 — it shows the raw HTTP request and response, including all headers and the full body, making it straightforward to debug HMAC mismatches or wrong payload shapes.
Once the inspector shows a 200 response with "revalidated": true, swap the URL back to your production Vercel endpoint and deploy. The CI/CD pipeline guide covers how to wire environment secrets into your deployment pipeline so NEXTJS_REVALIDATION_SECRET is set correctly in production without manual steps.
Error Handling and Retry Logic
The wp_remote_post call uses a 5-second timeout. If the Next.js endpoint is slow or unreachable, WordPress doesn’t wait — the post save completes normally and the failed request is dropped. That’s intentional: a slow frontend should never block a content editor from saving their work.
For production reliability, add retry logic on the WordPress side:
- On a non-200 response, schedule a retry with
wp_schedule_single_event()using a 30-second delay - Cap at 3 retry attempts to avoid cascading load if the frontend is down for an extended period
- Log failures to
wp-content/debug.log(requiresWP_DEBUG_LOGenabled) so you can identify which pages might be showing stale content after a frontend outage
On the Next.js side, revalidatePath is synchronous within the route handler — it doesn’t return until the cache entry is invalidated. Wrap it in a try/catch and return a 500 on failure so WordPress knows a retry is needed. Any unhandled error that returns a 200 will prevent WordPress from retrying, leaving the cache stale with no log entry.
Implementation Checklist
- Generate the shared secret:
openssl rand -hex 32 - Store it as
NEXTJS_REVALIDATION_SECRETin both your WordPress server environment and your Next.js.env.local(or Vercel environment variables) - Add the
save_posthook tofunctions.phpwith HMAC-SHA256 signing - Create the revalidation endpoint:
pages/api/revalidate.js(Pages Router) orapp/api/revalidate/route.js(App Router) - Use
crypto.timingSafeEqualfor all secret comparisons — not string equality - In the App Router version: read the body with
request.text()before any JSON parsing - Test locally with ngrok — confirm a 200 response in the ngrok inspector at
localhost:4040 - Deploy to staging; save a post in WordPress and confirm the Next.js staging page updates within 5 seconds
- Add
wp_schedule_single_event()retry logic for non-200 responses - Enable
WP_DEBUG_LOGand monitorwp-content/debug.logfor failed webhook deliveries in the first week after launch
Once webhooks are in place, the headless stack handles the complete content lifecycle: WordPress as the authoring environment, the REST API or GraphQL delivering structured content to Next.js, and webhooks keeping the frontend cache in sync within seconds of a publish. The headless WordPress pros, cons and use cases guide covers where this architecture makes sense versus a traditional coupled setup — useful context if you’re still evaluating the approach. For the SEO implications of running headless, the headless WordPress SEO guide covers the five gaps that need closing to avoid ranking problems specific to decoupled setups.


