Incremental Static Regeneration in Next.js is a compelling answer to a real problem: you want near-static performance for your content site, but you also need updates to appear in seconds, not after a full rebuild. Tag-based revalidation — introduced with the App Router — makes this more surgical. Instead of busting every cached page when anything changes, you tag specific cache entries and revalidate only what was touched.
We run this exact setup in production: headless WordPress with WPGraphQL, Next.js 14 on the App Router, deployed to Vercel. The architecture is clean. The failure modes are not. Here’s what we’ve learned after shipping it and keeping it honest.
How It Works
In the App Router, you pass a next option to your fetch calls. In our case, that happens inside each GraphQL query module:
const res = await fetch(GRAPHQL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
next: {
revalidate: 3600,
tags: ['projects'],
},
});
On the WordPress side, a plugin fires a POST to your revalidation endpoint every time a post is saved. On the Next.js side, that endpoint calls revalidateTag:
// src/app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
const { secret, tag } = await req.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
revalidateTag(tag);
return Response.json({ revalidated: true, tag });
}
WordPress saves a post, fires the webhook, Vercel purges every cached response carrying that tag, and the next request gets a fresh server render. Clean, composable, fast — when it works.
The Silent Failures
This is the part that doesn’t make it into the documentation.
1. Secret drift goes undetected for months
The WordPress plugin has a hardcoded $secret in PHP. Vercel has REVALIDATION_SECRET in its environment. These two values must match exactly. If they drift — someone rotates the Vercel secret, or the plugin was deployed with a placeholder value — every webhook call returns a 401. ISR silently falls back to the 3600-second TTL and keeps serving stale content on that schedule.
Nothing breaks loudly. Pages still render. Content just shows up an hour late, every time. You might not notice for weeks unless you’re actively monitoring webhook response codes from the WordPress side.
Fix: Log every outbound webhook call with its HTTP response status in the WordPress plugin. Add an alert for sustained 401s from that endpoint. Verify the secret chain explicitly any time you rotate Vercel environment variables — pull the new value and update the plugin before the next editor makes a change.
2. WP-CLI meta updates don’t fire save_post
If you make content changes via WP-CLI — common for bulk updates, scripted migrations, or setting ACF field values from the command line — wp post meta update writes to the database but does not fire the save_post hook your revalidation webhook depends on. ACF fields are post meta, so this trap is easy to fall into.
# This updates the DB but does NOT trigger revalidation:
wp post meta update 42 project_order 3
# Follow with a no-op post update to fire save_post:
wp post update 42 --post_modified="$(date +'%Y-%m-%d %H:%M:%S')"
# Or bust the cache directly:
curl -sS -X POST https://yoursite.com/api/revalidate \
-H 'Content-Type: application/json' \
-d '{"secret":"'"$REVALIDATION_SECRET"'","tag":"projects"}'
The direct curl approach is actually preferable for scripted workflows — it’s explicit, testable, and doesn’t depend on WordPress hook timing.
3. ACF number fields return null for zero
This is a WPGraphQL behavior rather than a pure ISR issue, but it lives in the same data pipeline. When an ACF number field is set to 0, WPGraphQL returns null — not 0. Any sort or filter logic that treats the returned value as a number will misbehave in ways that are genuinely hard to trace.
For ordered lists — a featured-project ranking, a display sequence — start numbering at 1, not 0. Document this constraint inline so the next engineer doesn’t spend an afternoon debugging why a project mysteriously moved to the top of the grid.
Tag Design: More Granular Than You’d Expect
A single posts tag works fine for a simple blog. A site with multiple custom post types needs a deliberate tagging strategy. Ours looks like this:
menu— navigation structure (header + footer nav)settings— sitewide ACF options (footer copy, global toggles)services— service custom post typeprojects— project custom post typepage-{slug}— individual page content (e.g.page-about,page-contact)
The WordPress plugin maps each post type and options page to the appropriate tag before firing the webhook. Editing a project flushes only project pages — not the homepage, not the services section, not the contact page. At low traffic this barely matters. At scale it keeps your origin request rate from spiking every time an editor saves anything.
Verifying the Pipeline End-to-End
After any significant change to the revalidation chain, verify the full path explicitly. Don’t assume it works because pages are rendering:
- Update a post in WordPress and save it.
- Check the plugin’s outbound webhook log for a 200 from Vercel.
- Visit the affected page and hard-refresh (bypassing browser cache).
- Confirm the updated content appears immediately — not after 3600 seconds.
If step 3 requires waiting, step 2 either didn’t fire or returned a non-200. Trace it from there — it’s almost always the secret mismatch or a missing save_post trigger.
You can also probe the revalidation endpoint directly with curl, entirely decoupled from WordPress. This makes it easy to test tag granularity in isolation and confirm Vercel is receiving and acting on the request during local development.
Handling the Nuclear Option
Sometimes you need to bust everything — after a large data migration, a structural ACF change, or a cached response that got into a bad state. Support this via an omitted or wildcard tag:
// In the revalidate route handler:
if (!tag) {
['menu', 'settings', 'services', 'projects'].forEach(revalidateTag);
return Response.json({ revalidated: true, tag: 'all' });
}
One important caveat: don’t expose this path without authentication, and be deliberate about when you invoke it. Under concurrent traffic, busting every cached entry simultaneously means every incoming request hits the origin until the cache warms back up. That’s usually fine; it just needs to be intentional.
The Takeaway
Tag-based ISR is the right model for headless WordPress. It’s composable, efficient, and makes content updates feel instant for editors without sacrificing the performance benefits of static rendering. The failure modes are almost entirely operational rather than architectural: secrets that drift without alerting, CLI commands that bypass WordPress hooks, and ACF edge cases in the GraphQL layer.
Build logging into your revalidation webhook from day one. Verify the secret chain after every env var rotation. Document the WP-CLI workflow so the save_post trigger is never an afterthought. Ship those three habits alongside the initial ISR implementation and the system will hold.



