Migrating a Legacy WordPress Site to a Headless Architecture
What actually changes — and what doesn't — when you move a content-heavy WordPress site to a headless setup with a REST/GraphQL frontend.
"Headless WordPress" gets pitched as a silver bullet for performance and developer experience. It can be — but only if the migration is scoped honestly. Here's what I've learned moving a content-heavy, plugin-dependent WordPress site to a headless architecture without a rebuild-from-scratch budget.
Decide what "headless" means for this project #
There's a spectrum, not a binary:
- Decoupled front, WordPress admin stays. Editors keep using wp-admin; the public site is a separate frontend consuming the WordPress REST or GraphQL API. This is the most common and lowest-risk option.
- Fully headless. WordPress becomes a pure content API with no public-facing theme at all.
- Hybrid. Some routes stay server-rendered by WordPress (checkout, account pages from plugins that assume a WordPress frontend); everything else is decoupled.
Most of my migrations land on option 1 or 3 — a full headless rebuild only pays off when the editorial team's workflow genuinely benefits and the plugin ecosystem in use doesn't assume a traditional theme is rendering the page.
Audit what breaks first #
Before touching the frontend, inventory every plugin that renders its own markup directly into the page — SEO plugins, forms, page builders, membership/paywall plugins. These either need a headless-compatible replacement or a plan to keep their functionality server-side behind the API.
The SEO plugin trap
Most SEO plugins (Yoast, Rank Math) generate meta tags via wp_head(), which a
headless frontend never renders. Use their REST API exposure instead — Yoast exposes an
_links.yoast_head_json field per post that gives you the fully computed title,
description, and structured data to render in your frontend's <head>.
Expose only what the frontend needs #
The default WP REST API response is chatty and leaks internal fields. I add a custom
namespace rather than over-fetching from /wp/v2/:
add_action( 'rest_api_init', function () {
register_rest_route( 'site/v1', '/posts', [
'methods' => 'GET',
'callback' => 'site_get_posts_for_frontend',
'permission_callback' => '__return_true',
] );
} );
function site_get_posts_for_frontend( WP_REST_Request $request ) {
$posts = get_posts( [ 'posts_per_page' => 10 ] );
return array_map( function ( $post ) {
return [
'id' => $post->ID,
'title' => get_the_title( $post ),
'slug' => $post->post_name,
'excerpt' => get_the_excerpt( $post ),
'cover' => get_the_post_thumbnail_url( $post, 'large' ),
];
}, $posts );
}This keeps the API surface intentional, cacheable, and easy to version — instead of the frontend depending on WordPress's internal response shape.
Preview and draft content #
Editors expect to preview unpublished changes. With a decoupled frontend this needs explicit wiring: a signed preview token from WordPress that the frontend uses to request draft content and bypass any public caching for that request.
Caching moves, it doesn't disappear #
In a traditional WordPress setup, page caching happens at the web server or a caching
plugin. Headless shifts this responsibility to the frontend/CDN layer — but WordPress
still needs to notify the frontend when content changes, typically via a webhook that
triggers on-demand revalidation (Next.js's revalidatePath/revalidateTag, for
example) rather than a fixed TTL.
Lessons learned #
The migrations that went smoothly were the ones where I mapped every plugin's rendering
dependency before writing frontend code. The ones that went over budget were the ones
where "just point the frontend at /wp-json/" turned out to ignore three plugins that
assumed a WordPress-rendered page existed.