In a normal WordPress site, PHP themes or block templates turn content into HTML. In a headless setup, WordPress only manages content and exposes it through an API. A separate frontend, often built with a JavaScript framework, fetches that content and renders the pages. The editors keep the WordPress admin they know; developers get full control of the frontend.
That freedom has real costs. This lesson shows the mechanics and then the trade-offs, so you can make the decision on evidence. Framework-specific details, such as data fetching in Next.js, belong to their own courses.
The architecture
| Part | Role |
|---|---|
| WordPress | Editing, users, media, content storage, the API |
| API | The core REST API, or GraphQL through a plugin |
| Frontend | A separate application that fetches content and renders HTML |
| Hosting | Usually two deployments: the PHP site and the frontend |
The frontend can render pages on each request, build them ahead of time as static files, or mix the two. Whatever it does, WordPress is no longer responsible for what visitors see, so theme features, most frontend plugin output and the Site Editor stop affecting the public site.
REST or GraphQL
The REST API is built into core and needs nothing extra. Every public post type and taxonomy registered with show_in_rest gets endpoints. Its downside for headless use is shape: you often need several requests, or _embed, to assemble one page.
GraphQL, through the free WPGraphQL plugin, lets the frontend request exactly the fields it needs, including related data, in one query. It adds a plugin to maintain and its own schema and extensions for custom fields.
Both are sound. Start with REST unless your pages need deeply nested data from many types; the examples here use REST.
Fetching content in the frontend
These functions run on the frontend's server, for example during a build or in a server-rendered route, in any JavaScript runtime with fetch.
const API = 'https://cms.example.com/wp-json/wp/v2';
export async function getPosts( page = 1 ) {
const url = new URL( `${ API }/posts` );
url.searchParams.set( 'per_page', '10' );
url.searchParams.set( 'page', String( page ) );
url.searchParams.set( '_fields', 'id,slug,date,title,excerpt' );
const res = await fetch( url );
if ( ! res.ok ) {
throw new Error( `WordPress returned ${ res.status }` );
}
return {
posts: await res.json(),
totalPages: Number( res.headers.get( 'X-WP-TotalPages' ) ),
};
}
export async function getPostBySlug( slug ) {
const url = new URL( `${ API }/posts` );
url.searchParams.set( 'slug', slug );
url.searchParams.set( '_embed', 'author,wp:featuredmedia' );
const res = await fetch( url );
const posts = await res.json();
return posts[ 0 ] ?? null; // Querying by slug returns an array.
}
What to notice:
_fieldstrims the response to what the page needs. Responses are much smaller and faster to parse.- Pagination totals come from the
X-WP-TotalandX-WP-TotalPagesheaders, not the body. - Querying by
slugreturns an array, because slugs are only unique per post type. _embedincludes linked resources such as the author and featured image, saving extra requests. You can name the links to embed, as shown.
Rendered content
content.rendered and title.rendered are HTML produced by WordPress, with filters and block rendering already applied. Titles contain HTML entities, so decode or render them as HTML rather than as plain text.
Block content arrives as markup with class names. To look right, the frontend must provide styles for the core blocks your editors use. Planning which blocks editors may use, and styling those, is part of every headless project.
Exposing custom fields
Custom fields are not in the API unless you register them. Register meta in a plugin with show_in_rest, a type, sanitisation and an auth callback:
<?php
/**
* Plugin Name: My Plugin Headless Fields
*/
defined( 'ABSPATH' ) || exit;
add_action( 'init', 'myplugin_register_meta' );
function myplugin_register_meta(): void {
register_post_meta(
'post',
'myplugin_subtitle',
array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => static function (): bool {
return current_user_can( 'edit_posts' );
},
)
);
}
The value appears under meta.myplugin_subtitle in post responses, and authorised users can update it through the API. For computed data that is not stored as meta, use register_rest_field() or a custom endpoint, as covered earlier.
Everything you expose on a public endpoint is public. Do not register internal meta, such as integration tokens or private notes, with show_in_rest.
Authentication
Published content needs no authentication. Drafts, private posts and anything in context=edit do.
For server-to-server requests, Application Passwords are built into core. Create one for a dedicated low-privilege user (an Editor is usually enough for previews) under Users > Profile, then send it as HTTP Basic authentication over HTTPS:
const auth = Buffer.from(
`${ process.env.WP_USER }:${ process.env.WP_APP_PASSWORD }`
).toString( 'base64' );
const res = await fetch( `${ API }/posts/${ id }?context=edit`, {
headers: { Authorization: `Basic ${ auth }` },
} );
Keep these credentials on the frontend's server, in environment variables. Never ship them in browser JavaScript.
Previews
Previews are where headless projects most often disappoint editors. In a normal site, Preview shows the draft through the theme. Headless, you must send editors to the frontend and let it fetch the unpublished version.
- Point the preview button at the frontend with the
preview_post_linkfilter. - The frontend's preview route checks a secret, fetches the draft or latest autosave with authentication, and renders it without caching.
add_filter( 'preview_post_link', 'myplugin_preview_link', 10, 2 );
function myplugin_preview_link( string $link, WP_Post $post ): string {
return add_query_arg(
array(
'id' => $post->ID,
'type' => $post->post_type,
'secret' => MYPLUGIN_PREVIEW_SECRET,
),
'https://www.example.com/api/preview'
);
}
MYPLUGIN_PREVIEW_SECRET is a constant you define in wp-config.php and share with the frontend. Unsaved changes to a published post are stored as autosaves, available from the /wp/v2/posts/<id>/autosaves endpoint, so the preview route should read the latest autosave when one exists.
Revalidating the frontend
If the frontend caches or prebuilds pages, it must hear about changes. Send a webhook when content changes:
add_action( 'transition_post_status', 'myplugin_notify_frontend', 10, 3 );
function myplugin_notify_frontend( string $new_status, string $old_status, WP_Post $post ): void {
if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
return;
}
wp_remote_post(
'https://www.example.com/api/revalidate',
array(
'blocking' => false,
'timeout' => 2,
'headers' => array(
'Content-Type' => 'application/json',
'X-Revalidate-Key' => MYPLUGIN_REVALIDATE_SECRET,
),
'body' => wp_json_encode(
array(
'type' => $post->post_type,
'slug' => $post->post_name,
)
),
)
);
}
MYPLUGIN_REVALIDATE_SECRET is another shared secret defined in wp-config.php. The frontend endpoint checks the key and refreshes the affected pages, including listings. Menus, settings and taxonomy changes need the same treatment, just as with page caching.
Closing the WordPress frontend
The WordPress theme still answers requests unless you stop it. Redirect public front-end requests to the new site so search engines do not index two copies. template_redirect only fires for theme requests, so the admin and REST API are unaffected:
add_action( 'template_redirect', 'myplugin_redirect_frontend' );
function myplugin_redirect_frontend(): void {
if ( is_preview() ) {
return;
}
$request = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
$path = wp_parse_url( $request, PHP_URL_PATH );
wp_redirect( 'https://www.example.com' . ( $path ? $path : '/' ), 301 );
exit;
}
The trade-offs
Headless gives you:
- Complete control of frontend code, tooling and rendering strategy.
- One content source for several channels: a website, an app, kiosks.
- The option to serve mostly static pages from a CDN, with WordPress off the public path.
It costs you:
- Two systems to host, deploy, monitor and secure, and usually two skill sets on the team.
- Lost frontend plugin features. Anything that outputs HTML on the theme side (forms, SEO tags, related posts, consent banners, comments) must be rebuilt or replaced through the API.
- Editor experience. Previews, block styling and the Site Editor need work to match what editors had.
- More moving parts for caching: revalidation, webhooks and failure handling.
A useful test: if the main reason is performance, a well-cached traditional or block theme is usually cheaper to run and simpler to maintain. Headless pays off when you need multiple channels, a frontend team already invested in a framework, or application-like interactivity on top of content.
Summary
- Headless WordPress manages content and exposes it through the REST API or GraphQL; a separate frontend renders the pages.
- Fetch with
_fields,_embedand the pagination headers; slug queries return arrays. - Expose custom fields with
register_post_meta()andshow_in_rest, and never expose private data. - Use Application Passwords server-side for drafts, and wire up previews with
preview_post_linkand the autosaves endpoint. - Notify the frontend of changes with a webhook, and redirect the old theme frontend.
- Choose headless for multiple channels or frontend needs, not as the default route to speed.