WordPress

How to Build a Headless Website Using WordPress as a CMS?

Chandan Kumar
By Chandan Kumar
April 2, 2026
14 min read
Share:
build headless wordpress website

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 couplings. WordPress becomes a structured content repository that you can access via its built-in REST API (/wp-json/wp/v2/) or 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 same — a theme is no longer needed to make 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. Major plugins like ACF, Yoast, and Rank Math automatically expand the schema.

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.
Then run:
````bash
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

Important: This entire frontend section uses the Next.js App Router only.

Do not mix in any Pages Router APIs. If you find these in old tutorials or Stack Overflow answers, here is what to use instead:

Pages RouterApp Router replacement
getStaticPropsfetch() inside a Server Component
getStaticPathsgenerateStaticParams()
getServerSidePropsServer Component + export const dynamic = “force-dynamic”
getInitialPropsNot supported — do not use at all

Mixing these two systems in the same project will cause silent bugs and broken builds. If you see getStaticProps or getStaticPaths anywhere in your App Router project, replace them immediately.

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
Why do both variables hold the same URL value?

WP_API_URL

  • Server-only. Used inside Server Components and API routes.
  • Never sent to the browser under any circumstances.
  • Use this one everywhere you can.

NEXT_PUBLIC_WP_API_URL

  • Bundled into the client-side JavaScript at build time.
  • Only needed when a ‘use client’ component (like ApolloProvider) needs the API URL in the browser at runtime.
  • Visible to anyone who opens the browser Network tab or reads the JS bundle.

They hold the same value in simple setups, but keeping them as two separate variables from the start matters because in staging or more complex setups, they will differ. For example:

WP_API_URLhttp://10.0.0.5 Internal server addressThe browser cannot reach this
NEXT_PUBLIC_WP_API_URLhttps://staging-cms.example.comThe browser can reach this

If you only had one variable, you would have to do a painful refactor the moment your WordPress backend moves behind a private network.

Note: Never put Application Passwords, JWT secrets, or any credentials inside a NEXT_PUBLIC_ variable. They are fully exposed to every user visiting your site.

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}>
{/* post.title.rendered is sanitized by WordPress core, but never passes
arbitrary user-supplied strings here — dangerouslySetInnerHTML
bypasses React's XSS protection entirely */}
 <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.

```tsx
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
// getPost is defined above the component that uses it, in the same file.
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 } }
}
}
}`,
      // ⚠️  The URI here must exactly match your WordPress permalink
// structure (Settings → Permalinks in your WordPress dashboard).
//
// For the default "Post name" structure, it looks like /posts/{slug}/
// but do not assume this — verify it yourself by:
//   1. Fetching any post from the REST API
//   2. Looking at the `link` field in the response
//   3. Using that exact pattern here
//
// A wrong URI does not throw an error or return a 404.
// nodeByUri silently returns null, which can waste hours of debugging.
variables: { uri: `/posts/${slug}/` },
}),
next: { revalidate: 3600 },
});
const { data } = await res.json();
return data.nodeByUri;
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;  // Next.js 15+ — params is a Promise, must be awaited
const post = await getPost(slug);
if (!post) notFound();  // getPost returns null when nodeByUri finds nothing
// post.content is the rendered HTML from WordPress — already sanitized by wp_kses_post()
return <article dangerouslySetInnerHTML={{ __html: post.content }} />;
}
```

Server Components vs Client Components: which Apollo setup do you need

For Server Components, use plain fetch() as shown above. No Apollo package is needed; there is no client bundle cost, and Next.js handles caching.

Only reach for Apollo Client when you need to use `useQuery` or `useMutation` inside a Client Component (e.g., for user-specific or real-time data).

In that case, you must:

  • Install the packages: npm install @apollo/client graphql
  • Create an ApolloClient instance
  • Wrap your root layout with ApolloProvider
  • UseQuery will throw at runtime if it is called in a server component.
```tsx
// app/providers.tsx
'use client'; // Required — ApolloProvider is a Client Component
import { ApolloProvider, ApolloClient, InMemoryCache } from '@apollo/client';
const client = new ApolloClient({
uri: `${process.env.NEXT_PUBLIC_WP_API_URL}/graphql`, // NEXT_PUBLIC_ required: this runs in the browser
cache: new InMemoryCache(),
});
export function Providers({ children }: { children: React.ReactNode }) {
return <ApolloProvider client={client}>{children}</ApolloProvider>;
}
```
```tsx
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>  {/* wraps the entire tree */}
</body>
</html>
);
}
```

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’ it 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 the fallback, ‘blocking,’ in the Pages Router.

// 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: Promise<{ slug: string }> }) {
const { slug } = await params; // Next.js 15+ — params is a Promise, must be awaited
const res = await fetch(
`${process.env.WP_API_URL}/wp-json/wp/v2/posts?slug=${slug}`
);
const posts = await res.json();
if (!posts.length) notFound();
  // WordPress sanitizes content.rendered server-side via wp_kses_post().
// Safe here, but do not use dangerouslySetInnerHTML with any value
// that hasn't gone through server-side sanitization first.
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.

Note: REVALIDATION_SECRET must be defined before this code will work. Add the following line to your wp-config.php, using a long random string that matches the REVALIDATION_SECRET environment variable set on your frontend:

define('REVALIDATION_SECRET', 'your-long-random-secret-here');

// 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;
$response = 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'],
'timeout' => 5,
]);
// Log failures so they aren't silently lost.
// For high-traffic or critical sites, replace this with a proper
// queue (e.g., Action Scheduler) so failed pings can be retried.
if (is_wp_error($response)) {
error_log(
'[Revalidation] wp_remote_post failed for slug "'
. get_post_field('post_name', $post_id) . '": '
. $response->get_error_message()
);
}
});
// 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

Important: The PostPage component in this section replaces the one from the Dynamic Routes section. Do not keep both. Your final app/posts/[slug]/page.tsx should use the version at the end of this section. The complete file contains these three exports in this order:

  • generateStaticParams — from the Dynamic Routes section (keep as-is)
  • generateMetadata — from this section
  • PostPage (default export) — from this section (replaces the Dynamic Routes version)

Before writing generateMetadata or the page component, extract the post fetch into a shared helper file. Both functions require the same post data, which avoids duplicating the fetch logic in two places.

```ts
// lib/getPost.ts
export async function getPostBySlug(slug: string) {
const res = await fetch(
`${process.env.WP_API_URL}/wp-json/wp/v2/posts?slug=${slug}&_embed`
);
const posts = await res.json();
return posts[0] ?? null;  // returns null if post doesn't exist
}
```

Note: Next.js automatically deduplicates identical fetch() calls within the same render pass. Calling getPostBySlug in both generateMetadata and the page component does not result in two network requests — only one is made.

Yoast SEO and Rank Math expose their metadata via REST. Fetch per route and connect to generateMetadata:

```ts
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPostBySlug } from '@/lib/getPost';
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; // Next.js 15+ — params is a Promise, must be awaited
const post = await getPostBySlug(slug);  // uses shared helper
if (!post) return {};                    // return empty metadata if post not found
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 inside the page component for better results. The JSON-LD script and the page content live together in PostPage, not inside generateMetadata, because generateMetadata only returns metadata objects; it does not render HTML.

tsx
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; // Next.js 15+ — params is a Promise, must be awaited
const post = await getPostBySlug(slug);  // same helper, no extra network request
if (!post) notFound();
return (
<>
<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.yoast_head_json.og_image?.[0]?.url,
}),
}}
/>
{/* WordPress sanitizes content.rendered server-side via wp_kses_post() */}
<article dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
</>
);
}

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 ignores the rule from reaching 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 reach, or use IP allowlisting at the server or CDN level. Never 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 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.

Chandan Kumar

Chandan Kumar

Chandan Kumar doesn't just write code; he builds digital legacies. As the Founder and Team Lead at AvyaTech, Chandan combines high-level strategy with granular technical expertise to turn "what if" into "it's live." When he’s not steering his team through complex development sprints, he’s busy architecting the future of scalable, user-first technology.

Related Articles

Continue reading with these hand-picked articles on similar topics.

How to Build a Headless Website Using WordPress as a CMS
WordPress
How to Build a Headless Website Using WordPress as a CMS
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, […]
Chandan Kumar March 6, 2026
The Beginner’s Guide to Headless WordPress with React and WPGraphQL
WordPress
The Beginner’s Guide to Headless WordPress with React and WPGraphQL
Headless WordPress with front-end JavaScript library React and WP plugin WPGraphQL lets you use WordPress purely for content management.
Chandan Kumar August 4, 2025
Top Free & Paid Optimization Plugins to Supercharge WordPress Sites in 2025
WordPress
Top Free & Paid Optimization Plugins to Supercharge WordPress Sites in 2025
Struggling with a slow WordPress site? Discover the best speed optimization plugins to boost performance, enhance UX, and rank higher on Google.
Chandan Kumar March 21, 2025