Architecture

Headless WordPress with GraphQL: Decoupled Frontends

Build a headless WordPress site using WPGraphQL and a decoupled frontend. Covers setup, queries, authentication, and deployment patterns.

Headless WordPress architecture with GraphQL API and decoupled frontend

WordPress powers over 40% of the web. But the traditional WordPress model—PHP rendering HTML on every request—does not always match modern frontend requirements. When you need a React or Next.js frontend with WordPress as the content backend, headless WordPress with GraphQL is the established pattern.

The idea: WordPress manages content (posts, pages, custom post types, media). A separate frontend application fetches that content via GraphQL and renders it. WordPress becomes an API, not a website.

When headless WordPress makes sense

Good fit:

  • Marketing sites with complex interactive components (configurators, calculators)
  • Multi-channel content delivery (web, mobile app, digital signage)
  • Teams with strong JavaScript/React skills but established WordPress content workflows
  • Performance-critical sites where static generation or edge rendering matters

Poor fit:

  • Content-heavy blogs that do not need interactivity beyond comments
  • Sites relying heavily on WordPress plugins (WooCommerce, Gravity Forms, Elementor)
  • Small teams that benefit from WordPress’s all-in-one approach
  • Sites where editors need real-time preview with the actual frontend theme

Setting up WPGraphQL

Install the plugin

1
2
3
4
# Via WP-CLI
wp plugin install wp-graphql --activate

# Or download from wordpress.org/plugins/wp-graphql

Once activated, WPGraphQL adds a /graphql endpoint to your WordPress installation and a GraphQL IDE at /wp-admin/admin.php?page=graphiql-ide.

Basic queries

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Fetch recent posts
query RecentPosts {
posts(first: 10, where: { orderby: { field: DATE, order: DESC } }) {
nodes {
id
title
slug
date
excerpt
content
author {
node {
name
avatar {
url
}
}
}
featuredImage {
node {
sourceUrl(size: LARGE)
altText
}
}
categories {
nodes {
name
slug
}
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Fetch a single post by slug
query PostBySlug($slug: ID!) {
post(id: $slug, idType: SLUG) {
title
content
date
seo {
title
metaDesc
opengraphImage {
sourceUrl
}
}
}
}

Custom post types

1
2
3
4
5
6
7
8
9
// functions.php — register a CPT with GraphQL support
register_post_type('project', [
'label' => 'Projects',
'public' => true,
'show_in_graphql' => true,
'graphql_single_name' => 'project',
'graphql_plural_name' => 'projects',
'supports' => ['title', 'editor', 'thumbnail', 'custom-fields'],
]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Query custom post type
query GetProjects {
projects(first: 20) {
nodes {
title
slug
projectFields {
clientName
completionDate
projectUrl
}
}
}
}

Custom fields with ACF

If you use Advanced Custom Fields, install the WPGraphQL for ACF extension:

1
wp plugin install wpgraphql-acf --activate

ACF field groups automatically appear in the GraphQL schema.

Frontend integration

Next.js (most common)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// lib/wordpress.js
const API_URL = process.env.WORDPRESS_GRAPHQL_URL;

export async function fetchGraphQL(query, variables = {}) {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
});

const json = await response.json();

if (json.errors) {
throw new Error(json.errors.map(e => e.message).join(', '));
}

return json.data;
}

export async function getRecentPosts() {
const data = await fetchGraphQL(`
query {
posts(first: 10) {
nodes {
title
slug
excerpt
date
featuredImage {
node { sourceUrl altText }
}
}
}
}
`);

return data.posts.nodes;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// app/page.tsx (Next.js App Router)
import { getRecentPosts } from '@/lib/wordpress';

export default async function Home() {
const posts = await getRecentPosts();

return (
<main>
<h1>Latest Posts</h1>
{posts.map(post => (
<article key={post.slug}>
<h2><a href={`/blog/${post.slug}`}>{post.title}</a></h2>
<div dangerouslySetInnerHTML={{ __html: post.excerpt }} />
</article>
))}
</main>
);
}

Static site generation

For blogs and marketing sites, generate pages at build time:

1
2
3
4
5
6
7
8
9
10
11
12
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const data = await fetchGraphQL(`
query {
posts(first: 1000) {
nodes { slug }
}
}
`);

return data.posts.nodes.map(post => ({ slug: post.slug }));
}

This fetches all post slugs at build time and pre-renders every page. Rebuilds happen on content change via a WordPress webhook.

Authentication and previews

Preview drafts

Editors need to preview unpublished content. This requires authentication:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// WordPress: register a preview endpoint
add_action('rest_api_init', function () {
register_rest_route('headless/v1', '/preview-token', [
'methods' => 'POST',
'callback' => function ($request) {
$post_id = $request->get_param('post_id');
$token = wp_generate_password(32, false);
set_transient("preview_token_{$post_id}", $token, 300);
return ['token' => $token, 'url' => "/api/preview?id={$post_id}&token={$token}"];
},
'permission_callback' => function () {
return current_user_can('edit_posts');
},
]);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Next.js preview API route
// app/api/preview/route.ts
export async function GET(request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const token = searchParams.get('token');

// Verify token with WordPress
const valid = await verifyPreviewToken(id, token);
if (!valid) {
return Response.json({ error: 'Invalid token' }, { status: 401 });
}

// Fetch draft content with authentication
const post = await fetchDraftPost(id);

// Enable Next.js draft mode
const { draftMode } = await import('next/headers');
draftMode().enable();

// Redirect to the post page
return Response.redirect(new URL(`/blog/${post.slug}`, request.url));
}

Common mistakes

Underestimating the plugin gap: WordPress plugins add frontend functionality (contact forms, SEO, comments, e-commerce). In headless mode, you rebuild all of that in your frontend. Budget for this.

Not handling WordPress HTML in content: The content field returns raw HTML from the WordPress editor. Your frontend needs to handle embedded images, iframes, Gutenberg block markup, and shortcodes.

1
2
3
4
5
6
7
8
9
// You might need to process WordPress HTML
function processWordPressContent(html) {
// Replace WordPress image URLs with your CDN
html = html.replace(/src="https:\/\/wp\.example\.com\/wp-content/g,
'src="https://cdn.example.com/wp-content');
// Handle Gutenberg block classes
// Handle embedded videos
return html;
}

Ignoring cache invalidation: When an editor publishes a post, your frontend cache needs to invalidate. Set up webhooks from WordPress to trigger rebuilds or cache purges.

Over-fetching with GraphQL: Request only the fields you need. Fetching full post content for a listing page wastes bandwidth.

Production tradeoffs

Hosting complexity: You now run two services (WordPress backend + frontend app) instead of one. This means two deployments, two sets of monitoring, and two potential points of failure.

Cost: WordPress hosting + Vercel/Netlify hosting + potentially a CDN. Compare against a single managed WordPress host.

Editor experience: Editors lose real-time preview with the actual theme. Content preview requires additional engineering. Some editors find this frustrating.

SEO: With static generation or server-side rendering, SEO is fine. With client-side rendering only, you risk SEO problems. Always use SSR or SSG for content pages.

FAQ

Can I use the REST API instead of GraphQL?

Yes. WordPress includes a REST API by default. It is simpler to set up but returns fixed response shapes. GraphQL is better when you need flexible queries or are fetching from multiple content types in a single request.

Is WooCommerce compatible with headless WordPress?

Partially. Product catalog and cart functionality work via the REST API or WooCommerce’s GraphQL extension. Checkout is complex and most teams keep it on WordPress or use a dedicated payment service.

Should I use WordPress.com or self-hosted for headless?

Self-hosted gives you full control over plugins and GraphQL extensions. WordPress.com restricts plugin installation, making WPGraphQL unavailable on lower tiers.

Next steps

Start by installing WPGraphQL on an existing WordPress site and exploring the GraphQL IDE. Query your existing content to see how it maps to the schema. Then build a minimal Next.js page that renders one post. The complexity grows from there, but the foundation is straightforward.

For building the GraphQL API layer, the GraphQL for PHP developers guide covers schema design and performance patterns. For understanding WordPress theme development, the existing WordPress theme guide provides the foundational knowledge of how WordPress structures content.