Headless WordPress gives you the world’s best content management interface connected to a modern frontend stack with full control over performance, design, and deployment. This is the exact setup I use for this site and several client projects.
This guide covers everything: WordPress configuration, WPGraphQL setup, custom post types, the Next.js data layer, ISR, and deployment on Vercel.
Why Headless WordPress
WordPress powers 43% of the web. The admin interface is mature, content editors know it, and the plugin ecosystem is deep. But the traditional theme system produces slow, hard-to-maintain frontends.
Going headless keeps everything good about WordPress (the CMS) and replaces everything problematic (the frontend) with Next.js. You get:
- Full control over the frontend stack, performance, and design system
- Static generation and ISR via Vercel for sub-100ms page loads
- TypeScript, component architecture, and modern tooling
- WordPress handles content, Next.js handles presentation
The tradeoff: you give up traditional WordPress themes and plugins that modify the frontend. You also add infrastructure complexity — two deployments instead of one.
WordPress Setup
Install these plugins:
- WPGraphQL — Exposes WordPress content as a GraphQL API. Required.
- WPGraphQL for ACF — Exposes Advanced Custom Fields data through GraphQL. Required if you use ACF.
- Advanced Custom Fields (ACF) — For structured custom fields on posts and custom post types.
- Rank Math SEO — Optional but recommended. WPGraphQL for Rank Math exposes SEO metadata through the API.
After installing WPGraphQL, your GraphQL endpoint lives at:https://your-wordpress-site.com/graphql
Test it by visiting that URL in your browser — you should see the GraphiQL IDE.
Configure CORS
WPGraphQL needs to allow requests from your Next.js frontend. Add this to your wp-config.php or a custom plugin:
add_filter('graphql_response_headers_to_send', function($headers) {
$headers['Access-Control-Allow-Origin'] = 'https://your-nextjs-site.com';
$headers['Access-Control-Allow-Headers'] = 'Content-Type';
return $headers;
});
For local development, use http://localhost:3000 or * temporarily.
Custom Post Types
Standard WordPress posts work out of the box with WPGraphQL. For custom post types (case studies, products, team members), register them with show_in_graphql set to true:
register_post_type('case_study', [
'labels' => ['name' => 'Case Studies', 'singular_name' => 'Case Study'],
'public' => true,
'has_archive' => true,
'supports' => ['title', 'editor', 'thumbnail', 'excerpt'],
'show_in_graphql' => true,
'graphql_single_name' => 'caseStudy',
'graphql_plural_name' => 'caseStudies',
]);
Add this to a custom plugin (not functions.php — custom post types belong in plugins so they survive theme changes).
The Next.js Data Layer
Install the GraphQL client:
npm install graphql-request graphql
Create the client in lib/wordpress.ts:
import { GraphQLClient } from 'graphql-request';
const WORDPRESS_API_URL = process.env.NEXT_PUBLIC_WORDPRESS_API_URL!;
const client = new GraphQLClient(WORDPRESS_API_URL);
export async function fetchWordPress<T>(
query: string,
variables?: Record<string, unknown>
): Promise<T> {
try {
return await client.request<T>(query, variables);
} catch (error) {
console.error('GraphQL request failed:', error);
throw error;
}
}
export const REVALIDATE_INTERVAL = 60; // seconds
Write your queries in lib/queries.ts. Keep them in one file for easy maintenance:
// GET_ALL_POSTS
query GetAllPosts($first: Int = 10) {
posts(first: $first, where: { status: PUBLISH }) {
nodes {
id
slug
title
excerpt
date
featuredImage {
node { sourceUrl altText }
}
categories {
nodes { name slug }
}
}
}
}
// GET_POST_BY_SLUG
query GetPostBySlug($slug: ID!) {
post(id: $slug, idType: SLUG) {
id
slug
title
content
excerpt
date
modified
featuredImage {
node { sourceUrl altText }
}
categories {
nodes { name slug }
}
author {
node { name }
}
}
}
Pages and Data Fetching
Blog index page — fetch all posts, render the list:
// app/blog/page.tsx
import { fetchWordPress, REVALIDATE_INTERVAL } from '@/lib/wordpress';
import { GET_ALL_POSTS } from '@/lib/queries';
export const revalidate = REVALIDATE_INTERVAL;
export default async function BlogPage() {
const data = await fetchWordPress<PostsResponse>(
GET_ALL_POSTS, { first: 20 }
);
const posts = data.posts.nodes;
return posts.map(post => PostCard({ key: post.id, post }));
}
Individual post page — generate static params at build time, fetch by slug:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const data = await fetchWordPress<SlugsResponse>(
GET_ALL_POST_SLUGS
);
return data.posts.nodes.map(post => ({
slug: post.slug
}));
}
export default async function PostPage({
params
}: {
params: { slug: string }
}) {
const data = await fetchWordPress<PostResponse>(
GET_POST_BY_SLUG, { slug: params.slug }
);
const post = data.post;
if (!post) notFound();
// Render WordPress HTML content
// Use dangerouslySetInnerHTML with
// @tailwindcss/typography prose classes
return post.content;
}
ISR and Revalidation
export const revalidate = 60 on a page tells Next.js to regenerate the page in the background at most every 60 seconds. Visitors always see a cached page (fast), and the cache stays fresh.
For on-demand revalidation — so the site updates immediately when you publish in WordPress — add a revalidation webhook:
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json(
{ message: 'Unauthorized' },
{ status: 401 }
);
}
revalidatePath('/blog');
revalidatePath('/');
return NextResponse.json({ revalidated: true });
}
In WordPress, install the WPGraphQL Smart Cache plugin or use the “Save Post” hook to POST to this endpoint whenever content is published.
Deployment
Environment variables in Vercel:
NEXT_PUBLIC_WORDPRESS_API_URL=https://cms.your-site.com/graphql
REVALIDATION_SECRET=your_random_secret_here
NEXT_PUBLIC_SITE_URL=https://your-site.com
WordPress hosting: Your WordPress CMS needs to be on a server that’s always available since Next.js calls it at build time and during ISR. A cheap managed WordPress host (Kinsta, WP Engine, Cloudways, or even a $6/month DigitalOcean droplet with ServerPilot) works fine. The WordPress frontend is never seen by end users — only the API matters.
Image domains: Add your WordPress domain to next.config.js so next/image can optimize images from the CMS:
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cms.your-site.com'
},
],
},
};
Common Gotchas
Slugs must match exactly. The slug in your GraphQL query must match the WordPress permalink slug. If WordPress auto-generates a slug differently than expected, posts won’t be found.
ACF fields require the WPGraphQL for ACF plugin AND the field group must have GraphQL enabled. In ACF, edit the field group and scroll to the bottom — there’s a “Show in GraphQL” toggle that’s off by default.
Published status only. WPGraphQL only exposes published posts by default. Draft posts won’t appear. Use where: { status: PUBLISH } in your queries explicitly.
Content is raw HTML. WordPress Gutenberg saves content as HTML. You’ll render it with dangerouslySetInnerHTML and style it with the Tailwind prose plugin (@tailwindcss/typography).
This setup handles everything from simple blogs to complex multi-content-type sites. The pattern scales cleanly — add a new custom post type in WordPress, add a query in queries.ts, add a page in Next.js.