I have moved four WordPress sites to a headless Next.js frontend, and talked two clients out of it. The ones that went headless needed a content backend feeding a website, a mobile app and a partner portal; the ones that did not just wanted a faster site and got it with a custom theme instead. If headless is right for you, this is the architecture that has held up.

The architecture

EDITORS CONTENT BACKEND FRONTEND (edge) VISITORS ┌──────────┐ wp-admin ┌───────────────────────┐ build/ISR ┌───────────────────────┐ CDN ┌─────────┐ │ Writers │───────────▶ │ WordPress (origin) │◀──────────▶│ Next.js (App Router) │──────▶ │ Browser │ │ Marketing│ │ • WPGraphQL / REST │ GraphQL │ • RSC + ISR pages │ └─────────┘ └──────────┘ │ • ACF Pro │ │ • next/image │ │ • Yoast/RankMath │ webhook │ • /api/revalidate │ │ • locked to VPN/IP │───────────▶│ • /api/preview │ └───────────────────────┘ └───────────────────────┘ │ ▲ └─── same GraphQL API feeds ──▶ mobile app / partner portal

WordPress keeps the jobs it is unbeatable at: editorial workflow, media library, roles, plugins like ACF and Yoast. Next.js takes rendering, routing, caching and performance. The two talk over GraphQL (or REST) at build time and on revalidation, and through a webhook when content changes.

WPGraphQL vs the REST API

WPGraphQLWP REST API
Nested data (post + author + terms + ACF)One querySeveral requests or _embed with bloat
Typed schema for codegenYes (graphql-codegen)No; you write types by hand
ACF supportExcellent via WPGraphQL for ACFNeeds "Show in REST" + manual shaping
CachingNeeds WPGraphQL Smart Cache; POST by defaultGET, plays well with page caches
Setup effortTwo pluginsZero
Best forContent models with relationships and custom fieldsSimple lists, quick integrations, RAG ingestion

My rule: if the site uses ACF (and every serious client site does), WPGraphQL. I use REST for lightweight consumers, like the ingestion job in my RAG pipeline.

The data layer in Next.js

One typed client, one function per query, all cached with tags so a WordPress webhook can purge precisely. This is the core of the App Router caching model applied to a CMS.

// lib/wp.ts
import { GraphQLClient } from 'graphql-request';

const client = new GraphQLClient(process.env.WP_GRAPHQL_URL!, {
  headers: { Authorization: `Bearer ${process.env.WP_API_TOKEN}` },   // keeps the origin private
});

export async function wpQuery<T>(query: string, variables: Record<string, unknown>, tags: string[]): Promise<T> {
  // graphql-request uses fetch under the hood; pass Next.js cache options through.
  return client.request<T>(query, variables, undefined, { next: { tags, revalidate: 86400 } } as any);
}

const POST_BY_SLUG = /* GraphQL */ `
  query PostBySlug($slug: ID!) {
    post(id: $slug, idType: SLUG) {
      databaseId slug title date modified content
      excerpt
      featuredImage { node { sourceUrl altText mediaDetails { width height } } }
      author { node { name slug avatar { url } } }
      categories { nodes { name slug } }
      seo { title metaDesc canonical opengraphImage { sourceUrl } }   # Yoast via WPGraphQL SEO
      caseStudyFields { client industry results { metric value } }      # ACF group
    }
  }`;

export const getPostBySlug = (slug: string) =>
  wpQuery<{ post: WpPost | null }>(POST_BY_SLUG, { slug }, ['posts', `post-${slug}`]).then(r => r.post);

const ALL_SLUGS = /* GraphQL */ `query AllSlugs { posts(first: 1000, where: { status: PUBLISH }) { nodes { slug } } }`;
export const getAllPostSlugs = () =>
  wpQuery<{ posts: { nodes: { slug: string }[] } }>(ALL_SLUGS, {}, ['posts']).then(r => r.posts.nodes.map(n => n.slug));

Pages: static by default, streamed where slow

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPostBySlug, getAllPostSlugs } from '@/lib/wp';
import { WpContent } from '@/components/WpContent';
import type { Metadata } from 'next';

export const dynamicParams = true;                 // new posts render on first request, then cache
export async function generateStaticParams() { return (await getAllPostSlugs()).map(slug => ({ slug })); }

export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPostBySlug((await params).slug);
  if (!post) return {};
  return {
    title: post.seo.title,
    description: post.seo.metaDesc,
    alternates: { canonical: post.seo.canonical || `https://example.com/blog/${post.slug}` },
    openGraph: { images: post.seo.opengraphImage ? [post.seo.opengraphImage.sourceUrl] : [] },
  };
}

export default async function PostPage({ params }) {
  const post = await getPostBySlug((await params).slug);
  if (!post) notFound();
  return (
    <article>
      <h1>{post.title}</h1>
      <WpContent html={post.content} />
    </article>
  );
}

Everything here is a Server Component: no client JavaScript ships for the article itself. Yoast metadata flows straight into generateMetadata, so the SEO team keeps working in the WordPress UI they know.

Rendering WordPress HTML safely

Post content arrives as HTML. Do not dangerouslySetInnerHTML it raw; parse it, rewrite <img> to next/image, rewrite internal links to next/link, and strip anything you do not expect. html-react-parser with a replace callback does this in about 40 lines. Images are the big one: WordPress emits <img src="https://origin/wp-content/uploads/…">; you want them proxied through the frontend's image optimiser with the origin domain allow-listed in next.config.js.

On-demand revalidation from WordPress

This is what makes editors happy. Publish in WordPress, see it live in under a second, no rebuild.

<?php
// mu-plugins/headless-revalidate.php
add_action('transition_post_status', function (string $new, string $old, WP_Post $post): void {
    if ($new !== 'publish' && $old !== 'publish') return;        // ignore draft-to-draft
    if (wp_is_post_revision($post->ID)) return;

    $payload = ['type' => $post->post_type, 'slug' => $post->post_name];
    wp_remote_post(HEADLESS_URL . '/api/revalidate', [
        'timeout'  => 5,
        'blocking' => false,                                     // don't slow down the editor
        'headers'  => ['Content-Type' => 'application/json', 'x-webhook-secret' => HEADLESS_SECRET],
        'body'     => wp_json_encode($payload),
    ]);
}, 10, 3);

The Next.js side is the /api/revalidate route from the caching guide: verify the secret, call revalidateTag('post-{slug}') and revalidateTag('posts'). Menus and global options get their own tags and hooks (wp_update_nav_menu, acf/save_post for options pages).

Preview mode

Editors will not accept a stack where "Preview" is broken. The flow: WordPress' preview link is filtered to point at https://frontend/api/preview?id=123&token=…; the route verifies the token against WordPress, enables Next.js draft mode, and redirects to the page; the page's data function requests the draft revision when draft mode is on.

// app/api/preview/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';

export async function GET(req: Request) {
  const url = new URL(req.url);
  const id = url.searchParams.get('id'), token = url.searchParams.get('token');
  const ok = await fetch(`${process.env.WP_URL}/wp-json/headless/v1/verify-preview?id=${id}&token=${token}`).then(r => r.ok);
  if (!ok) return new Response('Invalid preview token', { status: 401 });
  (await draftMode()).enable();
  redirect(`/preview/${id}`);       // a dynamic route that fetches the latest revision with asPreview: true
}

The unglamorous parts that decide success

  • Redirects. Import the Redirection plugin's table into next.config.js redirects at build, or serve them from middleware backed by a cached fetch. Losing 300 legacy redirects on launch day is how you lose rankings.
  • Sitemap and robots. Generate them in Next.js from the same queries; disable Yoast's XML sitemap on the origin or point it at the frontend.
  • Forms. Gravity Forms and Contact Form 7 render in WordPress, not Next.js. Either post to their REST endpoints from a React form, or use a form service. Decide this before quoting the project.
  • Search. WordPress search is now behind an API. Either proxy it or, better, build proper semantic search on the frontend side.
  • Lock the origin. The WordPress site should not be publicly browsable: block front-end requests, allow wp-admin behind SSO or IP allow-list, and only expose the GraphQL endpoint with a token. This alone removes most of WordPress' attack surface.

Hosting and cost, honestly

A managed WordPress host at $30 to $50/month typically becomes: a smaller WordPress origin ($15 to $25, since it serves only editors and the API) plus a Next.js host ($0 to $20 for most content sites on Vercel or Cloudflare, more for high traffic). Net change is usually within $20/month either way. What changes dramatically is performance: the last migration went from an LCP of 3.1 s to 0.9 s and a Lighthouse performance score from 58 to 99, with no CDN tricks, just static HTML at the edge.

When I say no

A five-page brochure site, a single content consumer, a team without a JavaScript developer on retainer, or a budget under four figures: I recommend a fast custom theme instead. Headless doubles the number of systems to maintain, and that cost lands on the client every month. The same "start simpler" reasoning is in Monolith vs Microservices.

If you do have the multi-frontend problem, headless WordPress with Next.js is the most editor-friendly headless CMS setup I know, because the editors never leave WordPress. I have shipped this for agencies and can do the same for your team; here is how I work with WordPress projects.