WordPress
Headless WordPress retrofit: a 38-plugin multisite playbook
You inherit a WordPress multisite. Fourteen years old. Thirty-eight active plugins. Editors who can find any field in their sleep. The brief: go headless without breaking their muscle memory.

It's a Tuesday afternoon. The editor logs into wp-admin, the same wp-admin she has used since 2014. She opens a "Case study" post, scrolls past the flexible-content blocks she remembers by row number, types into the "Hero subtitle" field, hits Update. Two seconds later the case study is live at /work/sabai-forum on a Next.js front she has never seen. Same wp-admin, new front-end. That is the bar.
This is the playbook for getting there on a 14-year-old WordPress multisite with thirty-eight active plugins, without forcing the editorial team to relearn anything, and without the six-month rewrite project that kills these migrations halfway through.
Why headless, and why this client
The client ran four subsites on a single WordPress network: corporate, careers, blog, legal. Stack was PHP 7.4, MariaDB 10.3, Apache with a tangle of mod_rewrite rules nobody had touched since 2019. Lighthouse was scoring 31 on mobile. Time to first byte sat at 1.8s before any front-end JavaScript ran. The plugin folder held thirty-eight active plugins, three of which had not shipped an update in over four years. PHP 8.3 was already the floor on the hosting plan, and the team had been postponing the upgrade because four plugins broke when they tried.
The reflex move is "rewrite the whole thing in Next.js from scratch." That is also the move that gets killed at the third sprint review when the marketing director cannot find the field for the homepage hero. The retrofit path is different: keep wp-admin exactly as it is, swap the front-end for Next.js, and let editors notice nothing changed in their workflow. Fewer surprises, fewer training sessions, fewer chances for the project to lose sponsorship halfway through.
The thirty-eight plugin audit
Start with a census. WP-CLI gives you a CSV in one line:
wp plugin list --status=active --format=csv --fields=name,version,update > active-plugins.csvThen sort every row into one of three buckets:
- Admin-only. ACF Pro, Admin Columns, User Role Editor, WP Mail SMTP, custom client plugins. These stay. They never touched the front-end.
- Front-end coupled. Yoast SEO, Contact Form 7, WP Rocket, Smush, a slider plugin from 2018, two analytics injectors. These need a decision: keep the data and re-render it on Next.js (Yoast meta), or delete entirely (WP Rocket is meaningless once Next.js owns caching).
- Dead weight. Three plugins no template referenced anymore. Deactivate first, watch the logs for a week, then delete.
On this project the buckets came out 14 admin, 17 front-end coupled, 7 dead. Of the seventeen, eleven became Next.js code. Six were replaced by libraries (form validation, image optimisation, sitemap generation). The plugin folder shrank to twenty-one and stayed there.
Do not deactivate the slider plugin until you grep the database for shortcode usage. wp db query "SELECT ID, post_title FROM wp_posts WHERE post_content LIKE '%[slider%'" finds the orphans. We missed seventeen on the first pass.
WP-GraphQL plus WPGraphQL for ACF
The lock-in editors actually care about is the shape of the fields, not the database table the values live in. ACF field groups, flexible content rows, repeater layouts: those are muscle memory. The job is to expose them as GraphQL without renaming a single one.
Install WP-GraphQL and WPGraphQL for ACF. Network-activate both. Each subsite answers GraphQL at its own /graphql endpoint using the same authentication wp-admin uses. Field group names become GraphQL types automatically: a flexible-content field called "blocks" with a layout called "Two column" becomes the union member CaseFieldsBlocksTwoColumnLayout. The editor never sees this. She still sees "Two column" in the admin dropdown.
A query for a case study looks like this:
query CaseStudy($slug: ID!) {
caseStudy(id: $slug, idType: SLUG) {
title
caseFields {
heroSubtitle
blocks {
__typename
... on CaseFieldsBlocksTwoColumnLayout {
left
right
}
... on CaseFieldsBlocksPullQuote {
quote
attribution
}
}
}
}
}The __typename is the hinge. The Next.js component dispatches on it to pick a renderer. Add a new flexible-content layout in wp-admin tomorrow and you only have to write a new React component to match. Nothing else changes. No schema migration, no rebuild of the editorial form, no training email.
The Yoast question
Yoast is the plugin every editor expects to keep. They write the SEO title and the meta description in the same yellow-and-green box they have used for ten years. Killing that box gets you a revolt.
Keep Yoast running in wp-admin. WP-GraphQL has a community extension that exposes the Yoast meta object on every post type: seo { title metaDesc opengraphImage { sourceUrl } }. Read those values in your Next.js generateMetadata function. The editorial workflow is untouched, the meta tags render server-side, and the public site never loads a line of Yoast PHP. The plugin stops being a runtime dependency and becomes a content-entry tool.
The multisite decision
WP-GraphQL respects WordPress multisite. Each subsite gets its own endpoint. The architectural choice is whether to ship four Next.js apps or one Next.js app that switches on the request host.
We picked one app. The reason is operational: a single Vercel project, a single CI pipeline, a single error log, one place to audit a dependency. The downside is that the Next.js routing layer has to know which WP endpoint to query for each host. We solved it with a small siteFromHost helper that reads the host header in middleware.ts and writes it onto a request header the data layer reads. Four hosts, one switch statement, no shared state.
If your four subsites have genuinely different design systems and unrelated editorial calendars, do the opposite: ship four apps and stop pretending they are one product. The cost of pretending is paid every sprint after.
Caching with on-demand revalidation
The reason WP Rocket got deleted is that Next.js owns this now. Pages render statically at build time, get tagged, and revalidate on a webhook from WordPress. The publish-to-live latency target was two seconds. We hit just over one.
The fetch on the Next.js side tags the response:
async function getCaseStudy(slug: string) {
const res = await fetch(process.env.WP_GRAPHQL_URL!, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
query: CASE_STUDY_QUERY,
variables: { slug },
}),
next: { tags: [`case-study:${slug}`] },
})
const { data } = await res.json()
return data.caseStudy
}The revalidation endpoint takes a webhook and busts the tag. Next.js documents the API; the wiring is small:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const secret = req.headers.get('x-revalidate-secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ ok: false }, { status: 401 })
}
const { type, slug } = await req.json()
revalidateTag(`${type}:${slug}`)
return NextResponse.json({ ok: true, revalidated: `${type}:${slug}` })
}On the WordPress side, a fourteen-line mu-plugin fires the webhook on publish:
// wp-content/mu-plugins/abn-revalidate.php
<?php
add_action('save_post', function ($post_id, $post) {
if (wp_is_post_revision($post_id)) return;
if ($post->post_status !== 'publish') return;
wp_remote_post(NEXT_REVALIDATE_URL, [
'headers' => ['x-revalidate-secret' => NEXT_REVALIDATE_SECRET],
'body' => wp_json_encode([
'type' => $post->post_type,
'slug' => $post->post_name,
]),
'blocking' => false,
'timeout' => 0.1,
]);
}, 10, 2);The blocking => false matters. The editor hits Update and gets her admin notice in normal WordPress time. The webhook fires asynchronously. Next.js revalidates the tag. The next visitor to /work/sabai-forum gets the new version. If you ever skip the false-blocking flag, you will get an editor complaining that Update takes four seconds, and you will deserve it.
Preview mode the editor will actually use
The other thing editors do is preview drafts. If preview is broken, they stop using it, then they stop trusting headless, then they ask for the old front-end back. Treat preview as a feature, not an afterthought.
In wp-admin, the "Preview" button hits a URL of the editor's choosing. Point it at /api/draft on the Next.js host with a signed token and the post slug:
add_filter('preview_post_link', function ($link, $post) {
$token = hash_hmac('sha256', (string) $post->ID, WP_PREVIEW_SECRET);
return add_query_arg([
'token' => $token,
'type' => $post->post_type,
'slug' => $post->post_name,
'id' => $post->ID,
], NEXT_FRONT_URL . '/api/draft');
}, 10, 2);Next.js verifies the HMAC, enables draft mode, and redirects to the canonical URL, where the data layer reads the unpublished revision through an authenticated GraphQL query:
// app/api/draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import crypto from 'node:crypto'
export async function GET(req: Request) {
const url = new URL(req.url)
const id = url.searchParams.get('id')!
const token = url.searchParams.get('token')!
const slug = url.searchParams.get('slug')!
const type = url.searchParams.get('type')!
const expected = crypto
.createHmac('sha256', process.env.WP_PREVIEW_SECRET!)
.update(id)
.digest('hex')
if (token !== expected) {
return new Response('Unauthorized', { status: 401 })
}
;(await draftMode()).enable()
redirect(`/${type}/${slug}`)
}One detail that bites: in draft mode, never cache. The fetch in your data layer needs cache: 'no-store' when draftMode().isEnabled. We learned this when an editor previewed the same draft five times and got the first version every time.
Cutover without losing search rankings
The last mile is DNS and redirects, and it is where most retrofits leak SEO. Three things matter.
First, move wp-admin to its own host. We picked admin.client.com. The front-end client.com goes to Next.js. Editors get a one-line bookmark update. Brute-force bots that were hammering /wp-login.php on the public domain stop getting through, because the public domain no longer serves WordPress at all.
Second, the URL shapes have to match. If the old site served /case-studies/sabai-forum/, the new one serves /case-studies/sabai-forum/. Same trailing slash. Same hierarchy. If you change either, write 301s for every old URL and verify them with a crawl. We use Screaming Frog against staging before the DNS flip, then again two hours after.
Third, keep /wp-content/uploads/ reachable for at least one release cycle. The Next.js front references images by absolute URL from WordPress. The media library still lives in wp-content/uploads on the admin host. Either proxy /wp-content/uploads/ through Next.js, or rewrite the image URLs at GraphQL response time to point at the admin host. The proxy is cleaner, the rewrite is faster, and either is fine as long as you do not leave editors uploading new hero images that 404 in production.
Closing the loop
The retrofit only works if wp-admin looks identical on day one of go-live. Every editor surface you change is a chance for the project to stall. When we ran this for a client whose four-subsite network was hitting PHP EOL, the front-end went from a 31 Lighthouse score to a 96, the plugin folder shrank from thirty-eight to twenty-one, and the editorial team did not book a single training session, because there was nothing new to train on. The ACF field groups they memorised in 2017 still rendered the same components in 2026. We have done this legacy migration pattern enough times now that the audit-to-cutover window is six weeks, not six months.
The smallest thing you could do today: run wp plugin list --status=active on your own production site, open the CSV, and tag each row admin, front-end, or dead. That one hour of sorting tells you whether your retrofit is a three-week job or a three-month one.
Key takeaway
Retrofit, do not rewrite: keep wp-admin and the ACF field shapes intact, move the front-end to Next.js + WP-GraphQL, and the editorial team will not notice anything changed.
FAQ
Do we have to rebuild ACF field groups from scratch?
No. WPGraphQL for ACF exposes existing field groups as GraphQL types automatically. The editor sees the same admin form, the field names stay identical, and you write React components against the generated schema.
How fast is publish-to-live with on-demand revalidation?
On our last retrofit the median was just over one second from Update click to the new version showing on the public URL. The WordPress webhook is non-blocking, so the editor never waits for Next.js.
Can editors keep using the Gutenberg block editor?
Yes, if you query Gutenberg blocks through WP-GraphQL and render each block type in React. Most retrofits we see are ACF-driven, so this matters less, but Gutenberg works headless.
What happens to plugins like WP Rocket and Smush after the migration?
They get deleted. Next.js owns caching and image optimisation on the front-end, so the front-facing caching and image plugins become dead weight. Admin-only plugins like ACF Pro and Yoast stay.
How do we handle WordPress preview for drafts on a headless front-end?
Override preview_post_link in PHP to point at a Next.js /api/draft route with a signed HMAC token. The route enables draft mode and reads the unpublished revision through an authenticated GraphQL query.