Headless WordPress architecture: REST API, WPGraphQL and Next.js
When headless WordPress pays off, how to expose custom post types to the REST API or WPGraphQL, and how to cache and revalidate Next.js pages on publish.
Updated 8 min read

In this article
Headless WordPress means using WordPress only as the content back end, while a separate front end, typically a Next.js app, fetches content through the built-in REST API or the WPGraphQL plugin and renders the pages. It makes sense when the same content feeds several front ends or when your team already ships a JavaScript application; for an ordinary marketing site or blog, a traditional block theme is usually cheaper and faster to run.
This guide covers the decision, a reference architecture that holds up in production, the content model code you need in WordPress, and the cache invalidation that most first headless projects get wrong. Versions are current as of October 2026: WordPress 7.1.3, WPGraphQL 2.23.1 and Next.js 16.4.
Should you go headless?
| Go headless when | Stay with a theme when |
|---|---|
| One content source feeds a website, a mobile app and other channels | The website is the only channel |
| Your team builds and maintains a JavaScript front end anyway | Editors rely on themes, the Site Editor and visual page building |
| WordPress content must sit inside a larger application (shop, dashboard, docs) | You depend on plugins that output front-end features: forms, SEO tags, memberships, comments |
| You want the CMS on a private hostname, away from public traffic | You have no budget to rebuild previews, search, redirects and sitemaps |
The last row is the real cost. In a traditional site, WordPress plugins produce meta tags, sitemaps, redirects, forms and previews for free. Headless, each one becomes front-end work.

A reference architecture
- WordPress (the CMS) on its own hostname such as
cms.example.com, used by editors and by the front end's server. Keep wp-admin behind your usual hardening. - Content API: the core REST API under
/wp-json/wp/v2/, or GraphQL at/graphqlwith WPGraphQL. - Front end: a Next.js app that fetches on the server, caches responses and renders HTML. Visitors never call WordPress directly.
- Cache invalidation: WordPress sends a signed webhook when content changes, and the front end refreshes only the affected data.
- Media: images served from WordPress uploads or an object store behind a CDN, referenced by URL in API responses.
Step 1: Model content so the API exposes it
Custom post types, taxonomies and meta are invisible to the REST API unless you opt in. The handbook's rule is simple: set show_in_rest when registering, and optionally change rest_base for a nicer URL. Registered meta also needs show_in_rest, and the post type must support custom-fields or the meta will not appear.
<?php
/**
* Plugin Name: Acme Content Model
* Description: Event post type, taxonomy and meta for a headless front end.
* Version: 1.0.0
*/
add_action( 'init', 'acme_register_content_model' );
function acme_register_content_model() {
register_post_type(
'event',
array(
'labels' => array(
'name' => __( 'Events', 'acme' ),
'singular_name' => __( 'Event', 'acme' ),
),
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-calendar-alt',
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ),
'show_in_rest' => true, // Adds /wp-json/wp/v2/events.
'rest_base' => 'events',
'show_in_graphql' => true, // Read by WPGraphQL when active.
'graphql_single_name' => 'event',
'graphql_plural_name' => 'events',
)
);
register_taxonomy(
'event_type',
'event',
array(
'labels' => array( 'name' => __( 'Event types', 'acme' ) ),
'hierarchical' => true,
'show_in_rest' => true,
'rest_base' => 'event-types',
)
);
register_post_meta(
'event',
'event_start',
array(
'type' => 'string',
'description' => 'Start date and time, ISO 8601.',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => static function () {
return current_user_can( 'edit_posts' );
},
)
);
}Ship this as a plugin, not inside a theme: in a headless setup the theme is barely used, and content types must survive a theme change. For array or object meta, show_in_rest must be an array with a JSON Schema, including items for arrays.
Step 2: Fetch only what each page needs
The REST API supports global parameters that keep responses small. _fields limits the properties returned, and WordPress skips the work for fields you did not ask for. _embed includes linked resources such as the featured image or terms, and since WordPress 5.4 you can name which ones.
# Latest 20 events, only the fields the listing page uses
curl "https://cms.example.com/wp-json/wp/v2/events?per_page=20&_fields=id,slug,title,excerpt,meta.event_start"
# One event with its featured image and terms embedded
curl "https://cms.example.com/wp-json/wp/v2/events?slug=launch-night&_embed=wp:featuredmedia,wp:term"Collections are paginated: per_page accepts 1 to 100, and the X-WP-Total and X-WP-TotalPages headers tell you how many records and pages exist.
With WPGraphQL active, the same model is available as a typed schema. A listing query looks like this:
query LatestEvents {
events(first: 20) {
nodes {
databaseId
slug
title
excerpt
}
}
}WPGraphQL does not expose registered meta automatically; you add fields with its own registration API or an extension.
REST API or WPGraphQL?
| Core REST API | WPGraphQL | |
|---|---|---|
| Availability | Built into core | Plugin (2.23.1, requires WordPress 6.0+ and PHP 7.4+) |
| Response shape | Fixed per endpoint, trimmed with _fields | Exactly what the query asks for |
| Related data | _embed or extra requests | One nested query |
| HTTP caching | Simple: GET URLs cache well at a CDN | Needs persisted queries or the Smart Cache extension |
| Plugin support | Most plugins register REST routes | Depends on extensions for each plugin |
A practical rule: start with REST for simple content sites; choose GraphQL when pages combine many related types and the team already uses GraphQL tooling.
Step 3: Cache in Next.js and invalidate on publish
In the Next.js App Router, fetch on the server with a revalidation window and cache tags:
// lib/wordpress.js
const API = process.env.WORDPRESS_API_URL; // e.g. https://cms.example.com/wp-json
export async function getEvents() {
const url = `${ API }/wp/v2/events?per_page=20&_fields=id,slug,title,excerpt,meta.event_start`;
const res = await fetch( url, { next: { revalidate: 3600, tags: [ 'events' ] } } );
if ( ! res.ok ) {
throw new Error( `WordPress responded with ${ res.status }` );
}
return {
items: await res.json(),
total: Number( res.headers.get( 'X-WP-Total' ) ),
};
}An hour-long window alone means edits can take an hour to appear. Fix that with a webhook. On the WordPress side, notify the front end when an event is published, updated or unpublished:
add_action( 'transition_post_status', 'acme_notify_frontend', 10, 3 );
function acme_notify_frontend( $new_status, $old_status, $post ) {
if ( 'event' !== $post->post_type || ! defined( 'ACME_REVALIDATE_URL' ) || ! defined( 'ACME_REVALIDATE_SECRET' ) ) {
return;
}
if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
return; // Drafts changing do not affect the public site.
}
wp_remote_post(
ACME_REVALIDATE_URL,
array(
'blocking' => false,
'timeout' => 2,
'headers' => array(
'Content-Type' => 'application/json',
'X-Revalidate-Secret' => ACME_REVALIDATE_SECRET,
),
'body' => wp_json_encode( array( 'tags' => array( 'events', 'event-' . $post->ID ) ) ),
)
);
}Define both constants in wp-config.php, never in code you commit. On the Next.js side, a route handler checks the secret and marks the tags stale:
// app/api/revalidate/route.js
import { revalidateTag } from 'next/cache';
import { timingSafeEqual } from 'node:crypto';
export async function POST( request ) {
const given = Buffer.from( request.headers.get( 'x-revalidate-secret' ) ?? '' );
const expected = Buffer.from( process.env.REVALIDATE_SECRET ?? '' );
if ( expected.length === 0 || given.length !== expected.length || ! timingSafeEqual( given, expected ) ) {
return Response.json( { error: 'Unauthorized' }, { status: 401 } );
}
const { tags = [] } = await request.json();
for ( const tag of tags.filter( ( t ) => typeof t === 'string' ).slice( 0, 10 ) ) {
revalidateTag( tag, 'max' );
}
return Response.json( { revalidated: true } );
}Next.js 16 expects the two-argument form of revalidateTag(); the single-argument form is deprecated. The 'max' profile serves the cached page while fresh data loads in the background.

Authentication and security
- Public content needs no credentials. Fetch published posts anonymously, and keep it that way so a leaked front-end secret cannot read drafts.
- Drafts and previews need authentication. Since WordPress 5.6, Application Passwords provide Basic authentication over HTTPS. Create one for a dedicated low-privilege user and keep it in server-side environment variables only.
- Cookie authentication with nonces only works inside WordPress itself, so it is not an option for a separate front end.
- Do not expose more than you need. Meta registered with
show_in_restis public on published posts; keep internal fields unregistered or protected.
Gotchas
- Block markup needs block styles.
content.renderedcontains HTML with core block classes. Load the block library styles in the front end or map blocks to your own components. - SEO plugins stop working on their own. Titles, canonical URLs, Open Graph tags and sitemaps must be rebuilt in the front end, usually from data the SEO plugin exposes over the API.
- Previews are a project. Plan an authenticated preview route early; editors notice its absence on day one.
- Permalinks and redirects. Keep slugs identical to the old site and port redirects, or you will lose search traffic.
- Two systems to patch. You now update WordPress, its plugins, Node.js and the framework. Budget for both.
What to do next
- Write down every front-end feature your current plugins provide and decide who rebuilds each one.
- Register your content model in a plugin with
show_in_restand test it at/wp-json/wp/v2/. - Build one listing page and one detail page with
_fields, cache tags and the revalidation webhook. - Add authenticated previews before inviting editors.
- Compare page speed and editorial effort with the theme-based site before committing fully.
Frequently asked questions
Does WordPress have an API built in for headless use?
Yes. The REST API is part of core and serves content under
/wp-json/wp/v2/. Custom post types, taxonomies and meta appear there once you register them withshow_in_rest.Is WPGraphQL better than the REST API?
Not universally. GraphQL returns exactly the fields a page asks for in one request, which suits pages that combine many related types. REST is built in, caches easily at a CDN and is supported by more plugins.
Why does my custom field not appear in the REST response?
Register it with
register_post_meta()andshow_in_rest, and make sure the post type supportscustom-fields. Array and object values also need a JSON Schema inshow_in_rest.How do I show draft previews on a headless site?
Fetch drafts on the server with an authenticated request, for example an Application Password for a dedicated user, and render them on a preview route protected by a secret. Never expose those credentials to the browser.
How many posts can one REST request return?
Up to 100, set with
per_page. Use theX-WP-TotalandX-WP-TotalPagesresponse headers to page through larger collections.Will my SEO plugin still work if WordPress is headless?
Only for the data it stores. The front end must output titles, meta descriptions, canonical URLs and sitemaps itself, usually using values the SEO plugin exposes through the API.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Adding REST API support for custom content types — REST API Handbook
- Modifying responses — REST API Handbook
- Global parameters — REST API Handbook
- Authentication — REST API Handbook
- WPGraphQL — WordPress.org Plugin Directory
- Custom post types — WPGraphQL
- revalidateTag — Next.js
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
Related tools
- Directory profile
Cloudflare
Global network service providing DDoS protection, a web application firewall, CDN and DNS, with a free plan for websites.
A tool connected to a service selected for this article. Compare its fit for your project; inclusion is not an endorsement.
- Directory profile
Sucuri
Website security service combining a cloud web application firewall, DDoS mitigation and CDN with malware scanning, unlimited hack cleanups and blocklist removal.
A tool connected to a service selected for this article. Compare its fit for your project; inclusion is not an endorsement.
More from Cloudsoft Zone

Resource guideWordPress
WordPress security hardening checklist for 2026 (WordPress 7.1)
A practical WordPress 7.1 hardening checklist based on the official handbook: updates, two-step logins, wp-config.php constants, file permissions, database privileges, firewalls, WP-CLI checksum checks and tested backups.
By Cloudsoft Zone

TutorialWordPress
How to update WordPress safely with WP-CLI: backup, update, verify, roll back
A repeatable WordPress update routine with WP-CLI: check pending updates, export the database and archive files, update core, plugins, themes and translations in order, verify checksums, and restore if something breaks.
By Cloudsoft Zone

Resource guideResources
Official documentation worth bookmarking for PHP, JavaScript and Node.js developers
A checked list of primary references for web developers: MDN, web.dev, the PHP manual and support schedule, Node.js API docs and releases, TypeScript, Laravel, React and WordPress docs, OWASP Cheat Sheets, DevDocs and endoflife.date.
By Cloudsoft Zone
