WordPress as a Headless CMS for a React App: The Setup We Use
How to set up WordPress as a headless CMS for a React app: WPGraphQL, ACF Pro, custom post types, and the preview-mode hookup that keeps editors happy.
Most articles about headless WordPress are either a definition with no code, or a vendor pitch, or a WPGraphQL quickstart that installs one plugin, shows one query, and stops. None of them prepare you for the two things that actually decide whether a headless build succeeds: whether editors can preview their drafts, and whether the client still has a CMS that feels like a CMS. This article is the setup our team ships, end to end, with those two hard parts treated as the main event rather than a footnote.
So here is how to set up WordPress as a headless CMS for a React app, stated plainly before the detail. You install WPGraphQL so your frontend can query content over a single GraphQL endpoint. You install Advanced Custom Fields Pro along with the WPGraphQL for ACF bridge, so your structured custom fields appear in that same API. You register the custom post types your content model needs, with the GraphQL flags switched on so they show up in the schema. Then you do the two jobs the quickstarts skip: you wire up preview mode so editors can see drafts on the React frontend, and you tidy wp-admin so the client is not left with broken buttons and a useless block editor. The React app, which in practice means Next.js, queries that GraphQL endpoint and renders every page. That is the whole shape of it, and the rest of this piece is each part in order.
What "headless" actually changes
A normal WordPress install is one application doing two jobs. It stores your content and manages your editors, and it also renders the public website through a PHP theme. The CMS and the presentation layer are welded together. Headless WordPress separates them. You keep WordPress doing what it is genuinely excellent at, which is the editing interface, user roles, the media library, revisions, scheduling, the whole content management side. You switch off the PHP theme as the public-facing site. In its place, a separate frontend application fetches content over an API and renders the pages.
That frontend is the "head." Remove it and WordPress is "headless": a content store and an editing UI, reachable only through an API. The API is the contract between the two halves, and choosing it well is the first real decision. WordPress ships a REST API that works, but the agency-grade choice is GraphQL through the WPGraphQL plugin. With REST you get fixed endpoint shapes, so a single page often means several requests and a lot of fields you never use. With GraphQL the frontend asks for exactly the fields it needs in one request and gets a response shaped like the query. For a content-heavy React app rendering many components per page, that difference is the practical reason headless builds standardise on WPGraphQL.
It is worth being honest up front that this is more moving parts than a normal WordPress site. You now have two applications, two deployments, and a schema contract between them. That cost is worth paying when you want a custom React frontend, top-tier performance, or a frontend team that owns the UI. It is overkill for a simple brochure site that a good custom theme would serve perfectly well. Headless is a deliberate choice, not an upgrade.
The API layer: WPGraphQL
WPGraphQL is a free plugin, and installing it is the entire WPGraphQL setup for the basics. It adds a single endpoint, usually at /graphql, and exposes your posts, pages, menus, media, taxonomies, and users as a typed schema. The frontend sends one query describing the data it wants and receives one JSON response in that exact shape.
Here is the kind of query a blog listing component would send, asking only for the fields a post card actually renders:
query GetPosts {
posts(first: 10) {
nodes {
id
title
slug
excerpt
date
featuredImage {
node {
sourceUrl
altText
}
}
}
}
}
Nothing is over-fetched. The card needs a title, a slug to link to, an excerpt, a date, and an image, and that is precisely what comes back. WPGraphQL also installs a GraphiQL IDE inside wp-admin, which lets the development team explore the schema interactively and makes it effectively self-documenting. For a team that is new to a project, that explorer is the fastest way to learn what content is queryable.
Structured content: ACF Pro and WPGraphQL for ACF
Real sites are not made of one big blob of post content. A landing page is a hero section, a row of feature blocks, a testimonial, a call to action, and each of those is a defined, structured field. Advanced Custom Fields Pro is the agency standard for modelling that structure, and using it as the backbone of your headless content model is what keeps the editor sane later.
There is a catch that surprises people. ACF fields do not appear in the WPGraphQL schema on their own. Install WPGraphQL and ACF side by side and the API knows nothing about your field groups. The bridge is a separate extension, WPGraphQL for ACF, now maintained officially under the WPGraphQL umbrella. Once it is installed, each ACF field group gains a "Show in GraphQL" toggle in its settings, along with a field where you set the GraphQL field name for that group. Enable the toggle, name the group, and the fields become a type on whatever post type the group is attached to.
A query for a landing page built this way looks like this, where landingPageFields is the GraphQL name set on the field group:
query GetLandingPage($slug: ID!) {
page(id: $slug, idType: URI) {
title
landingPageFields {
heroHeading
heroSubheading
ctaLabel
ctaUrl
}
}
}
Two things are worth knowing as a team. WPGraphQL for ACF maps field types intelligently, so a repeater becomes a list of objects and a flexible content field becomes a union of layout types, which means complex page builders translate cleanly into the schema. And the GraphQL field names you choose are a contract. Renaming a field group or a field later is a breaking change for every React query that references it, so name them deliberately the first time, the way you would name a public API.
Custom post types, registered the headless way
Most sites need more than posts and pages. Case studies, team members, services, testimonials, office locations: each of those is a custom post type. In a headless build you register custom post types in code, in a small plugin or your functions file, rather than with a UI plugin, because the registration has to be version-controlled and it has to set the headless-specific arguments.
Two arguments matter. show_in_graphql must be true, or WPGraphQL simply cannot see the post type. And you must provide both graphql_single_name and graphql_plural_name, because the schema needs a singular and a plural name for the type, and WPGraphQL will throw an error at registration if either is missing. Here is a case study post type registered correctly for headless:
register_post_type('case_study', [
'public' => true,
'label' => 'Case Studies',
'show_in_rest' => true,
'show_in_graphql' => true,
'graphql_single_name' => 'caseStudy',
'graphql_plural_name' => 'caseStudies',
'supports' => ['title', 'editor', 'thumbnail', 'revisions'],
'has_archive' => false,
]);
A few of those choices are deliberate. show_in_rest is set so the block editor still works for this type. has_archive is false because the archive page is now the React app's responsibility, not WordPress's. But public stays true and the slug stays sensible, because preview links and node resolution still rely on WordPress knowing this content is publicly addressable. The same show_in_graphql argument exists on register_taxonomy, so if your case studies need a custom taxonomy, register it the same way.
Preview mode: the thing that breaks, and how to keep it working
This is the single most common failure in headless WordPress, and it is worth slowing down for. On a normal WordPress site, an editor clicks "Preview" and sees their draft because the theme checks their login cookie and renders the unpublished content. Headless breaks that completely. The React frontend is a separate application. By default it only ever fetches published content, because a GraphQL query for a draft post returns nothing to an unauthenticated request. So the client clicks "Preview," lands on a 404 or the old published version, and concludes the CMS is broken.
Fixing it takes two halves, and both are mandatory. The first half lives in the frontend. Next.js provides Draft Mode, previously called Preview Mode: a special API route that sets a cookie flipping the app into draft rendering, where pages fetch with authentication and ask for the draft revision instead of the published node. That route validates a secret token, so not just anyone can trigger it.
The second half lives in WordPress, and it is the part people forget. WordPress's native Preview button points at the WordPress permalink, which in a headless setup shows raw or unstyled output, or nothing. You have to rewrite that link so it points at the frontend's preview route instead:
add_filter('preview_post_link', function ($link, $post) {
$secret = WP_PREVIEW_SECRET;
$frontend = 'https://app.example.com';
return "{$frontend}/api/preview?secret={$secret}"
. "&id={$post->ID}&type={$post->post_type}";
}, 10, 2);
Now the editor's Preview button sends them to the React app, which reads the id, verifies the secret, and renders the draft. For that draft to actually come back from WPGraphQL, the preview route must make an authenticated request, using an Application Password or a JWT from the WPGraphQL JWT Authentication plugin, because WPGraphQL respects WordPress capabilities and only an authenticated request with edit rights can read unpublished content. WPGraphQL also exposes an asPreview: true argument on single-node queries, which fetches the latest revision rather than the published parent, and the preview route uses it.
Here is the part that quietly rots and generates the "preview was working last month" ticket. The preview secret and the API credential are shared configuration between two applications. Rotate the WordPress Application Password and forget to update the frontend environment variable, and preview silently starts returning 401. The frontend preview route and the WordPress filter must also agree on the exact URL shape, query parameter names included. Treat that shared secret as a single documented piece of config, and do the authenticated fetch server-side inside the Next.js route so the credential never reaches the browser and you sidestep cross-origin headaches between the two domains.
Keeping the editor experience intact
The other way headless builds fail is subtler. The site is wonderful for developers and miserable for the client. wp-admin still loads, but every "View Post" button leads to a broken page, the block editor previews nothing meaningful, and the client feels like they lost their website. They did not lose the CMS. The team just never finished the headless setup, and the editor experience is the half that got skipped.
Keeping it intact means a handful of deliberate jobs. First, redirect the WordPress front end. With no theme rendering the public site, visiting the WordPress domain directly should not show a broken half-site, so a small must-use plugin on template_redirect, or a host-level rule, sends that front end to the React app while leaving wp-admin and the GraphQL endpoint reachable. Second, fix the permalinks. The "View Post" link in the admin list and the editor needs to resolve to the live React URL, which means filtering post_link, page_link, and post_type_link for custom types:
add_filter('post_link', function ($url, $post) {
return 'https://app.example.com/blog/' . $post->post_name;
}, 10, 2);
Third, decide honestly how content is authored. You either fully support Gutenberg blocks on the frontend, parsing block content and rendering a React component per block, which is real work, or you build the content model in ACF field groups so editors fill in clearly labelled fields that map one to one onto React components. Most agencies use ACF for structured templates like landing pages and case studies and support a curated set of blocks for body copy. The wrong move, the one that breaks the editor experience, is leaving the client full Gutenberg with no frontend rendering for half the blocks they can insert.
The rest is finishing touches that matter more than they sound. Working preview, from the previous section, is itself part of the editor experience, because an editor who cannot preview does not trust the CMS. Scheduled posts still need the frontend to update when they go live, which means triggering on-demand revalidation from a publish hook or using time-based regeneration. And tidying the admin, hiding the Themes, Customizer, and Widgets menus that now do nothing, stops the client tripping over controls with no effect. The point underneath all of it: the editing experience is half the reason a client is on WordPress instead of a developer-only headless CMS. A headless build that ignores it has thrown away its main advantage.
Where headless is the right call, and where WitsCode fits
Headless WordPress is the right answer when you want a genuinely custom React frontend, when performance targets push you past what a PHP theme comfortably hits, or when a frontend team wants to own the UI without fighting the theme layer. It is the wrong answer for a straightforward brochure site, where the extra application, the extra deployment, and the schema contract buy you complexity you will not use. Choosing it well is as much about saying no as saying yes.
When it is the right call, the plugin list in this article is free and the queries are copy-pasteable, and that is genuinely most of the easy part. What a custom build with WitsCode adds is everything the quickstarts skip and everything that rots. We design the content model in ACF so editors are filling in labelled fields rather than staring at an empty block editor. We wire preview mode so it survives a secret rotation and an environment change rather than breaking the next quarter. We redirect and tidy the WordPress admin so the client still has a real, recognisable CMS. And we treat the GraphQL schema as a versioned contract, because across more than 250 sites we have learned that the WPGraphQL install is never where headless projects go wrong; the editor experience and the preview hookup are. If you are building a React app on WordPress and you want the half that is hard done properly, that finished version of this setup is the work we take on.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
WordPress Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss headless / custom / advanced wp for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.