
A headless WordPress site should not have to choose between fresh content and fast pages. Next.js Incremental Static Regeneration (ISR) gives you a useful middle ground: serve a cached page immediately, rebuild it in the background when it becomes stale, and invalidate it deliberately when an editor publishes an important change.
The difficult part is not adding revalidate to a fetch call. It is designing a publishing path that is predictable when posts, media, authors, categories, and URLs change. This guide lays out that path for the current Next.js App Router and the WordPress REST API.
Start by separating public content from request-specific data. Published posts, category archives, author pages, and shared media are usually good cache candidates. Draft previews, account pages, carts, and any response affected by cookies or authorization must stay dynamic.
A practical content flow looks like this:
This gives you fast responses during normal traffic while keeping publishing latency under editorial control.
Put WordPress access in one server-only module. A shared function prevents different routes from quietly adopting conflicting cache rules.
// lib/wordpress.ts
const WP_API = process.env.WORDPRESS_API_URL
if (!WP_API) throw new Error('WORDPRESS_API_URL is required')
export async function getPosts() {
const response = await fetch(
`${WP_API}/wp-json/wp/v2/posts?status=publish&_embed=1`,
{
next: {
revalidate: 3600,
tags: ['wp-posts'],
},
}
)
if (!response.ok) {
throw new Error(`WordPress returned ${response.status}`)
}
return response.json()
}
export async function getPostBySlug(slug: string) {
const response = await fetch(
`${WP_API}/wp-json/wp/v2/posts?slug=${encodeURIComponent(slug)}&status=publish&_embed=1`,
{
next: {
revalidate: 3600,
tags: ['wp-posts', `wp-post-${slug}`],
},
}
)
if (!response.ok) {
throw new Error(`WordPress returned ${response.status}`)
}
const [post] = await response.json()
return post ?? null
}The one-hour interval is a fallback, not a promise that every page will rebuild exactly on the hour. A stale route is regenerated when it is visited. The existing cached response can be served while the new version is prepared. Adjust the interval to your tolerance for stale content and the cost of rebuilding, rather than copying a universal number.
Keep tags intentional. A broad wp-posts tag is useful when an archive depends on many posts. A post-specific tag limits invalidation for individual articles. Add category or author tags only where those relationships actually affect rendered output.
The route components can remain simple because the caching policy lives with the data request.
// app/blog/page.tsx
import { getPosts } from '@/lib/wordpress'
export default async function BlogPage() {
const posts = await getPosts()
return (
<main>
<h1>Blog</h1>
{posts.map((post: { id: number; slug: string; title: { rendered: string } }) => (
<article key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title.rendered}</a>
</article>
))}
</main>
)
}WordPress fields such as title.rendered and content.rendered may contain HTML. Treat that HTML as untrusted at your application boundary. Limit who can publish, keep WordPress and its extensions patched, and sanitize content if contributors or integrations cannot be fully trusted. Do not pass private REST responses into public route caches.
Time-based ISR protects you if the webhook is delayed, but editors usually expect a publish action to appear quickly. Add a Route Handler that accepts only a small, validated payload and verifies a signature before invalidating anything.
// app/api/revalidate/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
const secret = process.env.WORDPRESS_REVALIDATION_SECRET
function validSignature(body: string, supplied: string) {
if (!secret) return false
const expected = createHmac('sha256', secret).update(body).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(supplied)
return a.length === b.length && timingSafeEqual(a, b)
}
export async function POST(request: NextRequest) {
const body = await request.text()
const signature = request.headers.get('x-wp-signature') ?? ''
if (!validSignature(body, signature)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let payload: { slug?: unknown; previousSlug?: unknown }
try {
payload = JSON.parse(body)
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
if (typeof payload.slug !== 'string' || !/^[a-z0-9-]+$/.test(payload.slug)) {
return NextResponse.json({ error: 'Invalid slug' }, { status: 400 })
}
revalidateTag('wp-posts', 'max')
revalidateTag(`wp-post-${payload.slug}`, 'max')
revalidatePath('/blog')
revalidatePath(`/blog/${payload.slug}`)
if (typeof payload.previousSlug === 'string' && /^[a-z0-9-]+$/.test(payload.previousSlug)) {
revalidatePath(`/blog/${payload.previousSlug}`)
}
return NextResponse.json({ revalidated: true })
}The two-argument revalidateTag(tag, 'max') form uses stale-while-revalidate behavior and is the recommended current API. Calling revalidatePath from a Route Handler marks that path for regeneration on its next visit; it does not eagerly render every invalidated page. That behavior helps avoid a sudden rebuild storm after a broad editorial update.
Store the shared secret in server-side environment variables on both systems. Never place it in client JavaScript or in the webhook URL. In production, also reject old requests using a signed timestamp and keep a short-lived record of delivery IDs if replay prevention matters.
Trigger the delivery only after a meaningful state transition or saved update. The transition_post_status hook is suitable when publication state matters; rest_after_insert_post can cover editor saves through the REST API. Guard against autosaves, revisions, irrelevant post types, and updates that do not affect the headless site.
<?php
add_action('transition_post_status', function ($new, $old, $post) {
if ($post->post_type !== 'post' || $new !== 'publish') {
return;
}
$body = wp_json_encode([
'slug' => $post->post_name,
]);
$secret = getenv('NEXT_REVALIDATION_SECRET');
if (!$secret) {
return;
}
wp_remote_post(getenv('NEXT_REVALIDATION_URL'), [
'timeout' => 5,
'headers' => [
'Content-Type' => 'application/json',
'X-WP-Signature' => hash_hmac('sha256', $body, $secret),
],
'body' => $body,
]);
}, 10, 3);For a small site, a direct request may be adequate. For an editorial or commerce workload, enqueue the delivery and retry transient failures with bounded backoff. Record the post ID, event type, response status, and delivery time without logging the secret. That turns a vague “the page is stale” report into a traceable publishing event.
notFound() when the public API returns no post.A reliable ISR setup needs an end-to-end check, not just a successful build:
Monitor webhook failures, cache hit behavior, WordPress API latency, regeneration errors, and the delay from publish to visible page. Those measurements reveal whether the bottleneck is WordPress, the network, cache invalidation, or rendering.
Use time-based ISR as a recovery mechanism and authenticated on-demand revalidation as the normal publishing path. Tag data by the content relationships your pages actually use, keep preview traffic outside the public cache, and invalidate both paths and shared data when a change crosses route boundaries.
For current implementation details, refer to the official Next.js ISR guide, revalidateTag reference, revalidatePath reference, and the official WordPress REST API handbook.

Aditya Bhimrajka is a technology entrepreneur, product strategist, and software solutions expert with over a decade of experience building scalable web and mobile applications. His expertise spans SaaS, AI, cloud technologies, custom software development, and digital transformation. Passionate about solving real-world business challenges through technology, Aditya shares practical insights on WordPress, plugins, software development, startup growth, product strategy, and emerging technologies. At WPStack, he writes actionable, experience-driven content that helps developers, businesses, and website owners build secure, high-performing, and future-ready WordPress solutions.
Post a Comment