Headless WordPress + Next.js 16: show new posts the moment an editor clicks Publish
A headless WordPress site has one annoying failure: an editor clicks Publish, opens the live site, and the post isn't there. The Next.js front end is serving a cached page, and nothing told it that WordPress changed. Th
A headless WordPress site has one annoying failure: an editor clicks Publish, opens the live site, and the post isn't there. The Next.js front end is serving a cached page, and nothing told it that WordPress changed.
The fix takes three small pieces: tag the cached data, add a route that refreshes the tag, and have WordPress call that route. Here is the whole setup for Next.js 16 with the WordPress REST API.
1. Cache WordPress requests with a tag
New App Router apps in Next.js 16 have Cache Components on (cacheComponents: true), which gives you the 'use cache' directive. Keep every WordPress request in one file:
// lib/wordpress.ts
import { cacheLife, cacheTag } from 'next/cache'
const WP = process.env.WORDPRESS_URL
async function wp<T>(path: string): Promise<T> {
const res = await fetch(`${WP}/wp-json/wp/v2/${path}`)
if (!res.ok) throw new Error(`WordPress returned ${res.status} for ${path}`)
return res.json()
}
export async function getPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
return wp<{ slug: string; title: { rendered: string } }[]>(
'posts?per_page=20&_fields=id,slug,date,title,excerpt'
)
}
export async function getPost(slug: string) {
'use cache'
cacheLife('hours')
cacheTag('posts')
const posts = await wp<any[]>(
`posts?slug=${encodeURIComponent(slug)}&_fields=id,slug,date,title,content`
)
return posts[0]
}
cacheTag('posts') is the important line. It labels every cached result, so one call can mark all of them stale later. _fields trims the REST response to what the page uses. The API returns 10 posts by default, and per_page goes up to 100.
2. A route that refreshes the tag
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
return Response.json({ revalidated: false }, { status: 401 })
}
revalidateTag('posts', 'max')
return Response.json({ revalidated: true })
}
Two things changed in Next.js 16 that older tutorials miss:
-
revalidateTagtakes a second argument now. The one-argument form is deprecated.'max'marks the data stale, and the next visitor gets the cached page while a fresh one builds in the background (stale-while-revalidate). -
If the old version must never be shown again, for example after unpublishing a post by mistake, pass
{ expire: 0 }instead. The next request then waits for fresh data. The docs recommend this form when the call comes from a webhook, becauseupdateTagonly works inside Server Actions.
Pages revalidate as they're visited, not all at once, so a big site doesn't rebuild everything on each publish.
3. Let WordPress call the route
Drop a must-use plugin into wp-content/mu-plugins/. Define NEXT_REVALIDATE_SECRET in wp-config.php with the same value as REVALIDATE_SECRET in Next.js:
<?php
add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
if ( 'post' !== $post->post_type || ( 'publish' !== $new_status && 'publish' !== $old_status ) ) {
return;
}
wp_remote_post( 'https://www.example.com/api/revalidate', array(
'headers' => array( 'x-revalidate-secret' => NEXT_REVALIDATE_SECRET ),
'timeout' => 5,
'blocking' => false,
) );
}, 10, 3 );
transition_post_status fires on publish, update and unpublish, so the check lets through any change that touches a published post. 'blocking' => false means the editor never waits for Next.js to answer.
Mistakes I see most
-
Leaving out the tag. Without
cacheTag, there's nothing for the webhook to refresh, so pages update only whencacheLiferuns out. -
The secret in browser code. The revalidation secret lives only in
wp-config.phpand the Next.js server environment, never in aNEXT_PUBLIC_variable. - Calling the route from a plugin on every save. Autosaves and revisions also save posts. The status check above skips drafts that were never published.
-
Forgetting other post types. Pages, products or custom post types need their own tags (
cacheTag('pages')) and their own branch in the PHP check.
The rest of the setup
Caching is one part of a headless build. The full guide covers choosing between the REST API and WPGraphQL, contact forms (Contact Form 7's REST endpoint only takes multipart/form-data and needs a _wpcf7_unit_tag), SEO tags from Yoast's REST fields, previews and hosting: Headless WordPress with Next.js: the 2026 guide.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.