WordPress
Headless WordPress: splitting one install into three surfaces
One WordPress install ran the blog, the shop, and the member portal for a 12-person Amsterdam media brand. By autumn 2025, it was running none of them well. Here is how we cut it.

It was a Tuesday in November and the admin spinner had been turning for forty seconds. The features editor refreshed, hit publish again, then opened a second tab. The home page still loaded, slowly, but the dashboard at /wp-admin had become a coin flip. Tomorrow's cover story was three revisions deep and the shop was reporting failed checkouts. The publisher messaged us at 22:51: "Is the site about to die or is it just me?"
It was not just her. The same WordPress install had been running the blog, the shop, and the member portal for a twelve-person Amsterdam media brand since 2017. It carried 4,800 long-form articles, an 80-product print archive, and 2,300 paying members. Every plugin update was a small prayer. We had been hired six weeks earlier to make it faster. By that Tuesday, the diagnosis was done and the answer was no longer faster. The answer was fewer.
One install, three jobs, no margin
The site looked like one product to the reader. To the database it was three. The blog was a high-read, low-write surface that wanted aggressive HTTP caching and a CDN that would actually believe it. The shop wanted authenticated PHP sessions, write-heavy cart state, and a payment processor that did not pretend the user was anonymous. The portal (paywalled long-reads, a member directory, comments under each piece) wanted both, plus a notion of "logged in" that survived every plugin's idea of what a cookie was.
Pasted into one wp-content folder were 41 active plugins. Six of them touched wp_users on every page load. Two of them cached pages at the edge, which broke any time the membership plugin decided to write a session cookie, which was always. Object cache had been turned off in 2022 to fix a bug nobody could now name. wp-cron ran on every uncached request, which meant on most requests, because the cache was off.
The publisher's question was the right one. The site was not about to die. It was already dead and walking. We had to cut.
The three-surface architecture
We proposed splitting the install into three independently deployed front-ends, each calling into a stripped-down WordPress that ran only as a content API. Editors would keep logging in to the same WP-Admin they had used for eight years. Readers would never see PHP again.
- blog.brand.nl, an Astro static site, fully cacheable, pulling from the WP REST API at build time plus incremental rebuilds on publish.
- shop.brand.nl, Astro with server islands, talking to Adyen for checkout.
- members.brand.nl, Astro with a thin Node session layer, talking to WP as the user-and-entitlement source of truth, with paywalled article HTML served from the same headless API the blog used.
The point was not the framework. The point was that no single failure could now take down the editorial calendar. If the shop's Adyen integration broke, the blog kept publishing. If the portal's session layer crashed, checkout kept selling. If WordPress itself fell over, two of three surfaces stayed up because they had been built statically that morning.
The playbook, phase by phase
This is what we ran. Six weeks of work, no editorial blackout, no readers who noticed.
Phase 0: read-only inventory
Before we wrote a line of code, we mapped the install. Two commands and an evening did most of it:
wp plugin list --status=active --format=csv > plugins.csv
wp post list --post_type=any --format=csv \
--fields=ID,post_type,post_status,post_date \
--posts_per_page=-1 > posts.csv
Forty-one active plugins, eleven custom post types, six of which had zero published entries since 2019. We deactivated the dead ones on a staging clone, watched nothing break, and shipped that deactivation as the first PR. The site got 18% faster before we touched anything else.
Phase 1: turn WordPress into an API
We installed WPGraphQL alongside the REST API and put a CDN in front of both. Custom post types got REST controllers; ACF fields got registered with show_in_rest. The membership plugin was the only one that fought us. It owned wp_users and refused to expose entitlements through any API it had not written itself. We wrapped it in a small mu-plugin that exposed exactly two endpoints:
add_action('rest_api_init', function () {
register_rest_route('brand/v1', '/entitlements/(?P<id>\d+)', [
'methods' => 'GET',
'permission_callback' => 'brand_require_signed_request',
'callback' => function ($req) {
$user_id = (int) $req['id'];
return [
'tier' => brand_get_membership_tier($user_id),
'expires' => brand_get_expiry($user_id),
'features' => brand_features_for($user_id),
];
},
]);
});
Signed requests, ten-minute TTL on the CDN, nothing leaked. The plugin authors never noticed.
Phase 2: the blog, first and cheapest
Blog first, because it was the easiest win and the largest reader surface. We built it in Astro with content collections sourced from the REST API. A nightly full build, plus an on-publish webhook that triggered an incremental rebuild of only the affected pages.
// src/lib/wp.ts
const WP = 'https://cms.brand.nl/wp-json/wp/v2'
export async function getPosts(page = 1, perPage = 100) {
const res = await fetch(
`${WP}/posts?_embed&per_page=${perPage}&page=${page}`,
{ headers: { 'User-Agent': 'brand-astro/1.0' } }
)
if (!res.ok) throw new Error(`WP returned ${res.status}`)
return {
posts: await res.json(),
total: Number(res.headers.get('x-wp-total') ?? 0),
pages: Number(res.headers.get('x-wp-totalpages') ?? 0),
}
}
First production deploy hit 92 on Lighthouse mobile. The old WordPress theme had been hovering at 31.
WPGraphQL and the REST API disagree about how to expose draft posts. Editors will assume preview works the moment you ship a headless front-end. Build a signed preview route on day one or you will hear about it on day two.
Phase 3: the shop, on Adyen
The shop was a WooCommerce install with eighty SKUs, mostly back-issues and one print subscription. WooCommerce had been carrying its own copy of the catalogue, its own checkout, and its own opinion of session state. We kept the catalogue in WP (editors knew that UI), moved checkout to Adyen's drop-in, and let Astro server islands stitch the two together.
Adyen over Stripe came down to one thing: the brand had Dutch B2B buyers who paid by iDEAL and SEPA more often than card. Adyen handles iDEAL natively without an extra processor in the middle. We swapped the payment leg in eight days. Order webhooks still hit a Woo endpoint, which still wrote to wp_woocommerce_orders, which still let the publisher's bookkeeper run her usual export.
Phase 4: the member portal, last and slowest
Portal last, because session state is where you make the worst mistakes. We kept WordPress as the identity provider. A small Node sidecar accepted login posts, hit the WP REST endpoint we had written in phase 1, signed a JWT, and set an HttpOnly cookie scoped to .brand.nl. The Astro front-end read the cookie at the edge and decided whether to serve the paywalled body or the paywall.
// src/middleware.ts (Astro)
import { defineMiddleware } from 'astro:middleware'
import { verifyJwt } from './lib/auth'
export const onRequest = defineMiddleware(async (ctx, next) => {
const token = ctx.cookies.get('brand_session')?.value
ctx.locals.member = token ? await verifyJwt(token) : null
return next()
})
No member ever had to log in again. The cookie name and domain stayed identical to the old WordPress one, the JWT replaced the old PHP session, and the database row was the same row it had always been.
Phase 5: DNS and the cutover
We cut over surface by surface. Blog moved first, on a Friday at 11:00 Amsterdam time, while the features editor watched. Shop moved two weeks later, on a Tuesday morning after the bookkeeper had run a full export. The portal moved last, on a Sunday at 04:00, when 2,300 members were mostly asleep and the on-call number was answered by us.
The old brand.nl became a thin Astro shell that 301-redirected each path to the right new surface. We kept WordPress reachable at cms.brand.nl, locked to office IPs plus the editors' home connections. Search engines saw clean 301s, readers saw nothing different, editors saw the same login screen.
Keeping the editorial calendar alive
The publisher's only hard rule had been: do not break the calendar. The brand publishes four pieces a week, plus a Friday newsletter that pulls from the week's posts. We honoured that with three rules that sound boring and were worth every hour they cost.
First, editors kept publishing into the same WP-Admin throughout the migration. We did not ask them to learn a new CMS in the same quarter we changed the front-end. Migration tutorials that start with "first, switch your team to a new editor" lose the team.
Second, every front-end was deployed behind a feature flag the editor could see. A small banner inside WP-Admin showed which surface a given post would render to and whether the latest build had completed. When the banner went red, we noticed before the reader did.
Third, the Friday newsletter pulled from the same REST API the blog did, not from the old WordPress theme's RSS. The moment the API was live, the newsletter became surface-agnostic. The migration could move under it without it noticing.
Things we got wrong
We assumed the membership plugin's REST surface was complete. It was not. Two endpoints we needed had been removed in a 2024 release because, the changelog said, "nobody used them." We had to fork the plugin and maintain a thin patch, which we still do. If you are betting on a plugin's API, read its changelog for the last three years, not the last three months.
We also underestimated image handling. WordPress had been generating fifteen thumbnail sizes per upload. Astro wanted to handle responsive images itself. For six weeks we shipped both, which doubled storage. We eventually wrote a one-off script that deleted every thumbnail not referenced by a post since 2022 and reclaimed 71 GB.
And we discovered, the hard way, that wp-cron jobs scheduled by the membership plugin assumed the public site was reachable at the same hostname as the admin. Moving the admin to cms.brand.nl silently broke the renewal cron for nine days. Members whose subscriptions lapsed in that window had to be manually restored. We now monitor every plugin's cron schedule as part of any headless cut.
What you can do this afternoon
If you are sitting on a WordPress install that has grown into three jobs, you do not have to start with the rewrite. Start with Phase 0. Run wp plugin list --status=active and wp post list --post_type=any --format=count. Count the plugins that touch wp_users. Count the post types with zero recent entries. The shape of the cut you eventually need is already in those two numbers.
When we built this headless migration for the Amsterdam publisher, the thing we kept getting wrong was assuming the editorial team would adapt to the new tooling. They never had to. They kept their login screen, kept their calendar, kept their Friday rhythm. We changed the surface underneath them, one phase at a time, and they noticed only because the spinner stopped spinning.
Key takeaway
You do not migrate a CMS. You migrate a publishing habit. Keep the editor's login screen identical and you have earned the permission to change everything behind it.
FAQ
Do editors have to learn a new CMS?
No. The point of the headless cut is to keep the existing WP-Admin login. Editors never see the new framework. They log in where they always did and publish into the same boxes.
Why Astro instead of Next.js or SvelteKit?
Astro defaults to static, which is what a high-read media surface wants. Every dynamic island is opt-in, so the per-page cost of interactivity stays visible. Next.js makes that cost easy to hide.
How long does a split like this take for a 4,800-article site?
Six weeks end to end with one senior engineer and a part-time PM. The blog ships in two; shop and portal each take roughly two more. Phase 0 inventory is one evening.
What happens to SEO during the cutover?
Nothing, if you 301 every old path to the new surface and keep canonical URLs stable. We saw no ranking movement across the cutover for the publisher's top 200 queries.
Does headless reduce hosting cost?
Usually, yes. The PHP container shrinks because it serves only admin and API traffic. Most reader traffic moves to a CDN. The publisher's monthly bill dropped by about 40%.