Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
WordPressDevelopmentJavaScriptPHPArchitecture guide

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.

Cloudsoft Zone

Updated 8 min read

Laptops, tablets, e-readers and smartphones arranged in a grid on a wooden floor
Photograph: Jeremy Keith / CC BY 2.0, via Wikimedia Commons
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 whenStay with a theme when
One content source feeds a website, a mobile app and other channelsThe website is the only channel
Your team builds and maintains a JavaScript front end anywayEditors 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 trafficYou 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.

Long aisle between rows of black server racks with curved tops in a data centre
In a headless setup the CMS can live on a private hostname, with only the front end facing visitors.

A reference architecture

  1. 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.
  2. Content API: the core REST API under /wp-json/wp/v2/, or GraphQL at /graphql with WPGraphQL.
  3. Front end: a Next.js app that fetches on the server, caches responses and renders HTML. Visitors never call WordPress directly.
  4. Cache invalidation: WordPress sends a signed webhook when content changes, and the front end refreshes only the affected data.
  5. 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
<?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.

Shell
# 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:

Text
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 APIWPGraphQL
AvailabilityBuilt into corePlugin (2.23.1, requires WordPress 6.0+ and PHP 7.4+)
Response shapeFixed per endpoint, trimmed with _fieldsExactly what the query asks for
Related data_embed or extra requestsOne nested query
HTTP cachingSimple: GET URLs cache well at a CDNNeeds persisted queries or the Smart Cache extension
Plugin supportMost plugins register REST routesDepends 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:

JavaScript
// 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:

PHP
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:

JavaScript
// 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.

HTML form markup open in the Visual Studio Code editor with a dark theme
Going headless moves forms, SEO tags and previews from plugins into your own front-end code.

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_rest is public on published posts; keep internal fields unregistered or protected.

Gotchas

  • Block markup needs block styles. content.rendered contains 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

  1. Write down every front-end feature your current plugins provide and decide who rebuilds each one.
  2. Register your content model in a plugin with show_in_rest and test it at /wp-json/wp/v2/.
  3. Build one listing page and one detail page with _fields, cache tags and the revalidation webhook.
  4. Add authenticated previews before inviting editors.
  5. 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 with show_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() and show_in_rest, and make sure the post type supports custom-fields. Array and object values also need a JSON Schema in show_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 the X-WP-Total and X-WP-TotalPages response 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.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Adding REST API support for custom content types — REST API Handbook
  2. Modifying responses — REST API Handbook
  3. Global parameters — REST API Handbook
  4. Authentication — REST API Handbook
  5. WPGraphQL — WordPress.org Plugin Directory
  6. Custom post types — WPGraphQL
  7. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.