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.



