WordPress

How to Build a Headless Website Using WordPress as a CMS

Author By Chandan Kumar
March 6, 2026
8 min read
Share:
build headless website using wordpress

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

PlanConfiguring the App RouterUse Case
Static (SSG)When no dynamic APIs are used, there are no cookies(), headers(), searchParams, or uncached fetchMarketing, blogs, docs
ISRexport const revalidate = NBig sites that get updates often
SSRexport 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.

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.

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
Simplifying WordPress REST API: Integration, Usage, and Benefits
WordPress
Simplifying WordPress REST API: Integration, Usage, and Benefits
WordPress CMS is globally popular and empowers over 43% (501.28 million) of websites as of January 2025. WordPress REST API is a flexible and robust tool that enables developers to link WP with other apps and services. This REST API tutorial highlights WordPress REST API integration, utilization, and paybacks for WordPress users and developers.  What […]
Chandan Kumar March 19, 2025