← Blog

WordPress

WordPress multisite naar headless: 9.300 redirects intact

De SEO-lead opende de redirect-spreadsheet: 9.304 regels, in tien jaar bijgeschaafd. We stonden op het punt 47 subsites naar een headless stack te verhuizen zonder er eentje te verliezen.

Jacob Molkenboer· Oprichter · A Brand New Company· 7 feb 2025· 11 min
Open leren logboek met groen lint, messing sleutel en crème indexkaarten op ivoorpapier naast donkergroene linnen loper.

De SEO-lead mailde ons de spreadsheet op een zondag om 23:47. Tab één: 9.304 redirects, gesorteerd op aantal hits. Tab twee: tien jaar aantekeningen in de commentaarkolom ("Vakblad-fusie 2019", "PDF-archief ingestort", "Eduardo's lievelings-rewrite, niet aankomen"). Tab drie: een vlakke weigering om groen licht te geven voor de migratie totdat elke regel werkte op dag één van de nieuwe stack.

De klant geeft 47 vakbladen uit voor brancheorganisaties, allemaal binnen één WordPress multisite-netwerk. Twaalf jaar aan subsites, custom post types, ACF-schema's en de redirect-graph van de SEO-lead die rustig zo'n 18% van het organische verkeer draagt. Wij gingen het hele zaakje herbouwen op Next.js en Sanity. De briefing was simpel. Verlies geen redirects.

Het netwerk in kaart brengen voordat je code aanraakt

De eerste fout die teams maken bij een multisite-migratie: je behandelt het alsof het 47 losse sites zijn. Dat is het niet. Het is één users-tabel, één options-ladder, één redirect-graph en 47 sets content-tabellen (wp_2_posts, wp_3_posts, helemaal door tot wp_48_posts). Het multisite-schema deelt precies de stukken die je op cutover-dag breken, niet de content per site.

We begonnen met een inventarisatie van elk bewegend onderdeel via wp-cli over SSH:

wp site list --fields=blog_id,url,registered > sites.csv

for blog_id in $(wp site list --field=blog_id); do
  url=$(wp site list --blog_id=$blog_id --field=url)
  wp post list --url=$url --post_type=any --post_status=any \
    --format=json > "exports/site-${blog_id}.json"
done

wp option get permalink_structure --network
wp plugin list --network --status=active > plugins.csv
wp user list --network --format=csv > users.csv

De redirect-regels stonden op drie plekken die niemand samen gedocumenteerd had: de wp_redirection_items-tabel van de Redirection-plugin (8.104 regels), de netwerk-.htaccess (1.089 regels, restant van de magazine-consolidatie in 2019), en 111 hard-coded entries in een custom mu-plugin die het vorige bureau had achtergelaten in wp-content/mu-plugins/legacy-paths.php. We trokken alle drie samen in één CSV en dedupliceerden op source-path. Eindstand: 9.304 unieke regels, waarvan 8.927 301's en 377 302's. We hielden de statuscodes letterlijk aan. Een 302 die negen jaar lang een 302 is, doet dat met een reden.

URL-structuur voor 47 sites in één Next.js-app

Het oude netwerk gebruikte subdomain-mode (vakblad-x.publisher.nl, vakblad-y.publisher.nl). De reflex is om dat in Next.js na te bouwen met 47 Vercel-projecten. Wij hebben dat verworpen. Zevenenveertig projecten betekent zevenenveertig build-pipelines, zevenenveertig preview deploys en zevenenveertig KV-bindings die je synchroon moet houden.

In plaats daarvan draaiden we één Next.js-app. Een kort stukje middleware leest de Host-header, schrijft de request om naar /_sites/[siteSlug]/..., en laat de app de juiste tenant renderen. De URL die de bezoeker ziet, verandert niet. De interne route weet op welke site hij zit.

// middleware.ts (excerpt, runs before the redirect lookup)
import { NextResponse, type NextRequest } from 'next/server'
import { SITE_BY_HOST } from './lib/sites'

export function rewriteForTenant(req: NextRequest) {
  const host = req.headers.get('host') ?? ''
  const site = SITE_BY_HOST[host.replace(/:\d+$/, '')]
  if (!site) return null
  const url = req.nextUrl.clone()
  url.pathname = `/_sites/${site}${url.pathname}`
  return NextResponse.rewrite(url)
}

SITE_BY_HOST is een constante van 47 regels, op build-tijd gegenereerd uit de Sanity-dataset. Geen runtime-lookup, geen cold-start-belasting. De hele tenant-resolutie is een string-vergelijking.

Sanity-schemaontwerp voor de multi-tenant-situatie

De eerste architectuurvraag bij elke multi-tenant headless-migratie is of elke tenant zijn eigen dataset krijgt. De Sanity-docs beschrijven datasets als geïsoleerde namespaces, en dat klinkt als het juiste model voor 47 vakbladen. We hebben het overwogen en verworpen, om dezelfde reden als 47 Next.js-projecten.

Cross-site redactionele moves (één redacteur die een artikel tussen drie vakbladen verschuift), gedeelde auteursrecords en gedeelde taxonomieën voor branchecategorieën maakten isolatie duur. We kozen voor één dataset met een site-reference op elk document.

// schemas/article.ts
export default {
  name: 'article',
  type: 'document',
  fields: [
    { name: 'site', type: 'reference', to: [{ type: 'site' }] },
    { name: 'slug', type: 'slug' },
    { name: 'legacyId', type: 'number', readOnly: true },
    { name: 'title', type: 'string' },
    { name: 'body', type: 'portableText' },
  ],
  validation: (Rule) => Rule.custom(async (doc, ctx) => {
    const dup = await ctx.getClient({ apiVersion: '2024-01-01' }).fetch(
      `*[_type=="article" && site._ref==$site && slug.current==$slug && _id!=$id][0]._id`,
      { site: doc.site?._ref, slug: doc.slug?.current, id: doc._id }
    )
    return dup ? 'Slug exists on this site' : true
  }),
}

legacyId is het WordPress-post-ID. We hebben dat op elk document opgeslagen zodat een herimport geen duplicaten oplevert, en zodat de redirect-engine de nieuwe URL kan opzoeken op basis van het oude ID wanneer er een verouderde link binnenkomt. Dat ene veld heeft ons over het hele project ongeveer vier dagen opruimwerk bespaard.

Waarom de redirect-tabel geen config-bestand is

Hier loopt het bij de meeste teams vast op een project van deze omvang. De reflex is om redirects in next.config.js of vercel.json te zetten. Dat werkt bij tien. Het werkt niet bij 9.300.

De platformlimieten van Vercel cappen redirects in vercel.json op 1.024 entries. Redirects in next.config.js compileren naar hetzelfde routing-manifest en raken hetzelfde plafond. We hebben het toch geprobeerd, want we proberen altijd eerst de luie route. De deploy faalde met een duidelijke foutmelding en bespaarde ons een week vechten tegen een architectuur die nooit ging meeschalen.

Waarschuwing

Als je redirect-bestand boven de vier cijfers zit, stop. De juiste plek voor de tabel is een lookup-store die de edge in minder dan 5ms kan lezen. Alles anders deployt niet, of duwt de lookup naar de origin en sloopt daar je cache hit ratio.

We verhuisden de tabel naar Vercel KV. De vorm was bewust saai: één key per source-path, value is de bestemming plus de statuscode. Totale opslag met 9.304 entries was zo'n 1,4 MB. We seeden het op deploy-tijd vanuit een CSV, idempotent.

// scripts/load-redirects.ts
import { kv } from '@vercel/kv'
import { parse } from 'csv-parse/sync'
import fs from 'node:fs'

const rows = parse(fs.readFileSync('redirects.csv'), { columns: true })

const pipeline = kv.pipeline()
for (const r of rows) {
  pipeline.set(`r:${r.source}`, JSON.stringify({ to: r.target, code: Number(r.code) }))
}
await pipeline.exec()
console.log(`loaded ${rows.length} rules`)

De middleware die het werk doet

De redirect-engine is één bestand. Het hele institutionele geheugen van een decennium leeft achter deze lookup.

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { kv } from '@vercel/kv'

export const config = {
  matcher: '/((?!_next/|api/|favicon.ico|robots.txt).*)',
}

export async function middleware(req: NextRequest) {
  const path = decodeURIComponent(req.nextUrl.pathname)
  const hit = await kv.get<{ to: string; code: 301 | 302 }>(`r:${path}`)
  if (!hit) return NextResponse.next()

  const url = hit.to.startsWith('http')
    ? hit.to
    : new URL(hit.to, req.url).toString()

  return NextResponse.redirect(url, hit.code)
}

Die decodeURIComponent-call is wat ons bij go-live zeventien gebroken regels heeft gered. WordPress-URL's uit 2014 bevatten standaard encoded spaces en Nederlandse diakritieken (vakblad-w%C3%A9rk, %20 in slugs uit een import van 2016). De CSV uit de Redirection-plugin had ze URL-encoded staan. De request-paths die de middleware binnenkwamen, waren al door Next.js gedecodeerd. De match vuurde nooit. Het path decoderen vóór de lookup loste het op.

Content idempotent migreren

We schreven één import-script per content type, niet één groot script. Elk script was idempotent op legacyId. Je kon het tijdens de generale repetitie van 4 uur 's ochtends opnieuw draaien zonder duplicaten te produceren.

// scripts/import-articles.ts
import { createClient } from '@sanity/client'
import wpPosts from '../exports/all-articles.json'
import { portableTextFromHtml } from './lib/html-to-pt'

const sanity = createClient({ projectId, dataset, token, useCdn: false })

for (const post of wpPosts) {
  await sanity.createOrReplace({
    _id: `article-${post.id}`,
    _type: 'article',
    legacyId: post.id,
    site: { _type: 'reference', _ref: `site-${post.blog_id}` },
    slug: { current: post.slug },
    title: post.title.rendered,
    body: portableTextFromHtml(post.content.rendered),
    publishedAt: post.date_gmt,
  })
}

Het lastige stuk was portableTextFromHtml. Twaalf jaar aan redactionele output betekent twaalf jaar aan inline styles, deprecated shortcodes, Gutenberg-blokken, classic-editor HTML, restanten van Visual Composer en hier en daar een <font>-tag. We deden drie passes: shortcodes uitklappen, blokken normaliseren, daarna HTML naar Portable Text via het officiële @sanity/block-tools-pakket. Ongeveer 4% van de artikelen werd gevlagd voor handmatige review. De redactie werkte ze in twee middagen weg.

De Yoast-metadata die niemand wilde herbouwen

Het andere ding waar de SEO-lead tien jaar aan getuned had, was de per-post Yoast-metadata: focus keywords, canonical-overrides, OpenGraph-titels die afweken van de H1 omdat de H1 voor lezers was en de OG-titel voor de LinkedIn-share. Twaalf jaar daarvan, verspreid over zo'n 38.000 artikelen, stond in wp_postmeta-rijen met keys als _yoast_wpseo_metadesc en _yoast_wpseo_canonical.

We haalden het op met één query per site, normaliseerden de keys en plakten het resultaat in een Sanity-object op elk artikel:

SELECT post_id,
       MAX(CASE WHEN meta_key='_yoast_wpseo_metadesc'  THEN meta_value END) AS metadesc,
       MAX(CASE WHEN meta_key='_yoast_wpseo_canonical' THEN meta_value END) AS canonical,
       MAX(CASE WHEN meta_key='_yoast_wpseo_title'     THEN meta_value END) AS og_title,
       MAX(CASE WHEN meta_key='_yoast_wpseo_focuskw'   THEN meta_value END) AS focuskw
FROM wp_2_postmeta
WHERE meta_key LIKE '_yoast_wpseo_%'
GROUP BY post_id;

Uit die data kwamen twee verrassingen. Ongeveer 800 artikelen hadden een handmatige canonical die naar de URL van een concurrerende publicatie wees: de SEO-lead was na een overname in 2021 bewust ranking-signalen aan het consolideren, en die pointers deden echt werk. We hebben er geen één aangepast, letterlijk overgenomen. De tweede verrassing: de OG-titels waren systematisch acht tot twaalf tekens langer dan de H1's. Dat was de SEO-lead die share-copy onafhankelijk van page-copy testte. Het nieuwe schema houdt de twee velden apart, zodat de redactie dit zonder code-change kan blijven doen.

De sitemap was het andere bewegende stuk. Yoast genereerde er één per subsite op /sitemap_index.xml, met gekoppelde sub-sitemaps. De Search Console-properties bij de uitgever stonden ingesteld op precies die URL's, en die opnieuw verifiëren zou een dans betekenen met 47 losse accounts waar niemand tijd voor had. We bouwden de URL-vorm na in Next.js met een route handler die per tenant een sitemap-index streamt, gepagineerd op 50.000 URL's per sub-sitemap om onder het plafond van het sitemap-protocol te blijven. Search Console heeft nooit gemerkt dat de backend veranderde.

De generale repetitie die telde

We draaiden de volledige migratie twee keer op een staging-stack voordat we DNS aanraakten. De tweede keer zat de SEO-lead naast ons en speelden we haar 9.304 redirects opnieuw af via een crawler.

# verify.sh
while IFS=, read -r source expected_target expected_code; do
  out=$(curl -s -o /dev/null -w "%{http_code},%{redirect_url}" \
    "https://staging.example.nl${source}")
  actual_code="${out%%,*}"
  actual_target="${out#*,}"
  if [ "$actual_code" != "$expected_code" ] || \
     [ "$actual_target" != "$expected_target" ]; then
    echo "MISS: $source ($expected_code -> $expected_target) \
 got $actual_code -> $actual_target"
  fi
done < redirects.csv

Eerste run: 9.221 hits, 83 misses. We gingen de misses met de SEO-lead aan hetzelfde bureau door. Ongeveer de helft waren echte bugs in onze import (het URL-decoding-probleem zat daar). De andere helft waren redirects die al jaren stuk stonden in productie zonder dat iemand het had gemerkt. Ze hield er acht bewust in leven, zette de rest op pensioen en tekende de spreadsheet af.

Onthouden

Een redirect-graph is institutionele kennis. Het decennium aan beslissingen dat een goede SEO-lead heeft genomen over hoe verkeer door een publicatie loopt, is geen config-bestand dat je vervangt. Het is een graph die je bewaart, en de taak van de engineer is om hem te hosten zonder regels te verliezen.

Cutover en het eerste uur

We schakelden over op een dinsdag om 06:00 Amsterdamse tijd. De DNS TTL was de week ervoor naar 60 seconden gezet. De lookup-tabel was in KV vooraf opgewarmd in drie regio's. De oude WordPress-stack bleef nog achtenveertig uur live op een subdomain achter basic auth, voor het geval we iets moesten vergelijken.

Uur één: 47 sites online, redirect hit rate van 0,31 per request (ongeveer wat de spreadsheet voorspelde), geen 5xx, p95 middleware latency 11ms cold en 4ms warm. Uur zes: de SEO-lead stuurde dezelfde crawler terug, dit keer gericht op productie. 9.304 van 9.304 regels vuurden zoals verwacht. Ze hield de spreadsheet vastgepind in haar dock.

Wat we anders zouden doen

Eén ding. We zouden de verifier eerst hebben geschreven, vóór enige import-code. De reden dat we een dag kwijt waren aan URL-decoding, was dat we het probleem pas in de tweede generale repetitie ontdekten en niet bij de eerste import. Een red/green test-harness tegen de oude site, geschreven voordat er ook maar een regel migratie-code draait, had het op dag twee gevangen in plaats van op dag twaalf.

De andere les is ouder dan de stack. De kickoff-meeting bij een project als dit bevat altijd de suggestie om de redirect-graph als onderdeel van de verhuizing te "vereenvoudigen". De SEO-lead duwde daar hard op terug en ze had gelijk. Tien jaar van haar werk doet op de achtergrond echt omzetwerk, en het meeste daarvan is niet leesbaar voor wie het niet heeft zien ontstaan. De laatste maanden hoor je geregeld de discussie over welke taken een LLM wel of niet kan doen. Een redirect-graph is het weinig flatterende antwoord. Je kunt boilerplate op schaal genereren, en je kunt nog steeds niet de persoon vervangen die weet dat een 302 uit 2017 de bookmark van één specifieke reseller in leven houdt.

Toen we de headless-stack bouwden voor deze Nederlandse vakbladuitgever, kwamen we steeds terug op het idee dat de taak van de migratie niet was om slim te zijn. De taak was om tien jaar aan waarde over een stack-grens te tillen zonder iets te laten vallen. Dit soort legacy-migraties doen we vaak genoeg dat het bovenstaande draaiboek inmiddels de standaard volgorde is.

Sta je voor een project van deze omvang? De audit van vijf minuten is dit: open je redirect-store, tel de regels, en als het er meer dan 1.024 zijn, hou dan op te doen alsof de ingebouwde redirect-config van het platform het gaat trekken. Verhuis de tabel, zet een middleware-lookup ervoor, en schrijf de verifier voordat je de importer schrijft.

Kern

Een tien jaar oude redirect-graph is institutionele kennis, geen config-bestand. Verhuis hem naar een edge KV-lookup en de migratie wordt veilig om te shippen.

FAQ

Kunnen we WordPress-redirects in next.config.js of vercel.json laten staan?

Alleen als je er minder dan 1.024 hebt. Het routing-manifest van Vercel cap't op 1.024 redirects over beide bestanden samen. Daarboven verhuis je de tabel naar een edge KV en doe je de lookup in middleware.

Moet elke subsite zijn eigen Sanity-dataset krijgen?

Meestal niet. Eén dataset met een site-reference op elk document geeft je cross-site redactionele moves, gedeelde auteurs en één webhook om te onderhouden. Gebruik aparte datasets alleen als isolatie een harde eis is.

Hoe lang duurt een migratie van een multisite met 47 sites van begin tot eind?

In onze ervaring zes tot tien weken, gedomineerd door HTML-naar-Portable-Text-opschoning en generale repetities, niet door code. Reken op minstens twee volledige generales voor de DNS-cutover.

Wat is het rollback-plan als de cutover misgaat?

Hou de oude WordPress-stack minimaal 48 uur live op een subdomain achter basic auth. Met een DNS TTL van 60 seconden die je een week van tevoren hebt ingesteld, schakel je binnen twee minuten terug.

Hoe gaan we om met URL-encoded tekens in oude redirects?

Decodeer het request-path voordat je de lookup doet. WordPress-URL's uit de vroege jaren tien bevatten vaak encoded spaces en diakritieken, maar Next.js levert het path al gedecodeerd af bij de middleware.

wordpressmigrationlegacy sitesseoarchitecturecase study

Iets bouwen?

Start een project