---
title: Headless WordPress with Next.js: Incremental Static Regeneration (ISR)
description: Architect an enterprise headless WordPress setup with Next.js App Router. Implement on-demand ISR webhooks, secure HMAC validation, and sub-100ms TTFB.
url: https://wpstack.online/2026/09/17/headless-wordpress-nextjs-revalidation
date_modified: 2026-09-12
author: Aditya Bhimrajka
language: en_US
---

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.

## Choose the cache boundary before writing code

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:

1. WordPress remains the editorial source of truth.
2. Next.js fetches only public fields from the WordPress REST API.
3. Public routes use a time-based revalidation window as a safety net.
4. WordPress sends an authenticated webhook after a relevant change.
5. The webhook invalidates the affected page paths and cache tags.

This gives you fast responses during normal traffic while keeping publishing latency under editorial control.

## Fetch WordPress content with explicit cache tags

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.

## Render the archive and article routes

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.

## Add authenticated on-demand revalidation

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.

## Send the webhook from WordPress safely

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.

## Handle the cases that usually break first

- **Slug changes:** invalidate both the old and new paths, then create the intended redirect in WordPress or your frontend.
- **Unpublishing:** invalidate the article and every archive that referenced it. The regenerated route should return `notFound()` when the public API returns no post.
- **Taxonomy changes:** refresh affected category, tag, sitemap, and related-content views.
- **Media replacement:** use versioned media URLs or invalidate pages that embed the replaced attachment. A CDN may otherwise continue serving the old bytes.
- **Previews:** create a separately authenticated preview route that bypasses the public cache. Never cache draft responses under a public URL.
- **Revalidation storms:** coalesce repeated events, invalidate narrowly, and avoid rebuilding thousands of routes synchronously.

## Test the whole publishing contract

A reliable ISR setup needs an end-to-end check, not just a successful build:

1. Publish a test post and confirm the webhook receives a successful response.
2. Request the article and archive, then verify the new content is visible.
3. Change the title, slug, featured image, category, and author one at a time.
4. Confirm the old slug redirects or returns the intended status.
5. Unpublish the test post and confirm it disappears from every public surface.
6. Send a bad signature and malformed payload; both must be rejected without invalidation.
7. Temporarily block the webhook and confirm time-based revalidation eventually recovers.

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.

## A dependable default

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](https://nextjs.org/docs/app/guides/incremental-static-regeneration), [revalidateTag reference](https://nextjs.org/docs/app/api-reference/functions/revalidateTag), [revalidatePath reference](https://nextjs.org/docs/app/api-reference/functions/revalidatePath), and the official [WordPress REST API handbook](https://developer.wordpress.org/rest-api/).
