Headless WordPress with Next.js: A Practical Architecture Guide

Abstract technical illustration for the article: Headless WordPress with Next.js: A Practical Architecture Guide

WordPress dominates the web because non-developers can run it: install, open the editor, publish. But its classic request lifecycle — PHP rendering every page view, the theme layer welded to the content — is a poor fit for sub-second global load times, component-driven design systems, or content delivered to apps and kiosks as well as browsers.

Headless WordPress is the compromise that keeps the good part (the editorial experience) and replaces the rest. WordPress becomes a content API: editors still write in wp-admin, but pages are rendered by a separate frontend — most commonly Next.js — that fetches content over an API and serves it as static, cached HTML. This guide covers the patterns that make that setup work: the API layer, rendering and caching strategy, draft previews, and the traps that only show up in production.

The architecture at a glance

A typical production headless WordPress stack has four layers:

  • WordPress backend. The CMS lives on its own server or managed host. Editors log in here. Nothing public-facing touches its theme layer; it exists purely to author content and serve it over an API.
  • An API. WPGraphQL (a GraphQL API) or the built-in WP REST API exposes posts, pages, media, menus, and custom fields as structured data.
  • The Next.js frontend. Routes fetch from that API at build time or on request, render React components, and ship static HTML with client-side hydration.
  • A hosting layer for the frontend. Vercel, Netlify, or any Node-compatible platform serves the pages, usually behind a CDN.

The decoupling is the whole point. If your WordPress server goes down for maintenance, the frontend keeps serving its last generated pages from cache — visitors see no interruption. Teams split cleanly too: editors work in the familiar WordPress admin while developers iterate on the frontend in their own repository and deployment pipeline.

The API layer: WPGraphQL

WordPress ships with a REST API in core, but for a component-driven frontend, GraphQL has real advantages: one request returns exactly the fields a page needs. WPGraphQL is the established plugin here — a free, open-source plugin by Jason Bahl, now backed by Automattic as a Canonical Plugin, that exposes posts, pages, custom post types, taxonomies, menus, media, and users through a single /graphql endpoint.

Install it like any plugin (Plugins → Add New, search “WPGraphQL”), and it adds a GraphQL section to the admin sidebar with a built-in GraphiQL IDE for testing queries against your schema. A typical query for a post list looks like this:

query GetPosts {  posts(first: 10) {    nodes {      title      slug      excerpt    }  }}

WPGraphQL uses cursor-based pagination (up to 100 nodes per request), which matters once you enumerate hundreds of posts at build time. Its schema is extendable from PHP: functions like register_graphql_field expose custom post types and Advanced Custom Fields data to the frontend. For commerce, the community-maintained WPGraphQL for WooCommerce extension extends the same schema to products and carts.

How Next.js renders the data

The core question is when each page is generated. A headless WordPress site usually mixes these three:

  • Static Site Generation (SSG). Pages are fetched and rendered at build time into static HTML. Ideal for posts and pages that change rarely — fastest possible response, served from CDN.
  • Incremental Static Regeneration (ISR). Static pages that rebuild in the background on a timer, or on demand when content changes — the workhorse pattern for headless WordPress.
  • Server-Side Rendering (SSR). Rendered per request — reserve it for genuinely dynamic pages like search results.

With the App Router, fetching a post inside a route and giving it a revalidation window is a few lines:

export const revalidate = 3600; // rebuild at most once per hourasync function getPost(slug) {  const res = await fetch('https://cms.example.com/graphql', {    method: 'POST',    headers: { 'Content-Type': 'application/json' },    body: JSON.stringify({ query: `...` }),    next: { tags: ['posts'] },  });  return res.json();}

Dynamic routes pair with generateStaticParams, which queries all post slugs from WordPress at build time so each article gets its own static page. Slugs that appear later are generated on first request and then cached — a pattern that scales without rebuild times growing with content volume.

Keeping content fresh: cache invalidation

A one-hour revalidation window is fine for many blogs, but editors who just fixed a typo do not want to wait an hour. Next.js offers on-demand revalidation: a Route Handler that receives a signed request from WordPress and invalidates exactly the affected cache entries. Tag your fetches with next: { tags: ['posts'] } and invalidate by tag:

import { revalidateTag } from 'next/cache';export async function POST(request) {  const { secret, tag } = await request.json();  if (secret !== process.env.REVALIDATE_SECRET) {    return new Response('Invalid secret', { status: 401 });  }  revalidateTag(tag); // or revalidatePath('/blog/my-post')  return Response.json({ revalidated: true });}

On the WordPress side, a small hook on save_post fires a non-blocking POST to that handler whenever a post is published or updated. Because invalidation happens per tag or per path, the homepage, the post page, and the sitemap all refresh while everything else stays cached. If a revalidation fails, Next.js keeps serving the last good version and retries on the next request — it degrades gracefully instead of blanking the page.

Previewing drafts

Static pages are generated from published content, so an editor's draft is invisible to the frontend — unless you wire up previews. Next.js Draft Mode solves this: a cookie-gated request that bypasses every cache layer and fetches straight from WordPress, while all other visitors keep seeing the cached published page. WordPress gets a custom preview URL pointing at the frontend, for example /api/draft?secret=XXX&slug=/blog/my-draft; the Route Handler validates the shared secret, verifies the slug, enables Draft Mode, and redirects to the page:

import { draftMode } from 'next/headers';import { redirect } from 'next/navigation';export async function GET(request) {  const { searchParams } = new URL(request.url);  const secret = searchParams.get('secret');  const slug = searchParams.get('slug');  if (secret !== process.env.PREVIEW_SECRET || !slug) {    return new Response('Invalid token', { status: 401 });  }  const post = await getPostBySlug(slug);  if (!post) return new Response('Invalid slug', { status: 401 });  const draft = await draftMode();  draft.enable();  redirect(post.slug); // redirect from the fetched post, not searchParams}

Your page components barely change: the same fetch calls automatically skip the cache when the Draft Mode cookie is present. If WordPress exposes drafts through a separate endpoint, branch the fetch on isEnabled from draftMode() to pick the draft source. One guardrail from the official docs: redirect to the slug resolved from WordPress, never from the query string, to avoid open-redirect vulnerabilities.

Faust.js and the wider ecosystem

You can build all of the above by hand — WPGraphQL plus a few Route Handlers is a complete stack. But WP Engine's open-source Faust.js toolkit packages the repetitive parts: the @faustwp/core npm package for routing and data fetching, @faustwp/cli for generating TypeScript types from your GraphQL schema, @faustwp/blocks for mapping Gutenberg blocks to React components, and a companion FaustWP WordPress plugin handling authentication and previews. Its template-resolution system mirrors WordPress's own template hierarchy, so theme developers find the mental model familiar.

Whether or not you adopt Faust.js, two ecosystem pieces are worth knowing: the WPGraphQL Content Blocks extension, which exposes Gutenberg block data as typed GraphQL nodes for per-block React components, and WPGraphQL Smart Cache, which adds tag-based cache invalidation on the WordPress side so expensive queries don't hammer the database on every build.

Things that bite you in production

The architecture is straightforward; the operational details are where headless projects stall. A few worth planning for:

  • Images. WordPress media URLs point at your backend host. Either proxy them through the frontend, allowlist them for Next.js Image Optimization, or move media to a CDN — otherwise every image request round-trips to a PHP server.
  • SEO continuity. You lose WordPress's automatic sitemaps and meta handling. Generate sitemap.xml from the same slug query used by generateStaticParams, pull SEO title/description fields through WPGraphQL, and implement redirects for changed slugs.
  • Lock down the WordPress side. It no longer serves public traffic: restrict wp-admin to your team's IPs or a VPN, disable the theme frontend, and keep the API reachable only from your frontend servers and build pipeline.
  • Search. A static frontend has no database to search. Use a search-as-a-service product with a synced index, or a client-side index for small sites.
  • Plugin sprawl. Every plugin that assumes a theme is rendering — page builders, form plugins with frontend assets — needs a headless rethink. Audit for anything that emits markup, scripts, or redirects meant for browsers.

When not to go headless

Headless adds a second codebase, a second deployment pipeline, and a caching layer you now own. If the site is a small brochure site, the team has no JavaScript capacity, or heavy use of WordPress-native features (WooCommerce checkout flows, membership plugins with frontend dashboards) would need re-implementing, the classic monolith is the pragmatic choice. Go headless when the frontend genuinely needs to be faster, more interactive, or multi-channel than the theme layer can deliver — and when someone owns the Node.js side.

Further reading

Similar Posts

Leave a Reply