
How to Build a Headless WordPress Site with WPGraphQL?
Editors love WordPress. Developers building modern front ends often do not love PHP templates. Headless WordPress lets both sides get what they want: the content team keeps the familiar admin, the block editor, and their workflow, while the front end is built with a JavaScript framework, deployed separately, and served from a CDN. The piece that connects the two is an API, and for most headless projects that API is WPGraphQL. Instead of making several REST requests and receiving large payloads full of fields you do not need, you send one GraphQL query that asks for exactly the posts, fields, images, and authors a page needs.
This article covers building a headless WordPress site with WPGraphQL from start to finish: when headless is the right choice, installing and configuring the plugin, exploring the schema with GraphiQL, writing queries for posts, pages, and menus, building the front end in the Next.js App Router, pagination, previews of draft content with Application Passwords, cache revalidation when editors publish, SEO, and the problems that come up in real projects.
What Headless WordPress Means
In a traditional WordPress site, the same server stores content and renders HTML with a PHP theme. In a headless site, WordPress only stores and manages content. A separate front-end application fetches that content over an API and renders the pages.
| Aspect | Traditional WordPress | Headless WordPress |
|---|---|---|
| Rendering | PHP theme on the WordPress server | Separate front end (Next.js, Astro, etc.) |
| Content editing | WordPress admin | WordPress admin |
| Plugins that output HTML | Work automatically | Need extra integration |
| Hosting | One server | WordPress backend plus front-end host |
| Performance ceiling | Depends on caching setup | Static or edge-cached pages |
| Team skills needed | PHP, WordPress theming | JavaScript framework plus WordPress |
Headless is a good fit when you need a highly custom front end, want to serve content to several channels such as a website and a mobile app, or have a front-end team that works in React. It is a poor fit when the site depends on many plugins that render front-end output, such as page builders, forms, and membership tools, because each of those needs a custom replacement in the front end. For a broader comparison of approaches, see how to integrate Next.js with a CMS like WordPress or Contentful.
WPGraphQL vs the REST API
WordPress ships with a REST API, covered in what is the WordPress REST API, and how to use it. So why add a plugin?
- One request per page. A blog post page needs the post, its author, featured image, categories, and related posts. With REST that is several requests or heavy use of
_embed. With GraphQL it is one query. - No over-fetching. You get only the fields you ask for, which keeps responses small.
- A typed schema. Every type and field is documented and discoverable, and tools can generate TypeScript types from it.
- Ecosystem. Extensions add Advanced Custom Fields, SEO plugins, WooCommerce, and more to the same schema.
WPGraphQL is free, open source, and published on WordPress.org. It is in the process of becoming a canonical plugin on WordPress.org, which means long-term stewardship within the WordPress project.
Setting Up WordPress
Installing WPGraphQL
- In the WordPress admin, go to Plugins → Add New Plugin.
- Search for WPGraphQL and install it.
- Activate the plugin.
Or with WP-CLI:
# Terminal
wp plugin install wp-graphql --activate
WPGraphQL requires WordPress 6.0 or later and PHP 7.4 or later. After activation, a GraphQL menu item appears in the admin, and the API is available at /graphql, for example https://cms.example.com/graphql.
Configuring Permalinks and Settings
Pretty permalinks must be enabled for the /graphql endpoint to work. Under Settings → Permalinks, choose any structure other than Plain. Then review GraphQL → Settings:
- GraphQL endpoint: leave it as
graphqlunless you have a reason to change it. - Public introspection: introspection lets tools read the whole schema. Enabling it for public requests helps during development and with type generation. Many teams keep it disabled in production and only allow it for authenticated users.
- Debug mode: shows extra error details. Enable it locally only.
- Batch queries and query depth limits: keep reasonable limits to protect the server from expensive queries.
Using the GraphiQL IDE
WPGraphQL includes the GraphiQL IDE in the admin under GraphQL → GraphiQL IDE. It has a query editor with autocompletion, a documentation explorer for every type, and a query composer where you click fields to build a query. Write and test every query here before putting it in your front end.
Writing Your First Queries
Listing Posts
This query returns the ten most recent posts with the fields a blog listing needs:
# queries/recent-posts.graphql
query RecentPosts {
posts(first: 10, where: { status: PUBLISH }) {
nodes {
databaseId
title
slug
date
excerpt
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
categories {
nodes {
name
slug
}
}
}
}
}
Lists in WPGraphQL follow the connection pattern. You ask for nodes to get the items, and pageInfo when you need pagination.
Fetching a Single Post by Slug
# queries/post-by-slug.graphql
query PostBySlug($slug: ID!) {
post(id: $slug, idType: SLUG) {
databaseId
title
date
modified
content
excerpt
author {
node {
name
avatar {
url
}
}
}
featuredImage {
node {
sourceUrl
altText
}
}
}
}
The idType argument tells WPGraphQL how to interpret id. Options include SLUG, DATABASE_ID, URI, and the default global ID.
Pages and Menus
Pages and menus work the same way:
# queries/page-and-menu.graphql
query PageAndMenu($uri: ID!) {
page(id: $uri, idType: URI) {
title
content
}
menuItems(where: { location: PRIMARY }, first: 50) {
nodes {
id
label
uri
parentId
}
}
}
The menu location values come from the locations your theme registers, so a headless setup typically keeps a minimal theme on the WordPress side that registers menu locations.
Building the Front End with Next.js
The examples use the Next.js App Router with TypeScript, but the queries work from any framework. If you are new to GraphQL in Next.js, how to use Next.js with a GraphQL API covers the basics.
Environment Variables
# .env.local
WORDPRESS_GRAPHQL_URL=https://cms.example.com/graphql
WORDPRESS_PREVIEW_USER=preview-bot
WORDPRESS_APPLICATION_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx"
REVALIDATE_SECRET=a-long-random-string
None of these use the NEXT_PUBLIC_ prefix, so they stay on the server and are never sent to the browser.
A Typed Fetch Helper
A small helper sends queries, handles errors, and integrates with Next.js caching:
// lib/wordpress.ts
import "server-only";
type GraphQLResponse<T> = {
data?: T;
errors?: { message: string }[];
};
type FetchOptions = {
variables?: Record<string, unknown>;
tags?: string[];
revalidate?: number | false;
authenticated?: boolean;
};
export async function wpFetch<T>(
query: string,
{
variables,
tags = ["wordpress"],
revalidate = 3600,
authenticated = false,
}: FetchOptions = {},
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authenticated) {
const credentials = Buffer.from(
`${process.env.WORDPRESS_PREVIEW_USER}:${process.env.WORDPRESS_APPLICATION_PASSWORD}`,
).toString("base64");
headers.Authorization = `Basic ${credentials}`;
}
const res = await fetch(process.env.WORDPRESS_GRAPHQL_URL!, {
method: "POST",
headers,
body: JSON.stringify({ query, variables }),
...(authenticated
? { cache: "no-store" as const }
: { next: { revalidate, tags } }),
});
if (!res.ok) {
throw new Error(`WordPress request failed with status ${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;
}
Public requests are cached and tagged so they can be revalidated later. Authenticated preview requests are never cached, so draft content cannot leak into the public cache.
The Blog Index Page
// app/blog/page.tsx
import Link from "next/link";
import Image from "next/image";
import { wpFetch } from "@/lib/wordpress";
type Post = {
databaseId: number;
title: string;
slug: string;
date: string;
excerpt: string;
featuredImage: { node: { sourceUrl: string; altText: string } } | null;
};
const RECENT_POSTS = `
query RecentPosts {
posts(first: 12, where: { status: PUBLISH }) {
nodes {
databaseId
title
slug
date
excerpt
featuredImage { node { sourceUrl altText } }
}
}
}
`;
export default async function BlogPage() {
const data = await wpFetch<{ posts: { nodes: Post[] } }>(RECENT_POSTS, {
tags: ["wordpress", "posts"],
});
return (
<main>
<h1>Blog</h1>
<ul>
{data.posts.nodes.map((post) => (
<li key={post.databaseId}>
{post.featuredImage && (
<Image
src={post.featuredImage.node.sourceUrl}
alt={post.featuredImage.node.altText}
width={640}
height={360}
/>
)}
<h2>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</h2>
<time dateTime={post.date}>
{new Date(post.date).toLocaleDateString("en-US")}
</time>
</li>
))}
</ul>
</main>
);
}
To use next/image with images hosted on WordPress, allow the domain in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cms.example.com",
pathname: "/wp-content/uploads/**",
},
],
},
};
export default nextConfig;
The Single Post Page
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { wpFetch } from "@/lib/wordpress";
type SinglePost = {
title: string;
date: string;
content: string;
excerpt: string;
author: { node: { name: string } } | null;
} | null;
const POST_BY_SLUG = `
query PostBySlug($slug: ID!) {
post(id: $slug, idType: SLUG) {
title
date
content
excerpt
author { node { name } }
}
}
`;
const ALL_SLUGS = `
query AllSlugs {
posts(first: 100, where: { status: PUBLISH }) {
nodes { slug }
}
}
`;
async function getPost(slug: string) {
const data = await wpFetch<{ post: SinglePost }>(POST_BY_SLUG, {
variables: { slug },
tags: ["wordpress", `post:${slug}`],
});
return data.post;
}
export async function generateStaticParams() {
const data = await wpFetch<{ posts: { nodes: { slug: string }[] } }>(
ALL_SLUGS,
);
return data.posts.nodes.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return {};
return {
title: post.title,
description: post.excerpt
.replace(/<[^>]+>/g, "")
.trim()
.slice(0, 160),
};
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<p>
{post.author?.node.name} ·{" "}
<time dateTime={post.date}>
{new Date(post.date).toLocaleDateString("en-US")}
</time>
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
A few notes on this page:
paramsis a Promise in current Next.js versions, so it is awaited. On Next.js 14 and earlier,paramswas a plain object.generateStaticParamsprebuilds the most recent posts at build time. Posts not in that list are rendered on the first request and then cached.contentis HTML rendered by WordPress from the block editor. Rendering it withdangerouslySetInnerHTMLis standard for headless WordPress, because the HTML comes from your own trusted editors. Do not render HTML from untrusted sources this way.- Block styles are not included automatically. Import the core block library CSS or write styles for the block classes you use, such as
wp-block-imageandwp-block-quote.
Pagination
WPGraphQL uses cursor-based pagination. Request pageInfo alongside the nodes:
# queries/paginated-posts.graphql
query PaginatedPosts($first: Int!, $after: String) {
posts(first: $first, after: $after, where: { status: PUBLISH }) {
pageInfo {
hasNextPage
endCursor
}
nodes {
databaseId
title
slug
}
}
}
Pass endCursor as the after variable to fetch the next page. Cursors work naturally with "Load more" buttons and infinite scroll. If your design needs numbered pages like /blog/page/3, you either walk through cursors on the server or install an extension that adds offset pagination, because cursor pagination does not support jumping to an arbitrary page.
Previewing Drafts
Editors expect the Preview button to show unpublished changes. In a headless setup, that means the front end must fetch draft content, which requires authentication.
- Create a dedicated user in WordPress, for example
preview-bot, with the Editor role so it can read drafts. - Create an Application Password for that user under Users → Profile → Application Passwords. WPGraphQL accepts Application Passwords sent with HTTP Basic authentication, as explained in how to use Application Passwords in WordPress. Always use HTTPS.
- Enable Draft Mode in Next.js through a route handler that checks a secret:
// app/api/preview/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get("secret");
const id = searchParams.get("id");
if (secret !== process.env.REVALIDATE_SECRET || !id || !/^\d+$/.test(id)) {
return new Response("Invalid preview request", { status: 401 });
}
(await draftMode()).enable();
redirect(`/preview/${id}`);
}
- Render the preview by fetching the post by database ID with authentication. Drafts often have no slug yet, so the ID is the reliable identifier:
// app/preview/[id]/page.tsx
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import { wpFetch } from "@/lib/wordpress";
const PREVIEW_POST = `
query PreviewPost($id: ID!) {
post(id: $id, idType: DATABASE_ID, asPreview: true) {
title
content
}
}
`;
export default async function PreviewPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { isEnabled } = await draftMode();
if (!isEnabled) notFound();
const { id } = await params;
const data = await wpFetch<{
post: { title: string; content: string } | null;
}>(PREVIEW_POST, { variables: { id }, authenticated: true });
if (!data.post) notFound();
return (
<article>
<p>Preview mode</p>
<h1>{data.post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: data.post.content }} />
</article>
);
}
- Point the WordPress Preview button at the front end with the
preview_post_linkfilter in a small plugin or mu-plugin on the WordPress side:
<?php
// wp-content/mu-plugins/headless-preview.php
/**
* Plugin Name: Headless Preview Links
*/
add_filter( 'preview_post_link', function ( $link, $post ) {
if ( ! defined( 'HEADLESS_FRONTEND_URL' ) || ! defined( 'HEADLESS_PREVIEW_SECRET' ) ) {
return $link;
}
return add_query_arg(
array(
'secret' => HEADLESS_PREVIEW_SECRET,
'id' => $post->ID,
),
trailingslashit( HEADLESS_FRONTEND_URL ) . 'api/preview'
);
}, 10, 2 );
Define HEADLESS_FRONTEND_URL and HEADLESS_PREVIEW_SECRET in wp-config.php so the secret is not stored in code.
Revalidating When Content Changes
Cached pages must update when editors publish. The cleanest approach is on-demand revalidation: WordPress calls a front-end endpoint after a post is saved, and the front end invalidates the matching cache tags.
The route handler on the Next.js side:
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
export async function POST(request: Request) {
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 };
revalidateTag("posts", "max");
if (slug) revalidateTag(`post:${slug}`, "max");
return Response.json({ revalidated: true });
}
In Next.js 16, revalidateTag takes a cache profile as its second argument; "max" marks the data stale and refreshes it in the background on the next visit. In Next.js 15 and earlier, call it with the tag only.
And the WordPress side, which sends a request when a published post is saved:
<?php
// wp-content/mu-plugins/headless-revalidate.php
/**
* Plugin Name: Headless Revalidation
*/
add_action( 'save_post_post', function ( $post_id, $post ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
if ( 'publish' !== $post->post_status ) {
return;
}
if ( ! defined( 'HEADLESS_FRONTEND_URL' ) || ! defined( 'HEADLESS_REVALIDATE_SECRET' ) ) {
return;
}
wp_remote_post(
trailingslashit( HEADLESS_FRONTEND_URL ) . 'api/revalidate',
array(
'headers' => array(
'Content-Type' => 'application/json',
'x-revalidate-secret' => HEADLESS_REVALIDATE_SECRET,
),
'body' => wp_json_encode( array( 'slug' => $post->post_name ) ),
'timeout' => 5,
'blocking' => false,
)
);
}, 10, 2 );
Setting blocking to false means editors do not wait for the front end to respond when they click Update. For deletions and unpublishing, hook transition_post_status as well, so a post that moves from published to draft disappears from the front end.
SEO for Headless WordPress
Search engines see your front end, not WordPress, so SEO must be handled there:
- Titles and descriptions: generate them in
generateMetadata, as in the single post example. If you use an SEO plugin, install its WPGraphQL extension so its titles, descriptions, canonical URLs, and Open Graph data are available in the schema. - Canonical URLs: point them at the front-end domain, never the WordPress backend.
- Sitemaps: generate them on the front end from a query for all slugs.
- Block the backend from indexing: the WordPress domain should not compete with your front end. Add a
noindexheader on the CMS domain or protect it entirely, and make sure the API endpoint stays reachable for the front end. - Redirects: when editors change slugs, the front end needs to return 301 redirects from the old URLs.
Performance and Security
- Cache at every layer. Use Next.js caching with tags on the front end. On the WordPress side, persistent object caching speeds up GraphQL resolvers, and the WPGraphQL Smart Cache extension adds network caching for GraphQL GET requests with automatic invalidation.
- Ask only for what you need. Avoid huge
firstvalues and deeply nested queries in a single request. - Keep credentials server-side. Application Passwords and secrets belong in server environment variables, never in client components.
- Restrict the admin. Put the WordPress admin behind strong passwords and two-factor authentication. The front end only needs the
/graphqlendpoint. - Limit query complexity. Use the query depth and batching settings to protect the server from expensive or abusive queries.
Common Problems and Fixes
/graphqlreturns a 404. Permalinks are set to Plain. Choose another structure under Settings, Permalinks, and save.postreturnsnullfor a slug that exists. The post is a draft or private and the request is not authenticated, or the post belongs to a custom post type. Check its status and query the correct type.- Custom post types do not appear in the schema. They must opt in with
show_in_graphql,graphql_single_name, andgraphql_plural_nameinregister_post_type. - Images fail in
next/image. The WordPress domain is not inimages.remotePatterns. - Published changes do not appear on the front end. The revalidation request is failing. Check the secret, the URL, and the front end's logs, and confirm the cache tags match.
- Preview shows the published version. The query is missing authentication or
asPreview: true, or the request was cached. Usecache: "no-store"for preview requests. - Block content looks unstyled. Block markup relies on core block CSS. Include the block library styles or style the block classes yourself.
Headless WordPress with WPGraphQL FAQ
Yes. WPGraphQL is a free, open-source plugin available on WordPress.org. Most extensions for other plugins are free as well.
WordPress needs an active theme to run, but it can be minimal. Many headless projects use a tiny theme that registers menu locations and redirects front-end visits to the real site.
Use WPGraphQL when pages need related data from several sources and you want small, precise responses. The REST API is built in and is fine for simple integrations, server-to-server jobs, and when you only need one resource type at a time.
Plugins that work in the admin or store data, such as SEO, custom fields, and editorial tools, still work. Plugins that output HTML on the front end, such as forms, sliders, and page builders, do not render on your front end and need a replacement or an API integration.
Point the WordPress preview link at a front-end route that enables draft mode, then fetch the draft over WPGraphQL with an authenticated request, for example using an Application Password, with caching disabled for that request.
Yes. WPGraphQL is a standard GraphQL endpoint, so any framework or language that can send an HTTP POST request can use it, including Astro, Nuxt, SvelteKit, Remix, and mobile apps.
Conclusion
Headless WordPress with WPGraphQL keeps the editing experience your content team knows while giving developers a typed, efficient API and full control over the front end. The setup is straightforward: install WPGraphQL, enable pretty permalinks, design your queries in GraphiQL, and fetch them from a server-side helper in your framework.
The work that makes a headless site production-ready is in the details around it: cache tags plus a revalidation webhook so published changes appear quickly, authenticated draft previews for editors, SEO metadata and sitemaps generated on the front end, and a locked-down WordPress backend. Get those right and you have a fast, secure site that is still pleasant to edit.
Here are some useful references for going deeper on headless WordPress with WPGraphQL:
- WordPress.org Plugins: WPGraphQL — the plugin page, requirements, and changelog.
- WPGraphQL Docs: Introduction to WPGraphQL — concepts, the schema, and connections.
- WPGraphQL Docs: Authentication and Authorization — Application Passwords and other auth methods.
- Next.js Docs: Draft Mode — previewing unpublished content in the App Router.
- Next.js Docs: revalidateTag — on-demand cache invalidation.


