The basic structure of WordPress is simple: a request is processed by PHP, which queries the MySQL database for content and uses a theme to render the final HTML. That pipeline connects the CMS and the frontend, which works fine until you need to serve content on more than one channel, reach a performance limit, or put a JS team on a PHP codebase.
Headless breaks those couples. WordPress turns into a structured content repository that you can access through its built-in REST API (/wp-json/wp/v2/) or GraphQL through WPGraphQL. Any HTTP client, like Next.js, a mobile app, or a voice interface, can be a valid consumer. The editorial process in /wp-admin stays the samea theme that makes HTML.
This guide is for Next.js with the App Router (Next.js 13+). The App Router and the Pages Router don’t work well together. The App Router APIs are generateStaticParams, generateMetadata, and revalidate. Pages Router APIs include getStaticProps, getStaticPaths, and getServerSideProps. Don’t mix them.
GraphQL vs. REST API
You don’t need any plugins to use REST. It can do CRUD on posts, pages, media, custom post types, and taxonomies. The schema is automatically expanded by major plugins like ACF, Yoast, and Rank Math.
GET /wp-json/wp/v2/posts # all published posts
GET /wp-json/wp/v2/pages?slug=about # single page by slug
GET /wp-json/wp/v2/posts?_embed # posts + embedded media and author WPGraphQL adds a /graphql endpoint that lets you choose exactly which fields you want, so you don’t get too much data or have to make an extra trip for related entities.
query GetPost($uri: String!) {
nodeByUri(uri: $uri) {
... on Post {
title
content
author { node { name avatar { url } } }
featuredImage { node { sourceUrl altText } }
}
}
} The current WPGraphQL v1.x pattern is nodeByUri. postBy(slug:) was removed in v1.0 and should not be used in new projects.
Note: Use REST for most projects. WPGraphQL is a good choice when your content model has complicated relationships or when the size of the payload is a real problem.
Setting up WordPress
Installation
To install, download the WP core, create a WP config file with the following information: dbname=headless_wp, dbuser=root, and dbpass=secret.
The run:
wp core install \
--url=localhost:8080 \
--title="Headless CMS" \
--admin_user=admin \
[email protected] Set up a simple blank theme. In a headless setup, the theme doesn’t change anything visually; its only job is to register custom post types and keep functions.php small.
CORS
Not init, but hook into rest_api_init. Using init adds headers to every request made to WordPress. You only want headers that apply to the REST API.
// functions.php
add_action('rest_api_init', function () {
remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
add_filter('rest_pre_serve_request', function ($value) {
$allowed = ['http://localhost:3000', 'https://your-frontend.com'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
}
return $value;
});
}, 15); The Content Model
Before you write any code for the front end, you need to define Custom Post Types. Changing the content model breaks API users.
register_post_type('case_study', [
'label' => 'Case Studies',
'public' => true,
'show_in_rest' => true, // required for REST API access
'supports' => ['title', 'editor', 'thumbnail', 'custom-fields'],
]); Field groups, not individual fields, control REST exposure. You also need ACF PRO or the ACF-to-REST-API plugin.
acf_add_local_field_group([
'key' => 'group_case_study',
'title' => 'Case Study Fields',
'show_in_rest' => true, // ← correct location: field GROUP, not individual field
'fields' => [[
'key' => 'field_client',
'label' => 'Client',
'name' => 'client',
'type' => 'text',
// no show_in_rest here
]],
'location' => [[[
'param' => 'post_type', 'operator' => '==', 'value' => 'case_study',
]]],
]); You need ACF PRO 6.1 or higher for this. If you’re using an older version, you can use the acf-to-rest-api plugin to automatically show field values, or you can register fields by hand with register_rest_field().
Authentication
Application Passwords (a core feature since 5.6) handle authentication between servers. Needs HTTPS because sending credentials over plain HTTP makes them visible in the request.
curl -X POST https://cms.example.com/wp-json/wp/v2/posts \
-H "Authorization: Basic $(echo -n 'admin:xxxx xxxx xxxx xxxx xxxx xxxx' | base64)" \
-H "Content-Type: application/json" \
-d '{"title": "Draft", "status": "draft"}'
Use JWT tokens in server-side API routes for frontend-authenticated flows (like gated content and form submissions). Never give the client access to credentials.
Frontend: Next.js App Router
Environment Variables
# .env.local
WP_API_URL=https://staging-cms.example.com
NEXT_PUBLIC_WP_API_URL=https://staging-cms.example.com
# .env.production
WP_API_URL=https://cms.example.com
NEXT_PUBLIC_WP_API_URL=https://cms.example.com WP_API_URL is private and only accessible in Server Components; it is never exposed to the browser. Conversely, NEXT_PUBLIC_WP_API_URL is bundled into the client-side code, making it available wherever ‘use client’ is used, such as in an ApolloProvider. Never store sensitive data, like Application Passwords or secret keys, in NEXT_PUBLIC_ variables, as they are visible to any user via the browser’s Network tab.
REST in a Server Component
// app/blog/page.tsx
export default async function BlogPage() {
const res = await fetch(
`${process.env.WP_API_URL}/wp-json/wp/v2/posts?per_page=10&_fields=id,slug,title,excerpt`,
{ next: { revalidate: 3600 } }
);
const posts = await res.json();
return (
<>
{posts.map((post) => (
<article key={post.id}>
<h2 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
</article>
))}
</>
);
} GraphQL inside a Server Component
For Server Components, just use fetch()—there’s no Apollo overhead or client bundle cost.
async function getPost(slug: string) {
const res = await fetch(`${process.env.WP_API_URL}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `query GetPost($uri: String!) {
nodeByUri(uri: $uri) {
... on Post {
title
content
featuredImage { node { sourceUrl altText } }
}
}
}`,
variables: { uri: `/posts/${slug}/` }, // URI must match WordPress permalink structure
}),
next: { revalidate: 3600 },
});
const { data } = await res.json();
return data.nodeByUri;
} Note about URI format: The URI that you give to nodeByUri must match the permalink structure that you set up in WordPress under Settings → Permalinks. Before hardcoding a pattern, check the link field in a REST API response to make sure you have the right URI for any post.
Apollo Client needs ApolloProvider to wrap the component tree if you want to use useQuery in a Client Component. Install GraphQL and @apollo/client, then wrap your root layout:
tsx// app/providers.tsx
'use client';
import { ApolloProvider, ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: `${process.env.NEXT_PUBLIC_WP_API_URL}/graphql`, // NEXT_PUBLIC_ required for client bundle
cache: new InMemoryCache(),
});
export function Providers({ children }: { children: React.ReactNode }) {
return <ApolloProvider client={client}>{children}</ApolloProvider>;
} Rendering Plan
| Plan | Configuring the App Router | Use Case |
| Static (SSG) | When no dynamic APIs are used, there are no cookies(), headers(), searchParams, or uncached fetch | Marketing, blogs, docs |
| ISR | export const revalidate = N | Big sites that get updates often |
| SSR | export const dynamic = “force-dynamic”; | Content that is tailored to you or happens in real time |
A route in the App Router is only fully static if none of its Server Components call dynamic functions or choose not to cache.
If you use `fetch()` with `{ cache: ‘no-store’ }` or `{ next: { revalidate: 0 } }` anywhere in the component tree, the whole route will be dynamic, even if `export const dynamic = ‘force-dynamic’` is not set.
Dynamic Routes
Dynamic Routes use generateStaticParams instead of getStaticPaths. The default value for dynamicParams is true, which means that paths that aren’t generated at build time can be rendered on demand and cached. This is the same as fallback: ‘blocking’ in the Pages:
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const posts = await fetch(
`${process.env.WP_API_URL}/wp-json/wp/v2/posts?per_page=100&_fields=slug`
).then((r) => r.json());
return posts.map((p: { slug: string }) => ({ slug: p.slug }));
}
export const dynamicParams = true;
export const revalidate = 60;
export default async function PostPage({ params }: { params: { slug: string } }) {
const res = await fetch(
`${process.env.WP_API_URL}/wp-json/wp/v2/posts?slug=${params.slug}`
);
const posts = await res.json();
if (!posts.length) notFound();
return <article dangerouslySetInnerHTML={{ __html: posts[0].content.rendered }} />;
} Webhooks for On-Demand Revalidation
The save_post hook runs several times for one editorial action: once for the revision, once for the post, and sometimes again for an autosave. Before making external requests, make sure to protect against all three.
// functions.php
add_action('save_post', function ($post_id) {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (wp_is_post_revision($post_id)) return;
if (get_post_status($post_id) !== 'publish') return;
wp_remote_post('https://frontend.example.com/api/revalidate', [
'body' => json_encode([
'slug' => get_post_field('post_name', $post_id),
'secret' => REVALIDATION_SECRET,
]),
'headers' => ['Content-Type' => 'application/json'],
]);
});
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const body = await req.json();
if (body.secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
}
revalidatePath(`/posts/${body.slug}`);
return NextResponse.json({ revalidated: true });
} SEO
Yoast SEO and Rank Math make their metadata available through REST. Get per route and connect to generateMetadata:
// app/posts/[slug]/page.tsx
export async function generateMetadata({ params }: { params: { slug: string } }) {
const [post] = await fetch(
`${process.env.WP_API_URL}/wp-json/wp/v2/posts?slug=${params.slug}`
).then((r) => r.json());
const seo = post.yoast_head_json;
return {
title: seo.title,
description: seo.description,
alternates: { canonical: seo.canonical },
openGraph: {
title: seo.og_title ?? seo.title,
description: seo.og_description ?? seo.description,
images: seo.og_image?.map((img: { url: string }) => ({ url: img.url })) ?? [],
},
};
} Add JSON-LD for better results:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title.rendered,
datePublished: post.date,
dateModified: post.modified,
author: { '@type': 'Person', name: post._embedded?.author?.[0]?.name },
image: post._embedded?.['wp:featuredmedia']?.[0]?.source_url,
}),
}}
/> Prevent indexing of the WordPress backend. This only applies to the backend host, not the frontend domain.
# robots.txt — backend host only
User-agent: *
Disallow: /wp-admin/
Disallow: /wp-json/
Disallow: /wp-login.php Warning: robots.txt is not a security measure; it’s just a suggestion. Googlebot and other compliant crawlers will follow it, but it doesn’t stop scripts, bots, or anything else that doesn’t follow the rule from getting to the /wp-json/ endpoints. To really limit API access, put the WordPress backend behind a firewall or private network that only your frontend server can access, or use IP allowlisting at the server or CDN level. Don’t ever use robots.txt to control access.
When Not to Go Headless
- Projects with a lot of plugins. You need to rebuild WooCommerce checkout, Gravity Forms, and Elementor layouts as frontend components. The benefits of moving often don’t outweigh the costs.
- Websites that are small or not updated often. A regular WordPress installation with WP Rocket or LiteSpeed Cache works just as well, but you don’t have to keep up with two deployment pipelines.
- No ability to do frontend engineering. To use Headless, you need to know how to use modern JS, APIs, CI/CD, and CDN configuration. Without it, the project’s complexity works against it.
- Big groups of editors. If you don’t use Next.js Draft Mode with WordPress preview tokens, editors lose the WYSIWYG preview. This is fixable, but it costs money.
“We want to use React” is not a good enough reason to go headless. Use this architecture only when it solves a real problem, like delivering content across multiple channels, scaling performance, or aligning team skills.