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:

  • revalidateTag takes 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, because updateTag only 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 when cacheLife runs out.
  • The secret in browser code. The revalidation secret lives only in wp-config.php and the Next.js server environment, never in a NEXT_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.