Connecting WordPress with Next.js Using WPGraphQL

Connecting WordPress with Next.js Using WPGraphQL


WordPress is one of the best content editors on the web. Next.js is one of the best ways to build fast, modern frontends with React. “Headless WordPress” combines them: editors keep the WordPress admin they know, and visitors get a frontend built with Next.js that pulls content from WordPress through an API.

It is a powerful setup, but it is also frequently chosen for the wrong reasons. In this guide I explain how the architecture works, connect WordPress to a Next.js App Router project with WPGraphQL step by step, cover caching and on-demand revalidation so published changes appear quickly, and finish with an honest look at when headless is worth the extra complexity and when a traditional WordPress theme is the better choice.

What “headless WordPress” actually means

In a traditional WordPress site, one application does everything: it stores content, runs PHP theme templates, and sends finished HTML to the browser. The “head” is the theme, the part visitors see.

In a headless setup, you remove that head. WordPress becomes a content backend that exposes data through an API, either the built-in REST API or WPGraphQL, and a separate application renders the website. That frontend can be Next.js, a mobile app or any other client, all reading from the same content.

Traditional WordPress runs web server, PHP theme, core and plugins and MySQL as one application. Headless separates a front end (CDN and a Next.js app) from a back end (WPGraphQL API and WordPress with MySQL).Traditional WordPressHeadless WordPressBrowserWeb server + PHPTheme templates (PHP)WordPress core + pluginsMySQLOne application renders everythingBrowserCDN / edge cacheNext.js app (React)WPGraphQL APIWordPress + MySQLFront end (pink) and back end (blue) deploy separately
Figure 1: Traditional WordPress is one application. Headless splits it into a frontend and a content backend.

The architecture at a glance

Here is the setup I use for headless WordPress with the Next.js App Router. Editors work in WordPress exactly as before. Next.js requests content from the WPGraphQL endpoint on the server, renders pages with React Server Components, and caches the result so most visitors get a page from the CDN without WordPress being involved at all. When an editor publishes, WordPress calls a webhook that tells Next.js to refresh the affected pages.

Headless architecture: editors publish in wp-admin, content is stored in MySQL and exposed by WPGraphQL. Next.js Server Components query it, results go into the data cache and are served to visitors through a CDN. On publish, WordPress sends a webhook to a Next.js route handler that purges the cache.Editorswrite, publishWordPress (back end)wp-admin / block editorMySQL databaseWPGraphQL /graphqlNext.js (front end)Route handler/api/revalidateData cache (ISR)Server Componentsfetch() and renderCDN / edgeVisitorswebhookGraphQL1Editors keep usingWordPress as usual.2Next.js queries only thefields each page needs.3Pages are cached at the edge;a webhook refreshes changes.
Figure 2: Headless WordPress with the Next.js App Router. Dashed pink lines show on-demand revalidation.

Step 1: Set up WPGraphQL

WordPress has a REST API built in, and it works fine for headless projects. I prefer WPGraphQL because it lets the frontend ask for exactly the fields a page needs in a single request, including related data such as the author, categories and featured image, instead of making several REST calls and receiving fields it does not use.

  1. Install and activate the free WPGraphQL plugin from the WordPress plugin directory. It adds a /graphql endpoint.
  2. Open GraphQL → GraphiQL IDE in the admin to explore the schema and test queries with autocomplete.
  3. Custom post types and taxonomies appear in the schema when they are registered with show_in_graphql, a graphql_single_name and a graphql_plural_name. Popular plugins such as Advanced Custom Fields and Yoast SEO have WPGraphQL extensions.
  4. Use pretty permalinks, and decide where the frontend will live, for example WordPress on cms.example.com and Next.js on example.com.

A query for the latest posts looks like this. You describe the shape of the data you want, and the response comes back in exactly that shape:

query LatestPosts($first: Int = 10) {
  posts(first: $first, where: { orderby: { field: DATE, order: DESC } }) {
    nodes {
      slug
      title
      date
      excerpt
      featuredImage {
        node {
          sourceUrl
          altText
        }
      }
      categories {
        nodes { name slug }
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Exposing custom post types

Most real projects have more than posts and pages: projects, services, team members, case studies. To make a custom post type queryable, add three arguments when you register it. WPGraphQL then generates project and projects fields with the same filtering and pagination as posts.

<?php
add_action( 'init', function () {
	register_post_type( 'project', array(
		'label'               => 'Projects',
		'public'              => true,
		'show_in_rest'        => true, // Block editor support.
		'supports'            => array( 'title', 'editor', 'excerpt', 'thumbnail' ),
		'show_in_graphql'     => true,
		'graphql_single_name' => 'project',
		'graphql_plural_name' => 'projects',
	) );
} );

Keep in mind that everything public in WordPress is public in the API too. Only expose the post types and fields the frontend actually needs, and keep drafts and private data behind authentication.

Step 2: Fetch data in Next.js

In the App Router, Server Components can fetch data directly, so the API call happens on the server and the GraphQL endpoint is never exposed to the browser. A small helper keeps every request consistent. I send queries as GET requests, which WPGraphQL supports, because GET responses can be cached by Next.js and by a CDN in front of WordPress.

// lib/wordpress.ts
const API_URL = process.env.WORDPRESS_GRAPHQL_URL!; // e.g. https://cms.example.com/graphql

type GraphQLResponse<T> = { data?: T; errors?: { message: string }[] };

export async function wpQuery<T>(
  query: string,
  variables: Record<string, unknown> = {},
  tags: string[] = ['wordpress'],
): Promise<T> {
  const url = new URL(API_URL);
  url.searchParams.set('query', query);
  url.searchParams.set('variables', JSON.stringify(variables));

  const res = await fetch(url, {
    headers: { Accept: 'application/json' },
    // Cache the response, refresh it at most once an hour,
    // and tag it so a webhook can refresh it on demand.
    next: { revalidate: 3600, tags },
  });

  if (!res.ok) {
    throw new Error(`WPGraphQL request failed: ${res.status}`);
  }

  const json = (await res.json()) as GraphQLResponse<T>;
  if (json.errors?.length) {
    throw new Error(json.errors.map((e) => e.message).join('\n'));
  }
  return json.data as T;
}

Step 3: Build the post page

A dynamic route at app/blog/[slug]/page.tsx renders each post. generateStaticParams pre-renders known posts at build time, generateMetadata sets the title and description, and the page component renders the content. In recent versions of Next.js (15 and later), params is a Promise, so it must be awaited.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { wpQuery } from '@/lib/wordpress';

type Post = {
  title: string;
  content: string;
  date: string;
  excerpt: string;
};

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

const POST_QUERY = `
  query PostBySlug($slug: ID!) {
    post(id: $slug, idType: SLUG) {
      title
      content
      date
      excerpt
    }
  }
`;

async function getPost(slug: string) {
  const data = await wpQuery<{ post: Post | null }>(POST_QUERY, { slug }, [
    'posts',
    `post:${slug}`,
  ]);
  return data.post;
}

// Pre-render the most recent posts at build time; others render on first visit.
export async function generateStaticParams() {
  const data = await wpQuery<{ posts: { nodes: { slug: string }[] } }>(
    `query { posts(first: 50) { nodes { slug } } }`,
  );
  return data.posts.nodes.map(({ slug }) => ({ slug }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};
  return {
    title: post.title,
    description: post.excerpt.replace(/<[^>]+>/g, '').trim(),
  };
}

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

  return (
    <article>
      <h1 dangerouslySetInnerHTML={{ __html: post.title }} />
      <time dateTime={post.date}>
        {new Date(post.date).toLocaleDateString('en-GB', { dateStyle: 'long' })}
      </time>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Calling getPost in both generateMetadata and the page is fine: Next.js memoises identical fetch calls within a single render, so WordPress receives one request. Rendering content as HTML is acceptable here because it comes from your own editors, and WordPress already filters the HTML that users without the unfiltered_html capability can save.

Rendering strategies and caching

The biggest advantage of headless is control over when HTML is produced. Next.js gives you several options, and you can mix them per route:

StrategyWhen HTML is builtFreshnessBest for
Static (SSG)At build timeUntil the next deployPages that rarely change
Incremental (ISR)At build or first visit, then refreshed in the backgroundWithin the revalidate window, or instantly with on-demand revalidationBlogs, marketing pages, catalogues
Dynamic (SSR)On every requestAlways currentPersonalised pages, search results
Client-sideIn the browser after loadAlways currentDashboards, user-specific widgets
Rendering strategies in the Next.js App Router

For WordPress content, incremental static regeneration is usually the sweet spot. Pages are as fast as static files, WordPress is only contacted when a page needs refreshing, and content stays reasonably fresh. Here is how the revalidate: 3600 setting from the helper behaves over time:

Incremental static regeneration timeline: the page is generated, requests are served from cache, after the revalidate period the next request still gets the cached page but triggers a background rebuild, then a fresh page replaces the cache. A publish webhook can refresh the page immediately.timerevalidate period (1 hour) passesPagegeneratedRequests servedfrom cache (fast)Next visitor gets the cached pageand triggers a background rebuildFresh pagereplaces cacheEditor publishes: webhookrefreshes it right awayPage builtCached responseStale response + rebuildOn-demand revalidation
Figure 3: How time-based and on-demand revalidation keep cached pages fresh.

Waiting up to an hour for a correction to appear is not acceptable for most editors, which is why the next step matters. Note that Next.js 16 also introduced Cache Components with the "use cache" directive as a newer caching model; the fetch-based approach shown here continues to work and is the easiest to reason about when you are starting out.

Step 4: On-demand revalidation when you publish

On-demand revalidation lets WordPress tell Next.js exactly what changed. First, a route handler in Next.js that checks a shared secret and invalidates the right cache tags and paths:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import type { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
    return Response.json({ message: 'Invalid secret' }, { status: 401 });
  }

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

  // Next.js 16 takes a cache profile as the second argument.
  // On Next.js 15, call revalidateTag('posts') with one argument.
  revalidateTag('posts', 'max');
  if (slug) {
    revalidateTag(`post:${slug}`, 'max');
    revalidatePath(`/blog/${slug}`);
  }
  revalidatePath('/blog');

  return Response.json({ revalidated: true, now: Date.now() });
}

Then, a few lines in a small plugin or must-use plugin on the WordPress side call that endpoint whenever a post is published, updated or unpublished. The transition_post_status hook fires on every save, including updates to posts that are already published:

<?php
// wp-content/mu-plugins/headless-revalidate.php
// Define FRONTEND_URL and REVALIDATE_SECRET in wp-config.php.
add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
	if ( 'post' !== $post->post_type ) {
		return;
	}
	if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
		return; // Drafts and private changes do not affect the public site.
	}

	wp_remote_post(
		FRONTEND_URL . '/api/revalidate',
		array(
			'headers'  => array(
				'Content-Type'        => 'application/json',
				'x-revalidate-secret' => REVALIDATE_SECRET,
			),
			'body'     => wp_json_encode( array( 'slug' => $post->post_name ) ),
			'timeout'  => 5,
			'blocking' => false, // Do not slow down the editor.
		)
	);
}, 10, 3 );

If hooks are new to you, my guide on WordPress actions vs filters explains how add_action works. For larger projects I package this kind of logic as a proper plugin, as described in how to build a custom WordPress plugin the right way.

SEO, previews and images

Several things WordPress does for you automatically become your responsibility in a headless build. Plan for them from the start:

  • SEO metadata. Titles, descriptions, canonical URLs, Open Graph tags and schema need to be generated in Next.js. With Yoast SEO and its WPGraphQL extension, you can query the values editors set and pass them to generateMetadata. You also need a sitemap (app/sitemap.ts) and robots.txt on the frontend domain.
  • Previews. The “Preview” button in WordPress points to the WordPress URL by default. To preview drafts on the frontend, you need authenticated GraphQL requests and Next.js Draft Mode. Frameworks such as Faust.js, built specifically for headless WordPress, handle much of this for you.
  • Images. Add the WordPress domain to images.remotePatterns in next.config to use next/image for featured images. Images inside post content arrive as HTML and are not optimised automatically unless you parse and transform them.
  • Internal links and redirects. Links in content point to the WordPress domain unless you rewrite them, and redirects created in WordPress need to be mirrored on the frontend.
  • Block styles. Core block markup expects WordPress’s block CSS. Import it into your frontend or write your own styles for the blocks your editors use.

Deploying the two halves

The frontend and backend deploy independently. Next.js runs on any Node.js host or a platform such as Vercel, while WordPress stays on regular PHP hosting. Because visitors never hit WordPress directly, the WordPress server mostly handles editors and cache refreshes, and you can lock it down further: restrict the admin by IP, disable the WordPress frontend with a redirect to the main domain, and set WORDPRESS_GRAPHQL_URL and REVALIDATE_SECRET as environment variables rather than committing them. Set up uptime monitoring for both, because a broken API means new content stops appearing even though cached pages keep working.

When headless is not worth it

Headless is not automatically faster or better. A well-built traditional WordPress theme with good hosting and full-page caching is very fast too; see my Core Web Vitals checklist. Before choosing headless, weigh the real costs:

  • Two applications to host, deploy, secure and monitor, often with two hosting bills.
  • Plugins that output frontend features stop working on the frontend: form builders, sliders, page builders, membership and many WooCommerce extensions. Each needs an API-based replacement or custom work.
  • Editors lose some WYSIWYG comfort: theme previews, the Site Editor and visual page builders do not apply to the Next.js frontend.
  • Higher development and maintenance cost, because previews, SEO, forms, search and redirects all have to be built.
Headless is a good fit when…A traditional theme is better when…
The frontend is highly interactive, app-like or built by a React teamIt is a brochure site, blog or typical business website
The same content feeds a website, a mobile app and other channelsThe site relies on plugins for forms, bookings, memberships or a store
You need a frontend that scales independently of WordPress trafficEditors want visual page building and full-site editing
There is budget and a team to maintain two codebasesOne developer or a small budget will maintain it long term
A quick decision guide

In my own project connecting WordPress and Next.js with WPGraphQL, the clean separation between content and presentation was the big win: the frontend could evolve independently while editors continued to work in WordPress. For many business websites, though, a fast custom theme delivers most of the same benefits with half the moving parts.

Key takeaways

  • Headless WordPress keeps the familiar editor and moves rendering to a separate frontend such as Next.js.
  • WPGraphQL lets each page request exactly the data it needs in one query.
  • Fetch on the server with Server Components, cache with revalidate and tags, and remember that params is a Promise in recent Next.js.
  • Use a publish webhook and revalidateTag/revalidatePath so changes appear right away.
  • Budget for SEO, previews, images, forms and two deployments, or choose a traditional theme if those costs outweigh the benefits.

Considering a headless build, or not sure whether your project needs one? Get in touch and I will help you choose the architecture that fits your content, team and budget.

Code examples in this post are free to use under the MIT licence. Article text © 2026 Farooq Bin Munir.