← Blog

WordPress

Headless WordPress-retrofit: een multisite met 38 plugins

Je erft een WordPress-multisite. Veertien jaar oud. Achtendertig actieve plugins. Editors die elk veld blind vinden. De opdracht: headless gaan zonder hun routine te breken.

Jacob Molkenboer· Oprichter · A Brand New Company· 6 jun 2026· 11 min
Open leren logboek met messing tabs en sleutel op ivoor papier, groen lint als boekenlegger, rode lakzegel in schaduw.

Het is dinsdagmiddag. De editor logt in op wp-admin, hetzelfde wp-admin dat ze sinds 2014 gebruikt. Ze opent een 'Case study'-post, scrollt langs de flexible-content-blokken die ze op rijnummer kent, typt in het veld 'Hero subtitle', klikt op Update. Twee seconden later staat de case study live op /work/sabai-forum op een Next.js-front-end die ze nog nooit heeft gezien. Hetzelfde wp-admin, nieuwe front-end. Dat is de lat.

Dit is het playbook om daar te komen op een 14 jaar oude WordPress-multisite met achtendertig actieve plugins, zonder dat het redactieteam iets opnieuw hoeft te leren, en zonder de halfjaarproject-rewrite die deze migraties halverwege om zeep helpt.

Waarom headless, en waarom deze klant

De klant draaide vier subsites op één WordPress-netwerk: corporate, careers, blog, legal. De stack was PHP 7.4, MariaDB 10.3, Apache met een kluwen mod_rewrite-regels die niemand sinds 2019 had aangeraakt. Lighthouse scoorde 31 op mobiel. Time to first byte zat op 1,8s voordat er ook maar één regel front-end-JavaScript draaide. De pluginmap bevatte achtendertig actieve plugins, waarvan er drie al meer dan vier jaar geen update meer hadden uitgebracht. PHP 8.3 was al de minimumeis op het hostingplan, en het team stelde de upgrade telkens uit omdat vier plugins kapotgingen bij de poging.

De reflex is 'schrijf alles helemaal opnieuw in Next.js'. Dat is ook de aanpak die bij de derde sprintreview wordt afgeschoten omdat de marketingdirecteur het veld voor de homepage-hero niet kan vinden. De retrofit-route is anders: laat wp-admin precies zoals het is, vervang de front-end door Next.js, en zorg dat editors niets aan hun workflow merken. Minder verrassingen, minder trainingssessies, minder kans dat het project halverwege zijn sponsor verliest.

De audit van achtendertig plugins

Begin met een telling. WP-CLI geeft je een CSV in één regel:

wp plugin list --status=active --format=csv --fields=name,version,update > active-plugins.csv

Sorteer vervolgens elke regel in een van drie bakken:

  • Alleen admin. ACF Pro, Admin Columns, User Role Editor, WP Mail SMTP, custom klantplugins. Die blijven. Ze hebben de front-end nooit aangeraakt.
  • Gekoppeld aan de front-end. Yoast SEO, Contact Form 7, WP Rocket, Smush, een sliderplugin uit 2018, twee analyticsinjectors. Hier moet je kiezen: data behouden en opnieuw renderen in Next.js (Yoast-meta), of helemaal weggooien (WP Rocket heeft geen zin meer zodra Next.js de caching doet).
  • Dood gewicht. Drie plugins waar geen template meer naar verwijst. Eerst deactiveren, een week de logs bekijken, dan verwijderen.

Bij dit project kwamen de bakken uit op 14 admin, 17 front-end-gekoppeld, 7 dood. Van die zeventien werden er elf Next.js-code. Zes werden vervangen door libraries (formuliervalidatie, image-optimalisatie, sitemap-generatie). De pluginmap kromp tot eenentwintig en bleef daar.

Waarschuwing

Deactiveer de sliderplugin niet voordat je de database hebt afgegrept op shortcode-gebruik. wp db query "SELECT ID, post_title FROM wp_posts WHERE post_content LIKE '%[slider%'" vindt de wezen. Bij de eerste ronde misten we er zeventien.

WP-GraphQL plus WPGraphQL for ACF

De lock-in waar editors echt om geven, is de vorm van de velden, niet de databasetabel waar de waarden in staan. ACF-veldgroepen, flexible-content-rijen, repeater-layouts: dat is routine. De klus is om die als GraphQL beschikbaar te maken zonder ook maar één naam te veranderen.

Installeer WP-GraphQL en WPGraphQL for ACF. Activeer beide op netwerkniveau. Elke subsite beantwoordt GraphQL op zijn eigen /graphql-endpoint via dezelfde authenticatie als wp-admin. Veldgroepnamen worden automatisch GraphQL-types: een flexible-content-veld 'blocks' met een layout 'Two column' wordt het union-lid CaseFieldsBlocksTwoColumnLayout. De editor ziet daar niets van. Zij ziet nog steeds 'Two column' in de admin-dropdown.

Een query voor een case study ziet er zo uit:

query CaseStudy($slug: ID!) {
  caseStudy(id: $slug, idType: SLUG) {
    title
    caseFields {
      heroSubtitle
      blocks {
        __typename
        ... on CaseFieldsBlocksTwoColumnLayout {
          left
          right
        }
        ... on CaseFieldsBlocksPullQuote {
          quote
          attribution
        }
      }
    }
  }
}

De __typename is het scharnier. De Next.js-component kiest op basis daarvan een renderer. Voeg morgen een nieuwe flexible-content-layout toe in wp-admin en je hoeft alleen een nieuwe React-component te schrijven die erbij past. Verder verandert er niets. Geen schemamigratie, geen herbouw van het redactieformulier, geen training-mail.

De Yoast-kwestie

Yoast is de plugin die elke editor wil houden. Ze schrijven de SEO-titel en meta-description in hetzelfde geel-en-groene vak dat ze al tien jaar gebruiken. Dat vak schrappen levert je een opstand op.

Laat Yoast in wp-admin draaien. WP-GraphQL heeft een community-extensie die het Yoast-meta-object beschikbaar maakt op elke post-type: seo { title metaDesc opengraphImage { sourceUrl } }. Lees die waarden in je Next.js generateMetadata-functie. De redactieworkflow blijft onaangetast, de meta-tags renderen server-side, en de publieke site laadt geen regel Yoast-PHP. De plugin is geen runtime-dependency meer, maar een content-invoertool.

De multisite-beslissing

WP-GraphQL respecteert WordPress-multisite. Elke subsite krijgt zijn eigen endpoint. De architectuurkeuze is of je vier Next.js-apps uitrolt, of één Next.js-app die schakelt op de request-host.

Wij kozen voor één app. De reden is operationeel: één Vercel-project, één CI-pipeline, één error-log, één plek om een dependency te auditeren. Het nadeel is dat de Next.js-routinglaag moet weten welk WP-endpoint hij per host moet bevragen. We hebben dat opgelost met een kleine siteFromHost-helper die de host-header in middleware.ts leest en die op een request-header zet die de datalaag uitleest. Vier hosts, één switch-statement, geen gedeelde state.

Als jouw vier subsites echt verschillende designsystemen en losse redactiekalenders hebben, doe dan het omgekeerde: bouw vier apps en stop met doen alsof het één product is. De kosten van dat doen-alsof betaal je in elke sprint die volgt.

Caching met on-demand revalidatie

WP Rocket ging eruit omdat Next.js dit nu doet. Pagina's renderen statisch tijdens de build, krijgen een tag, en revalideren via een webhook vanuit WordPress. De doellatentie van publish-naar-live was twee seconden. We zaten net boven de één.

De fetch aan de Next.js-kant voorziet de response van een tag:

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
}

De revalidatie-endpoint vangt een webhook op en bust de tag. Next.js documenteert de API; de wiring is klein:

// 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}` })
}

Aan de WordPress-kant vuurt een mu-plugin van veertien regels de webhook af bij publicatie:

// 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);

De blocking => false is belangrijk. De editor klikt op Update en krijgt haar admin-melding in normale WordPress-tijd. De webhook wordt asynchroon afgevuurd. Next.js revalideert de tag. De volgende bezoeker van /work/sabai-forum krijgt de nieuwe versie. Sla je de false-blocking-vlag ooit over, dan krijg je een editor die klaagt dat Update vier seconden duurt, en dan verdien je het.

Preview-modus die de editor echt gebruikt

Wat editors verder doen, is concepten previewen. Als preview kapot is, stoppen ze ermee, dan vertrouwen ze headless niet meer, dan vragen ze de oude front-end terug. Behandel preview als feature, niet als nakomertje.

In wp-admin gaat de 'Preview'-knop naar een URL naar keuze. Wijs hem aan op /api/draft op de Next.js-host met een ondertekend token en de slug van de post:

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 verifieert de HMAC, zet draft-modus aan en stuurt door naar de canonieke URL, waar de datalaag de ongepubliceerde revisie ophaalt via een geauthenticeerde 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}`)
}

Eén detail dat bijt: in draft-modus nooit cachen. De fetch in je datalaag heeft cache: 'no-store' nodig zodra draftMode().isEnabled waar is. Dat leerden we toen een editor hetzelfde concept vijf keer previewde en telkens de eerste versie te zien kreeg.

Cutover zonder zoekposities te verliezen

De laatste meter is DNS en redirects, en daar lekken de meeste retrofits SEO. Drie dingen tellen.

Eén: zet wp-admin op een eigen host. Wij kozen admin.client.com. De front-end client.com gaat naar Next.js. Editors krijgen een bookmark-update van één regel. Brute-force-bots die /wp-login.php op het publieke domein platlegden, komen er niet meer in, want het publieke domein serveert helemaal geen WordPress meer.

Twee: de URL-vormen moeten matchen. Als de oude site /case-studies/sabai-forum/ serveerde, doet de nieuwe dat ook. Dezelfde trailing slash. Dezelfde hiërarchie. Verander je iets, schrijf dan 301's voor elke oude URL en verifieer ze met een crawl. Wij draaien Screaming Frog op staging vóór de DNS-flip, en nogmaals twee uur erna.

Drie: hou /wp-content/uploads/ minstens één releasecycle bereikbaar. De Next.js-front verwijst naar afbeeldingen via absolute WordPress-URL's. De mediabibliotheek staat nog steeds in wp-content/uploads op de admin-host. Proxy /wp-content/uploads/ via Next.js, of herschrijf de afbeeldings-URL's bij de GraphQL-response zodat ze naar de admin-host wijzen. De proxy is schoner, de rewrite is sneller, beide werken zolang editors geen nieuwe hero-afbeeldingen uploaden die in productie een 404 geven.

De cirkel rond

De retrofit werkt alleen als wp-admin er op dag één van go-live identiek uitziet. Elke redactie-interface die je verandert, is een kans dat het project stilvalt. Toen we dit deden voor een klant wiens netwerk van vier subsites tegen het PHP-EOL aanliep, ging de front-end van een Lighthouse-score van 31 naar 96, kromp de pluginmap van achtendertig naar eenentwintig, en boekte het redactieteam geen enkele trainingssessie, want er was niets nieuws om op te trainen. De ACF-veldgroepen die ze in 2017 uit hun hoofd leerden, renderden in 2026 nog steeds dezelfde componenten. We hebben dit patroon voor legacy migratie inmiddels zo vaak gedraaid dat het audit-tot-cutover-venster zes weken is, geen zes maanden.

Het kleinste wat je vandaag kunt doen: draai wp plugin list --status=active op je eigen productiesite, open de CSV, en tag elke regel als admin, front-end of dood. Dat uurtje sorteren vertelt je of je retrofit een klus van drie weken of drie maanden is.

Kern

Retrofitten, niet herschrijven: laat wp-admin en de ACF-veldvormen staan, verhuis de front-end naar Next.js + WP-GraphQL, en het redactieteam merkt er niets van.

FAQ

Moeten we ACF-veldgroepen helemaal opnieuw bouwen?

Nee. WPGraphQL for ACF stelt bestaande veldgroepen automatisch beschikbaar als GraphQL-types. De editor ziet hetzelfde admin-formulier, de veldnamen blijven identiek, en jij schrijft React-componenten tegen het gegenereerde schema.

Hoe snel is publish-naar-live met on-demand revalidatie?

Bij onze laatste retrofit zat de mediaan net boven één seconde tussen klikken op Update en de nieuwe versie op de publieke URL. De WordPress-webhook is non-blocking, dus de editor wacht nooit op Next.js.

Kunnen editors de Gutenberg-blokeditor blijven gebruiken?

Ja, als je Gutenberg-blokken via WP-GraphQL bevraagt en elk bloktype in React rendert. De meeste retrofits die wij zien zijn ACF-gedreven, dus dit weegt minder, maar Gutenberg werkt prima headless.

Wat gebeurt er met plugins als WP Rocket en Smush na de migratie?

Die gaan eruit. Next.js doet caching en image-optimalisatie aan de front-end, dus de front-facing caching- en image-plugins worden dood gewicht. Admin-only-plugins als ACF Pro en Yoast blijven.

Hoe regelen we WordPress-preview voor concepten op een headless front-end?

Override preview_post_link in PHP zodat hij wijst naar een /api/draft-route in Next.js met een ondertekend HMAC-token. De route zet draft-modus aan en leest de ongepubliceerde revisie via een geauthenticeerde GraphQL-query.

wordpresslegacy sitesmigrationphparchitectureworkflow

Iets bouwen?

Start een project