Drupal
Friese redactie van Drupal 7 naar Sanity: 4 weken cutover
Een redactie in Leeuwarden op Drupal 7 en PHP 7.0, twaalf jaar artikelen en een venster van vier weken. Zo verliep de shadow-traffic cutover echt.

Dinsdag, 06:42. De ochtendredactie in Leeuwarden drukt op publiceren voor het hoofdartikel en de spinner blijft negen seconden draaien. Drupal 7 doet wat Drupal 7 doet na twaalf jaar en 14.200 artikelen: elke cache-tag wissen die hij kent, plus een paar waarvan hij vergeten was dat hij ze had. Negen seconden is een eeuwigheid als de print-PDF om zeven uur bij de drukker moet zijn.
Dat was de opdracht. Een Friestalige dagkrant, negentien mensen in het pand, custom PHP 7.0-modules van een ontwikkelaar die in 2019 vertrok, en een content-boom die niemand volledig in kaart had gebracht. Het bestuur wilde Sanity en Next.js. Ze wilden het zonder ook maar één Google News-positie te verliezen. Ze wilden dat de URL van elk artikel sinds 2014 bleef werken. En ze wilden het binnen vier weken, omdat community-support voor Drupal 7 in januari was geëindigd en hun hosting-rekening stilletjes was verdrievoudigd terwijl niemand keek.
We deden het met een cutover van vier weken via shadow traffic. Dit is de werkelijke volgorde.
In kaart brengen wat er echt in Drupal stond
Voordat je een migratieplan kunt maken, moet je weten wat erin zit. De node-tabel meldde 14.200 gepubliceerde items. De node_revision-tabel meldde 89.000. Er waren zes content types, waarvan de redactie zei er vier nooit te gebruiken. Er waren negentien taxonomie-vocabulaires, waarvan elf sinds 2017 niet meer waren aangeraakt.
We draaiden één script. Geen tool. Een PHP-script van 90 regels dat met de live MySQL-replica praatte en een CSV opleverde:
node_id, type, title, author, status, created_at, last_edited_at, body_length, image_count, taxonomy_terms, has_video_embed
Die CSV werd geopend in LibreOffice. De hoofdredacteur ging een middag met een van onze developers zitten en gaf elke rij een kleur. Groen betekende "dit blijft". Geel betekende "dit blijft maar de structuur verandert". Rood betekende "dit wordt nooit geïmporteerd".
Van de 14.200 artikelen werden er 13.847 groen. 281 werden geel (dat waren fotogalerijen met een custom node bundle waarvan we wisten dat Sanity die anders zou modelleren). 72 werden rood, wat de redactie verraste. Dat waren testpagina's uit 2015 die niemand ooit de moeite had genomen te depubliceren.
Dit doen we bij elke legacy-migratie. Het lastigste van weg van Drupal is niet de techniek. Het is de mensen die de content kennen ook echt naar die content laten kijken.
Artikelen modelleren in Sanity
Drupal 7 sloeg een artikel op als: een node-rij, een node_revision-rij, twaalf field_data_*-rijen voor body, lede, auteur, rubriek, kicker, pull-quote, video-embed, bronvermelding enzovoort, plus image-referenties in file_managed, plus URL-aliassen in url_alias.
Sanity wil één document per artikel. Dus schreven we eerst het schema, voor er één import liep:
// schemas/artikel.ts
import {defineType, defineField} from 'sanity'
export const artikel = defineType({
name: 'artikel',
title: 'Artikel',
type: 'document',
fields: [
defineField({name: 'title', type: 'string', validation: r => r.required()}),
defineField({name: 'slug', type: 'slug', options: {source: 'title', maxLength: 96}}),
defineField({name: 'lede', type: 'text', rows: 3}),
defineField({name: 'body', type: 'array', of: [
{type: 'block'},
{type: 'image'},
{type: 'videoEmbed'},
{type: 'pullQuote'},
]}),
defineField({name: 'kicker', type: 'string'}),
defineField({name: 'author', type: 'reference', to: [{type: 'redacteur'}]}),
defineField({name: 'category', type: 'reference', to: [{type: 'rubriek'}]}),
defineField({name: 'publishedAt', type: 'datetime', validation: r => r.required()}),
defineField({name: 'legacyNodeId', type: 'number', hidden: true, readOnly: true}),
defineField({name: 'legacyUrl', type: 'string', hidden: true, readOnly: true}),
],
})
De twee belangrijke velden staan onderaan. legacyNodeId en legacyUrl reizen voor altijd mee op elk geïmporteerd document. Twee redenen. Eerst: als er iets stuk gaat (en er gaat altijd iets stuk), opent de redacteur het artikel in Sanity Studio en staat de legacy-URL direct naast de titel. Twee: de redirect-laag leest legacyUrl rechtstreeks om de redirect-map op te bouwen. Daarover later meer.
Het import-script, in drie passes
De import liep in drie passes. We probeerden het niet in één keer te doen. Eén pass betekent dat één bug alles terugdraait.
Pass één was auteurs en rubrieken. De users-tabel van Drupal mapten we op een redacteur-documenttype. taxonomy_term_data van Drupal mapten we op rubriek. Ongeveer 60 auteurs, 22 rubrieken. Klaar in 14 seconden tegen de Sanity mutations API.
Pass twee waren de artikelen. We trokken in batches van 200 uit MySQL, vormden de body-HTML om naar Portable Text en stuurden mutations.
// scripts/import-articles.mjs
import {createClient} from '@sanity/client'
import {htmlToBlocks} from '@portabletext/block-tools'
import {JSDOM} from 'jsdom'
import mysql from 'mysql2/promise'
const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: 'production',
apiVersion: '2026-01-01',
token: process.env.SANITY_WRITE_TOKEN,
useCdn: false,
})
const db = await mysql.createConnection(process.env.MYSQL_URL)
const [rows] = await db.execute(`
SELECT n.nid, n.title, n.created, n.status,
b.body_value, b.body_summary,
u.name AS author_name,
a.alias AS url_alias
FROM node n
JOIN field_data_body b ON b.entity_id = n.nid
JOIN users u ON u.uid = n.uid
LEFT JOIN url_alias a ON a.source = CONCAT('node/', n.nid)
WHERE n.type = 'artikel' AND n.status = 1
ORDER BY n.nid ASC
`)
let imported = 0
for (const row of rows) {
const blocks = htmlToBlocks(row.body_value, defaultSchema, {parseHtml: html => new JSDOM(html).window.document})
await sanity.createOrReplace({
_id: `artikel-${row.nid}`,
_type: 'artikel',
title: row.title,
lede: row.body_summary || extractFirstParagraph(row.body_value),
body: blocks,
publishedAt: new Date(row.created * 1000).toISOString(),
legacyNodeId: row.nid,
legacyUrl: row.url_alias || `node/${row.nid}`,
})
imported++
if (imported % 100 === 0) console.log(`Imported ${imported}/${rows.length}`)
}
Pass drie waren de afbeeldingen. Die deden we als laatste, omdat file_managed dertien jaar aan extensies in wisselende kapitalisatie bevatte, kapotte referenties en EXIF-data die niemand in de nieuwe CDN wilde. We streamden elke afbeelding vanaf de oude server, lieten hem door sharp() lopen om metadata te strippen en een WebP-variant te maken, en uploadden via de Sanity asset API. Die stap kostte eenenveertig uur kloktijd over een weekend.
De redirect-keten
Hier sneuvelen de meeste redactie-migraties. Je kunt het nieuwe CMS perfect hebben draaien en de redactie getraind, maar als /artikel/2017/lokaal/burgemeester-opent-bibliotheek op cutover-dag een 404 geeft, raak je een jaar opgebouwde SEO-autoriteit binnen een week kwijt.
De legacy redirect-keten was sinds 2014 organisch gegroeid. Er waren oude /content/[nid]-URL's van voor de URL-aliasing-module geïnstalleerd was. Er waren /node/[nid]-paden uit het pre-Pathauto-tijdperk. Er waren /artikel/[jaar]/[rubriek]/[slug]-paden vanaf 2016. En er stonden een paar honderd handmatig bewerkte rijen in de tabel van de Drupal redirect-module.
We dumpten alles. Zo'n 31.000 entries. Daarna bouwden we één Next.js middleware:
// middleware.ts
import {NextRequest, NextResponse} from 'next/server'
import {redirectMap} from './lib/redirect-map'
export function middleware(request: NextRequest) {
const path = request.nextUrl.pathname
const target = redirectMap.get(path)
if (target) {
return NextResponse.redirect(new URL(target, request.url), 308)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico).*)'],
}
De redirectMap is een Map<string, string> die tijdens build wordt gecompileerd uit een JSON-bestand. 31.000 entries. Wordt één keer in het geheugen geladen. Gemiddelde lookup aan de edge: onder een milliseconde. We hebben het gebenchmarkt.
308, geen 301. 308 behoudt de request-method en is het moderne equivalent. We kennen in 2026 geen enkele zoekmachine die 308 slechter behandelt dan 301.
Een redirect die naar een andere redirect wijst die weer naar de uiteindelijke URL leidt, kost je twee dingen: latency en crawl budget. Plat elke keten naar één hop voor je de JSON genereert. Als /content/1234 vroeger naar /node/1234 ging dat weer naar het uiteindelijke pad doorstuurde, stuurt de nieuwe map /content/1234 direct naar dat uiteindelijke pad. Eén hop. Altijd één hop.
Google News en de structured data
De oude setup spuugde NewsArticle JSON-LD uit via een custom Drupal-module. Die module werd sinds 2019 niet meer onderhouden. De JSON-LD die hij produceerde valideerde nog steeds, maar puur per ongeluk. We bouwden hem opnieuw als Next.js-component:
// components/ArtikelStructuredData.tsx
export function ArtikelStructuredData({artikel}: {artikel: Artikel}) {
const data = {
'@context': 'https://schema.org',
'@type': 'NewsArticle',
headline: artikel.title,
description: artikel.lede,
datePublished: artikel.publishedAt,
dateModified: artikel.updatedAt ?? artikel.publishedAt,
author: [{
'@type': 'Person',
name: artikel.author.name,
url: `https://example-newsroom.nl/redacteur/${artikel.author.slug}`,
}],
publisher: {
'@type': 'NewsMediaOrganization',
name: 'Example Newsroom',
logo: {
'@type': 'ImageObject',
url: 'https://example-newsroom.nl/logo.png',
width: 600,
height: 60,
},
},
image: artikel.heroImage?.url,
inLanguage: 'fy-NL',
isAccessibleForFree: !artikel.paywalled,
}
return <script type="application/ld+json" dangerouslySetInnerHTML={{__html: JSON.stringify(data)}} />
}
De inLanguage-tag is het kleine detail dat telt. Fries is fy-NL, niet nl. Google News leest dat en plaatst de content in het Friestalige carrousel in plaats van het Nederlandse. De oude Drupal-module had nl hardcoded. Die ene regel fixen tilde de Friese carrousel-impressies in de eerste week merkbaar omhoog.
Vervolgens registreerden we de nieuwe build twee weken voor de cutover in Google News Publisher Center, terwijl Drupal nog steeds alle uitlevering deed. Publisher Center koppelt publicaties op URL-patroon en accepteert een nieuw patroon binnen ongeveer 48 uur.
De cutover van vier weken via shadow traffic
Dit was het deel waar iedereen rustig van kon slapen. We flipten de DNS niet op vrijdagavond en hoopten er het beste van.
Week één. Sanity ging live als het canonieke CMS, maar alleen voor nieuwe artikelen. Redacteuren schreven in Sanity Studio. De nieuwe Next.js-site renderde op staging.example-newsroom.nl, met wachtwoord. Drupal serveerde nog steeds al het publieke verkeer. Artikelen die in Sanity werden gemaakt, syndiceerden via een webhook en een PHP-receiver van 40 regels terug naar Drupal, zodat de live site er niets van merkte.
Week twee. De migratie van de 13.847 bestaande artikelen liep in een getagde Sanity-dataset. We diften elk geïmporteerd artikel tegen zijn Drupal-bron: titel exact gelijk, body-woordtelling binnen 1%, image-aantal exact. 17 artikelen vielen door de diff. Alle 17 bleken oude galerie-posts met kapotte HTML waarvan de redactie nooit had geweten dat ze kapot waren. Die hebben we met de hand gefikst.
Week drie. Traffic mirroring. We zetten de legacy site achter een Cloudflare Worker die 5% van de GET-requests parallel naar de nieuwe Next.js-site stuurde, de respons weggooide en statuscodes vergeleek. Van de zo'n 240.000 gespiegelde requests die week vonden we 312 paden die op de nieuwe site een 404 gaven. 287 daarvan waren ontbrekende redirects, die we aan de map toevoegden. De andere 25 waren echte deletes waarvan de redactie bevestigde dat ze als 410 Gone mochten blijven.
Week vier. DNS-cutover om 03:00 op een dinsdag. Drupal bleef nog 14 dagen draaien, bevroren, als read-only fallback op archive.example-newsroom.nl. Na 14 dagen zonder enig verkeer naar die fallback haalden we hem offline.
De cutover duurde vier weken omdat we week één en twee gebruikten om de twee systemen parallel te laten draaien. De technische migratie van 14.200 artikelen kostte elf uur. Het vertrouwen opbouwen kostte zesentwintig dagen. Elke redactie-migratie is een vertrouwensprobleem in een technisch jasje.
Wat we niet hebben gemigreerd
Iets over de database. De oude MySQL bevatte tabellen voor negentien modules die we niet nodig hadden. De verleiding is om die oude database als onderdeel van de migratie op te ruimen. Doe het niet. Er gaat momenteel een stuk over Postgres deletes rond dat stelt dat de enige schaalbare delete DROP TABLE is, en dezelfde logica geldt voor een uitgeleefde Drupal-MySQL. We hebben de cache_*-tabellen niet gemigreerd, de watchdog-tabel niet (4,2 miljoen logregels), de queue-tabel niet, de sessions-tabel niet, en geen enkele van de dblog- of search_index-tabellen. We dropten ze bij de bron door ze volledig te negeren. Het nieuwe systeem heeft geen watchdog-tabel, omdat Next.js-logs naar een ander systeem gaan. Opgelost.
Reacties waren een apart geval. Drupal had een comment-tabel met zo'n 89.000 entries, vooral uit 2014 tot 2018, voordat de redactie de community-discussie naar een gemodereerde Mastodon-instance verplaatste. We exporteerden de reacties naar een statisch JSON-bestand, tonen het aantal en de top drie op de nieuwe artikelpagina, en wijzen een "lees alle reacties"-link naar de archief-site. Geen reactiesysteem in de nieuwe stack. De redactie was opgelucht.
Na de cutover
Twee weken na de DNS-flip was de ochtendpublicatie onder 800ms, van opslaan in Sanity Studio tot zichtbaar op de homepage. De drukker-deadline schoof van 06:55 naar 06:30 omdat niemand meer op de spinner zat te wachten. De hosting-rekening ging van €2.400 per maand naar €310. Google News-impressies in het Friese carrousel lagen ongeveer 18% hoger dan in dezelfde maand een jaar eerder (dat was de inLanguage-fix), en zoekimpressies over alle URL's bleven binnen 4% van vóór de cutover, wat de ruisvloer is.
Toen we de artikel-migratie voor deze Leeuwardense redactie bouwden, was wat ons laat in het traject beet een eigenaardigheid in de oude custom PHP-module die pull-quotes verpakte in een niet-standaard <span class="kicker"> in plaats van <blockquote>. We losten het op met één regex-pass in de htmlToBlocks pre-processor voordat we naar Sanity stuurden. Zo'n pre-processor staat of valt erbij dat iemand de werkelijke HTML leest voor het script gaat draaien. Zit je op een Drupal 7-site met een redactie en een agenda die "nu" zegt, dan is onze legacy-migratiepraktijk precies rond dit gefaseerde cutover-patroon opgebouwd.
Het kleinste wat je vanmiddag kunt doen: draai die ene CSV-export tegen je eigen node-tabel, open hem in een spreadsheet en geef elke rij een kleur. Het migratieplan schrijft zichzelf vanaf daar.
Kern
Elke redactie-migratie is een vertrouwensprobleem in een technisch jasje. De agenda van vier weken weerspiegelt hoeveel vertrouwen er op te bouwen valt.
FAQ
Waarom vier weken en niet één weekend?
De technische migratie van 14.200 artikelen kostte elf uur. De andere zesentwintig dagen waren content-review, traffic mirroring en de redactie tijd geven om de nieuwe tool te vertrouwen voor de DNS werd geflipt.
Waarom Sanity in plaats van WordPress of een andere Drupal-versie?
De redactie had gestructureerde content nodig (kicker, pull-quote, video-embed) als eersteklas velden, geen WYSIWYG-soep. De schema-first aanpak en Portable Text van Sanity sloten aan op hoe de redactie al over een artikel dacht.
Hoe pakte je 14.200 URL-aliassen aan zonder SEO te verliezen?
We exporteerden elke alias plus historische redirect naar één JSON-bestand (31.000 entries) en serveerden ze als 308 redirects vanuit Next.js middleware. Elke keten werd voor deploy platgeslagen tot één hop.
Wat is er met de oude reacties gebeurd?
Geëxporteerd naar een statisch JSON-bestand. Artikelpagina's tonen nu het aantal en de top drie van de oude reacties, met een link naar het archief. Nieuwe gesprekken vinden plaats op een gemodereerde Mastodon-instance, niet in het CMS.
Bleven de Google News-impressies stabiel tijdens de cutover?
Ja. We registreerden het nieuwe build-patroon twee weken voor de DNS-flip in Google News Publisher Center, hielden de URL-structuur stabiel en corrigeerden de inLanguage-tag van nl naar fy-NL. De Friese carrousel-impressies stegen zelfs met ongeveer 18%.