Skip to content
All posts

13 October 20269 min read

Headless WordPress with Next.js App Router

A headless WordPress and Next.js 16 setup that holds up in production: WPGraphQL or the REST API, tagged caching, revalidation from a WordPress webhook, Draft Mode previews, remote images and SEO metadata.

Headless WordPress with Next.js App Router

Editors often want to keep the WordPress panel they know, while the site needs a faster and more flexible front end than a theme allows. A headless setup gives each side what it needs: WordPress for writing, Next.js for rendering. Below is how I put together a headless WordPress with Next.js App Router setup that holds up in production, from choosing between WPGraphQL and the REST API to caching with ISR, webhook revalidation, Draft Mode previews, remote images and SEO metadata.

The code targets Next.js 16. Several APIs changed recently and many tutorials still show the old versions, so I'll flag those as we go.

Why use headless WordPress with Next.js App Router?

With a headless setup, WordPress only serves data, and Next.js decides when to render and how long to cache. Pages are served from cache without a full rebuild for every typo fix, and the public site doesn't expose wp-admin at all.

The price: theme plugins (sliders, page builders, script-injecting shortcodes) won't render, and forms, comments and search need API-based replacements. Sites built on a visual page builder should probably stay classic.

WPGraphQL vs REST API: which one should you use?

WordPress ships with the REST API (/wp-json/wp/v2/...). WPGraphQL is a free plugin that adds a /graphql endpoint.

REST API

WPGraphQL

Setup

Built in

Install a plugin

Payload

Fixed shape, often large

You ask for exactly the fields you need

Related data

_embed or extra requests

Nested in one query

Plugin ecosystem

Most plugins register REST routes

Extensions for ACF, Yoast, Rank Math and others

Caching at the HTTP layer

Easy (GET requests)

Usually POST, so you rely on the Next.js data cache

Rule of thumb: for posts, pages and featured images, REST is fine and saves a dependency. For custom fields, menus or SEO data in one round trip, WPGraphQL pays for itself. The rest of this guide uses WPGraphQL, but the caching and revalidation parts work the same way with REST (/wp-json/wp/v2/posts?slug=hello-world&_embed).

Setting up the Next.js data layer for WordPress

Create the project with npx create-next-app@latest, pick TypeScript and the App Router, then add environment variables:

bash
# .env.local
WP_GRAPHQL_URL=https://cms.example.com/graphql
WP_APP_USER=next-preview
WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx"
REVALIDATE_SECRET=change-me-long-random-string
PREVIEW_SECRET=another-long-random-string

The WP_APP_* pair is a WordPress Application Password (Users → Profile) for a dedicated Editor-role user, used only for previews. Next, a typed fetch helper that tags every published query so you can invalidate it precisely:

TypeScript
// lib/wp.ts
type GraphQLResponse<T> = { data?: T; errors?: { message: string }[] }

type QueryOptions = {
  tags?: string[]
  preview?: boolean
}

export async function wpQuery<T>(
  query: string,
  variables: Record<string, unknown> = {},
  { tags = [], preview = false }: QueryOptions = {}
): Promise<T> {
  const headers: Record<string, string> = { 'Content-Type': 'application/json' }

  if (preview) {
    const auth = Buffer.from(
      `${process.env.WP_APP_USER}:${process.env.WP_APP_PASSWORD}`
    ).toString('base64')
    headers.Authorization = `Basic ${auth}`
  }

  const res = await fetch(process.env.WP_GRAPHQL_URL!, {
    method: 'POST',
    headers,
    body: JSON.stringify({ query, variables }),
    ...(preview
      ? { cache: 'no-store' as const }
      : { next: { revalidate: 3600, tags: ['wordpress', ...tags] } }),
  })

  if (!res.ok) throw new Error(`WPGraphQL HTTP ${res.status}`)
  const json = (await res.json()) as GraphQLResponse<T>
  if (json.errors?.length) throw new Error(json.errors[0].message)
  return json.data as T
}

export type WPPost = {
  databaseId: number
  slug: string
  title: string
  excerpt: string
  content: string
  date: string
  modified: string
  featuredImage: {
    node: {
      sourceUrl: string
      altText: string
      mediaDetails: { width: number; height: number }
    }
  } | null
}

const POST_FIELDS = `
  databaseId slug title excerpt content date modified
  featuredImage { node { sourceUrl altText mediaDetails { width height } } }
`

export async function getPostBySlug(slug: string): Promise<WPPost | null> {
  const data = await wpQuery<{ post: WPPost | null }>(
    `query PostBySlug($slug: ID!) { post(id: $slug, idType: SLUG) { ${POST_FIELDS} } }`,
    { slug },
    { tags: [`post:${slug}`] }
  )
  return data.post
}

export async function getPreviewPost(id: number): Promise<WPPost | null> {
  const data = await wpQuery<{ post: WPPost | null }>(
    `query Preview($id: ID!) {
      post(id: $id, idType: DATABASE_ID, asPreview: true) { ${POST_FIELDS} }
    }`,
    { id },
    { preview: true }
  )
  return data.post
}

// WPGraphQL returns rendered titles with HTML entities (&#8217;, &amp;).
// Metadata needs plain text, or you get double-escaped titles.
const ENTITIES: Record<string, string> = { amp: '&', quot: '"', lt: '<', gt: '>', nbsp: ' ', hellip: '…' }

export function toPlainText(html: string): string {
  return html
    .replace(/<[^>]+>/g, '')
    .replace(/&#(\d+);/g, (_, n: string) => String.fromCharCode(Number(n)))
    .replace(/&(\w+);/g, (m, name: string) => ENTITIES[name] ?? m)
    .trim()
}

export async function getAllSlugs(): Promise<string[]> {
  const data = await wpQuery<{ posts: { nodes: { slug: string }[] } }>(
    `query Slugs { posts(first: 100) { nodes { slug } } }`,
    {},
    { tags: ['posts'] }
  )
  return data.posts.nodes.map((p) => p.slug)
}

WPGraphQL caps first at 100; paginate with pageInfo on bigger sites.

Rendering WordPress posts with dynamic routes

In current Next.js, params is a Promise; the synchronous destructuring in older tutorials no longer type-checks:

TSX
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import { getAllSlugs, getPostBySlug } from '@/lib/wp'

type Props = { params: Promise<{ slug: string }> }

export const revalidate = 3600

export async function generateStaticParams() {
  const slugs = await getAllSlugs()
  return slugs.map((slug) => ({ slug }))
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params
  const post = await getPostBySlug(slug)
  if (!post) notFound()

  const img = post.featuredImage?.node

  return (
    <article>
      <h1 dangerouslySetInnerHTML={{ __html: post.title }} />
      {img && (
        <Image
          src={img.sourceUrl}
          alt={img.altText}
          width={img.mediaDetails.width}
          height={img.mediaDetails.height}
          priority
        />
      )}
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

On single sites, administrators and editors can save raw HTML, so if you don't fully trust everyone with those roles, sanitize content on the server (for example with sanitize-html) before rendering it.

ISR and on-demand revalidation with a WordPress webhook

revalidate = 3600 gives you time-based ISR: cached pages regenerate in the background at most once an hour. That's a safety net, but editors expect changes to appear right after clicking Update, which calls for on-demand revalidation.

The Next.js revalidation route handler

TypeScript
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { timingSafeEqual } from 'node:crypto'

function safeEqual(a: string, b: string) {
  const ab = Buffer.from(a)
  const bb = Buffer.from(b)
  return ab.length === bb.length && timingSafeEqual(ab, bb)
}

export async function POST(request: Request) {
  const secret = request.headers.get('x-revalidate-secret') ?? ''
  if (!safeEqual(secret, process.env.REVALIDATE_SECRET ?? '')) {
    return Response.json({ ok: false }, { status: 401 })
  }

  const { slug } = (await request.json().catch(() => ({}))) as { slug?: string }

  if (slug) revalidateTag(`post:${slug}`, { expire: 0 })
  revalidateTag('posts', 'max')

  return Response.json({ ok: true, slug: slug ?? null })
}

Heads-up: single-argument revalidateTag(tag) is deprecated. The second argument sets how long stale content may be served: 'max' means stale-while-revalidate (fine for lists), while { expire: 0 } makes the next request wait for fresh data, which the docs recommend for webhooks. To invalidate by URL instead, use revalidatePath('/blog/[slug]', 'page').

The WordPress side: a tiny mu-plugin

Drop this into wp-content/mu-plugins/next-revalidate.php and define the two constants in wp-config.php:

PHP
<?php
/**
 * Plugin Name: Next.js revalidation webhook
 */

add_action('wp_after_insert_post', function (int $post_id, WP_Post $post, bool $update, ?WP_Post $post_before): void {
    if ($post->post_type !== 'post' || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
        return;
    }
    // Only care about content that is, or was, public.
    $was_public = $post_before instanceof WP_Post && $post_before->post_status === 'publish';
    if ($post->post_status !== 'publish' && !$was_public) {
        return;
    }

    wp_remote_post(NEXT_REVALIDATE_URL, [
        'headers'  => [
            'Content-Type'        => 'application/json',
            'X-Revalidate-Secret' => NEXT_REVALIDATE_SECRET,
        ],
        'body'     => wp_json_encode(['slug' => $post->post_name]),
        'timeout'  => 5,
        'blocking' => false,
    ]);
}, 10, 4);

add_filter('preview_post_link', function (string $link, WP_Post $post): string {
    return add_query_arg(
        ['secret' => NEXT_PREVIEW_SECRET, 'id' => $post->ID],
        NEXT_PREVIEW_URL
    );
}, 10, 2);

I hook into wp_after_insert_post (WordPress 5.6+) rather than transition_post_status, because it fires after post meta and terms are saved, so the webhook can't overtake the data. 'blocking' => false keeps the editor fast but hides errors. Switch it to true and log the response while you're setting things up.

Draft Mode previews for WordPress editors

The preview_post_link filter sends WordPress's Preview button to your app. Draft Mode sets a bypass cookie so only that browser skips the cache. Note that draftMode() is now async:

TypeScript
// app/api/draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPreviewPost } from '@/lib/wp'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const id = Number(searchParams.get('id'))

  if (searchParams.get('secret') !== process.env.PREVIEW_SECRET || !id) {
    return new Response('Invalid preview request', { status: 401 })
  }

  const post = await getPreviewPost(id)
  if (!post) return new Response('Post not found', { status: 404 })

  const draft = await draftMode()
  draft.enable()
  redirect(`/preview/${post.databaseId}`)
}

The preview page queries by database ID with asPreview: true, since drafts may lack a final slug:

TSX
// app/preview/[id]/page.tsx
import { draftMode } from 'next/headers'
import { notFound } from 'next/navigation'
import { getPreviewPost } from '@/lib/wp'

export default async function PreviewPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { isEnabled } = await draftMode()
  if (!isEnabled) notFound()

  const { id } = await params
  const post = await getPreviewPost(Number(id))
  if (!post) notFound()

  return (
    <article>
      <p role="status">Draft preview</p>
      <h1 dangerouslySetInnerHTML={{ __html: post.title }} />
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

Application Passwords over Basic auth work for WPGraphQL requests, so the preview query can see drafts. Check it on your own install, though: some security plugins and Apache/CGI hosts strip the Authorization header. Serve WordPress over HTTPS only.

Configuring next/image remotePatterns for WordPress media

next/image refuses remote URLs you haven't allow-listed. Keep the pattern as narrow as you can:

TypeScript
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cms.example.com',
        port: '',
        pathname: '/wp-content/uploads/**',
        search: '',
      },
    ],
  },
}

export default nextConfig

Leaving out protocol, port, pathname or search implies a wildcard, which the docs warn against. Also, Next.js 16 blocks optimizing images from local IP addresses by default, so a WordPress dev box at 192.168.x.x needs a hostname (or dangerouslyAllowLocalIP, in development only).

SEO metadata with generateMetadata

Add this to the post page for per-article titles, descriptions, canonical URLs and Open Graph images (memoized fetch means no second WordPress query). Pass titles through toPlainText, or WPGraphQL's entities end up double-escaped as &amp;amp; in <title>:

TSX
// app/blog/[slug]/page.tsx (continued)
import type { Metadata } from 'next'
import { toPlainText } from '@/lib/wp'

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPostBySlug(slug)
  if (!post) return { title: 'Not found', robots: { index: false } }

  const title = toPlainText(post.title)
  const description = toPlainText(post.excerpt).slice(0, 155)
  const image = post.featuredImage?.node.sourceUrl

  return {
    title,
    description,
    alternates: { canonical: `/blog/${post.slug}` },
    openGraph: {
      type: 'article',
      title,
      description,
      publishedTime: post.date,
      modifiedTime: post.modified,
      images: image ? [image] : [],
    },
  }
}

Set metadataBase in your root layout so relative canonical URLs resolve to your public domain. Yoast and Rank Math have WPGraphQL extensions exposing a seo field you can map onto the same object.

If you're planning a project like this, I describe how I work on web apps and websites.

FAQ

Do I need WPGraphQL for headless WordPress with Next.js App Router?

No. The built-in REST API covers posts, pages and media. WPGraphQL pays off once you need custom fields, menus or SEO data in one request.

How do I update a Next.js page right after publishing in WordPress?

Send a webhook from WordPress to a route handler that calls revalidateTag or revalidatePath, and keep time-based ISR as a fallback.

Why does my old revalidateTag('posts') call show a deprecation warning?

Current Next.js expects a second argument: 'max' for stale-while-revalidate, or { expire: 0 } to expire data immediately from a webhook.

How do editors preview drafts in a headless setup?

Point WordPress's preview link at a Next.js route handler that checks a secret, calls (await draftMode()).enable(), and redirects to a page that fetches the draft with authentication.

Write