← Blog

WordPress

Headless WordPress: één install splitsen in drie frontends

Eén WordPress-installatie draaide de blog, de shop en het ledenportaal voor een Amsterdams mediamerk van twaalf mensen. In de herfst van 2025 ging er niets meer goed. Zo hebben we 'm gesplitst.

Jacob Molkenboer· Oprichter · A Brand New Company· 7 jun 2026· 10 min
Versleten leren grootboek op ivoorpapier, koperen tabs waaieren uit, groen lint en rood lakzegel ernaast.

Het was een dinsdag in november en de admin-spinner draaide al veertig seconden. De features-redacteur ververste de pagina, klikte opnieuw op publiceren en opende een tweede tab. De homepage laadde nog, traag, maar het dashboard op /wp-admin was muntje opgooien geworden. Het coververhaal van morgen zat drie revisies diep en de shop meldde mislukte checkouts. De uitgever stuurde ons om 22:51 een berichtje: "Gaat de site er nu uit of ligt het aan mij?"

Het lag niet aan haar. Dezelfde WordPress-installatie draaide sinds 2017 de blog, de shop en het ledenportaal voor een Amsterdams mediamerk van twaalf mensen. Hij droeg 4.800 longreads, een archief van 80 printproducten en 2.300 betalende leden. Elke plugin-update was een klein gebed. We waren zes weken eerder ingehuurd om de site sneller te maken. Tegen die dinsdag was de diagnose klaar en luidde het antwoord niet langer 'sneller'. Het antwoord luidde 'minder'.

Eén installatie, drie taken, geen marge

Voor de lezer was de site één product. Voor de database waren het er drie. De blog was een hoog-leest, laag-schrijf surface die agressieve HTTP-cache wilde en een CDN die dat ook echt geloofde. De shop wilde geauthenticeerde PHP-sessions, write-heavy cart state, en een payment processor die niet deed alsof de gebruiker anoniem was. Het portaal (paywalled longreads, een ledendirectory, reacties onder elk stuk) wilde allebei, plus een notie van 'ingelogd' die elke plugin met z'n eigen cookie-idee overleefde.

In één wp-content-map zaten 41 actieve plugins op elkaar geplakt. Zes ervan raakten wp_users aan op elke pageload. Twee ervan cachten pagina's op de edge, wat brak zodra de membership-plugin besloot een sessiecookie weg te schrijven, wat altijd was. De object cache was in 2022 uitgezet om een bug te fixen die niemand nu nog kon noemen. wp-cron draaide op elke ongecachete request, wat dus op de meeste requests was, want de cache stond uit.

De vraag van de uitgever was de juiste. De site stond niet op het punt om dood te gaan. Hij was al dood en liep alleen nog door. We moesten snijden.

De architectuur met drie surfaces

We stelden voor de installatie te splitsen in drie onafhankelijk gedeployde front-ends, die elk een uitgeklede WordPress aanspraken die alleen nog als content-API draaide. Redacteuren bleven inloggen op dezelfde WP-Admin die ze al acht jaar gebruikten. Lezers zouden nooit meer PHP zien.

  • blog.brand.nl, een Astro statische site, volledig cachebaar, die op build-time uit de WP REST API trekt plus incremental rebuilds bij publicatie.
  • shop.brand.nl, Astro met server islands, praat met Adyen voor checkout.
  • members.brand.nl, Astro met een dunne Node-sessielaag, gebruikt WP als single source of truth voor gebruikers en rechten, met paywalled artikel-HTML uit dezelfde headless API die de blog gebruikt.

Het ging niet om het framework. Het ging erom dat geen enkele storing de redactiekalender nog kon platleggen. Brak de Adyen-koppeling van de shop, dan bleef de blog publiceren. Crashte de sessielaag van het portaal, dan bleef de checkout verkopen. Viel WordPress zelf om, dan bleven twee van de drie surfaces overeind omdat ze die ochtend statisch waren gebouwd.

Het playbook, fase voor fase

Dit is wat we gedraaid hebben. Zes weken werk, geen redactionele blackout, geen lezers die het merkten.

Fase 0: read-only inventaris

Voordat we één regel code schreven, brachten we de installatie in kaart. Twee commando's en een avond deden het meeste werk:

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

Eenenveertig actieve plugins, elf custom post types, waarvan zes sinds 2019 geen enkele gepubliceerde entry meer hadden. We deactiveerden de dode op een staging-kloon, zagen niets breken, en stuurden die deactivatie uit als eerste PR. De site werd 18% sneller voordat we ook maar iets anders aanraakten.

Fase 1: WordPress omklappen naar een API

We installeerden WPGraphQL naast de REST API en zetten er een CDN voor. Custom post types kregen REST controllers; ACF-velden werden geregistreerd met show_in_rest. De membership-plugin was de enige die vocht. Hij was eigenaar van wp_users en weigerde rechten bloot te leggen via een API die hij niet zelf had geschreven. We omsloten hem met een kleine mu-plugin die precies twee endpoints aanbood:

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, tien minuten TTL op de CDN, niets lekte. De plugin-auteurs hebben nooit iets gemerkt.

Fase 2: de blog, als eerste en goedkoopste

Eerst de blog, want dat was de makkelijkste winst en de grootste lezerssurface. We bouwden 'm in Astro met content collections gevoed uit de REST API. Een nachtelijke full build, plus een on-publish webhook die alleen de geraakte pagina's incrementeel opnieuw bouwde.

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

De eerste productie-deploy haalde 92 op Lighthouse mobile. Het oude WordPress-thema hing rond de 31.

Let op

WPGraphQL en de REST API zijn het oneens over hoe je draft posts blootlegt. Redacteuren gaan ervan uit dat preview werkt zodra je een headless front-end live zet. Bouw op dag één een signed preview-route, of je hoort het op dag twee.

Fase 3: de shop, op Adyen

De shop was een WooCommerce-installatie met tachtig SKU's, vooral oude nummers en één print-abonnement. WooCommerce had z'n eigen kopie van de catalogus, z'n eigen checkout en z'n eigen mening over session state. We hielden de catalogus in WP (die UI kenden de redacteuren), verplaatsten checkout naar Adyens drop-in, en lieten Astro server islands de twee aan elkaar naaien.

Adyen boven Stripe kwam neer op één ding: het merk had Nederlandse B2B-kopers die vaker met iDEAL en SEPA betaalden dan met kaart. Adyen handelt iDEAL native af zonder een extra processor ertussen. We wisselden de payment-poot in acht dagen. Order-webhooks landden nog steeds op een Woo-endpoint, dat nog steeds naar wp_woocommerce_orders schreef, waar de boekhouder van de uitgever nog steeds haar gebruikelijke export uit kon trekken.

Fase 4: het ledenportaal, als laatste en traagst

Het portaal als laatste, want session state is waar je de ergste fouten maakt. We hielden WordPress aan als identity provider. Een kleine Node-sidecar accepteerde login-posts, sprak het WP REST-endpoint aan dat we in fase 1 hadden geschreven, ondertekende een JWT, en zette een HttpOnly-cookie scoped op .brand.nl. De Astro front-end las de cookie op de edge en besloot of die de paywalled body of de paywall serveerde.

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

Geen lid hoefde opnieuw in te loggen. De cookie-naam en het domein bleven identiek aan de oude WordPress-versie, de JWT verving de oude PHP-session, en de database-rij was dezelfde rij die hij altijd al was.

Fase 5: DNS en de cutover

We zetten 'm surface voor surface om. De blog ging als eerste, op een vrijdag om 11:00 Amsterdamse tijd, terwijl de features-redacteur meekeek. De shop volgde twee weken later, op een dinsdagochtend nadat de boekhouder een volledige export had gedraaid. Het portaal kwam als laatste, op een zondag om 04:00, toen 2.300 leden grotendeels sliepen en het noodnummer door ons werd beantwoord.

De oude brand.nl werd een dunne Astro-shell die elk pad met een 301 doorstuurde naar de juiste nieuwe surface. We hielden WordPress bereikbaar op cms.brand.nl, afgeschermd op kantoor-IP's plus de thuisverbindingen van de redactie. Zoekmachines zagen schone 301's, lezers zagen niets nieuws, redacteuren zagen hetzelfde inlogscherm.

De redactiekalender in leven houden

De enige harde regel van de uitgever was: breek de kalender niet. Het merk publiceert vier stukken per week, plus een vrijdagnieuwsbrief die put uit de posts van die week. Dat respecteerden we met drie regels die saai klinken en elk uur dat ze kostten dubbel en dwars waard waren.

Eén. Redacteuren bleven gedurende de hele migratie in dezelfde WP-Admin publiceren. We vroegen ze niet om een nieuw CMS te leren in hetzelfde kwartaal waarin we de front-end wisselden. Migratietutorials die beginnen met 'wissel eerst je team naar een nieuwe editor' raken hun team kwijt.

Twee. Elke front-end ging live achter een feature flag die de redacteur kon zien. Een kleine banner binnen WP-Admin liet zien naar welke surface een gegeven post zou renderen en of de laatste build geslaagd was. Sprong de banner op rood, dan zagen wij het voordat de lezer dat deed.

Drie. De vrijdagnieuwsbrief putte uit dezelfde REST API als de blog, niet uit de RSS van het oude WordPress-thema. Vanaf het moment dat de API live stond, was de nieuwsbrief surface-onafhankelijk. De migratie kon eronder bewegen zonder dat hij het merkte.

Dingen die we fout deden

We namen aan dat de REST-surface van de membership-plugin compleet was. Dat was niet zo. Twee endpoints die we nodig hadden waren in een release uit 2024 verwijderd omdat, volgens de changelog, 'niemand ze gebruikte'. We moesten de plugin forken en houden er nog steeds een dunne patch op. Als je op de API van een plugin gokt, lees dan de changelog van de afgelopen drie jaar, niet die van de afgelopen drie maanden.

We onderschatten ook image handling. WordPress had vijftien thumbnail-formaten per upload aangemaakt. Astro wilde responsive images zelf afhandelen. Zes weken lang shipten we allebei, waardoor de opslag verdubbelde. Uiteindelijk schreven we een one-off script dat elke thumbnail wegmikte die sinds 2022 niet meer door een post werd gerefereerd. Daarmee wonnen we 71 GB terug.

En we ontdekten, op de harde manier, dat wp-cron-jobs die door de membership-plugin werden ingepland aannamen dat de publieke site bereikbaar was op dezelfde hostname als de admin. Door de admin naar cms.brand.nl te verplaatsen brak negen dagen lang stilletjes de renewal-cron. Leden van wie het abonnement in dat venster afliep moesten handmatig hersteld worden. We monitoren nu het cron-schema van elke plugin als onderdeel van elke headless cut.

Wat je vanmiddag kunt doen

Zit je op een WordPress-installatie die in drie taken is uitgegroeid, dan hoef je niet met de rewrite te beginnen. Begin met fase 0. Draai wp plugin list --status=active en wp post list --post_type=any --format=count. Tel de plugins die wp_users aanraken. Tel de post types met nul recente entries. De vorm van de cut die je uiteindelijk nodig hebt zit al in die twee getallen.

Toen we deze headless migratie bouwden voor de Amsterdamse uitgever, was het ding dat we steeds verkeerd inschatten de aanname dat het redactieteam zich aan de nieuwe tooling zou aanpassen. Dat hoefde nooit. Ze hielden hun inlogscherm, hielden hun kalender, hielden hun vrijdagritme. We veranderden de surface eronder, fase voor fase, en ze merkten het alleen omdat de spinner stopte met draaien.

Kern

Je migreert geen CMS. Je migreert een publicatie-gewoonte. Houd het inlogscherm van de redacteur identiek en je hebt de toestemming verdiend om alles erachter te veranderen.

FAQ

Moeten redacteuren een nieuw CMS leren?

Nee. Het hele punt van de headless cut is dat de bestaande WP-Admin-login blijft staan. Redacteuren zien het nieuwe framework nooit. Ze loggen in waar ze altijd inlogden en publiceren in dezelfde velden.

Waarom Astro in plaats van Next.js of SvelteKit?

Astro is standaard statisch, en dat is precies wat een mediasurface met veel lezers wil. Elk dynamic island is opt-in, dus de kosten van interactiviteit per pagina blijven zichtbaar. Next.js maakt het makkelijk om die kosten te verstoppen.

Hoe lang duurt zo'n split voor een site met 4.800 artikelen?

Zes weken end-to-end met één senior engineer en een parttime PM. De blog gaat in twee weken live; shop en portaal kosten elk ongeveer twee weken extra. De inventaris in fase 0 is één avond werk.

Wat gebeurt er met SEO tijdens de cutover?

Niets, als je elk oud pad met een 301 naar de nieuwe surface stuurt en canonical URL's stabiel houdt. Over de cutover heen zagen we geen rankingbeweging op de top 200 queries van de uitgever.

Verlaagt headless de hostingkosten?

Meestal wel. De PHP-container krimpt omdat hij alleen nog admin- en API-verkeer bedient. Het meeste lezerverkeer verhuist naar een CDN. De maandelijkse rekening van de uitgever ging zo'n 40% omlaag.

wordpressmigrationarchitecturelegacy sitesphpoperations

Iets bouwen?

Start een project