← Blog

Migration

Drupal 7 naar Astro: een migratieplaybook voor pers-URLs

Een Volkskrant-redacteur klikt een URL uit haar archief van 2019 in een nieuw artikel. Drie uur later geeft hij een 404. Zo migreer je Drupal 7 zonder de pers te verliezen.

Jacob Molkenboer· Oprichter · A Brand New Company· 6 jul 2024· 9 min
Open leren logboek op ivoorpapier, koperen sleutel op kaart met groen lint, ijzeren label, fragment van waszegel.

Een redacteur bij de Volkskrant krijgt op een dinsdagochtend een ping uit haar CMS. De auto-link tool wil de canonieke URL voor een verhaal van zeven jaar geleden over een minister die een energieakkoord tekent. Het ding pakt /nieuws/2019/03/14/minister-tekent-energieakkoord uit het archief, plakt 'm in het nieuwe artikel en publiceert. Drie uur later geeft die link een 404. De Drupal 7 site van het ministerie ging in het weekend uit de lucht, en de nieuwe Astro build is een van de vier URL-vormen vergeten waar de Nederlandse pers sinds 2017 op staat te diep-linken.

Dat is de faalmodus waar we de meeste tijd in steken bij overheidsmigraties. De pers leest je release notes niet. Hun CMS-templates hebben jouw URL-structuur ingebakken, en zodra de auto-link tool van een journalist een 404 oplevert, mailen ze je niet. Ze stoppen gewoon met linken.

Dit is het playbook dat we gebruiken wanneer een 9 jaar oude Drupal 7 site moet landen op Astro plus een lichte headless CMS, zonder dat contract te breken.

De vier URL-vormen die moeten overleven

Op een typische ministerie- of agentschap-site in Drupal 7 dragen vier URL-patronen vrijwel alle inkomende waarde van de pers:

  1. /nieuws/YYYY/MM/DD/slug. Nieuwsberichten, diep gelinkt vanuit de archieven van NRC, Volkskrant en Trouw.
  2. /dossier/slug. Langlopende thema-dossiers, vaak gelinkt vanuit Wikipedia en vanuit het portaal van de Tweede Kamer.
  3. /persbericht/YYYY/NN. Genummerde persberichten, opgepikt door ANP en Bloomberg-syndicatie.
  4. /node/[nid]. Het ruwe Drupal node-pad. Oude links naar PDF-bijlagen en vergeten subpagina's wijzen hier nog steeds heen.

De vierde is de valkuil. De meeste teams behandelen /node/[nid] als legacy ruis en gooien 'm weg. Vervolgens blijkt dat een officieel transcript naar /node/4421 verwijst omdat een griffier in 2018 de URL-balk plakte.

Je inventariseert alle vier vóór je één regel Astro schrijft. De eerste SQL-pass ziet er zo uit:

SELECT
  n.nid,
  n.type,
  n.title,
  n.created,
  ua.alias,
  CONCAT('/node/', n.nid) AS canonical_node
FROM node n
LEFT JOIN url_alias ua ON ua.source = CONCAT('node/', n.nid)
WHERE n.status = 1
ORDER BY n.created DESC;

Draai 'm, dump naar CSV, sorteer op URL-vorm. Je vindt aliases die bij geen van de vier gedocumenteerde patronen passen. Dat zijn de items die iemand in 2014 met de hand heeft aangepast en is vergeten. Die tellen ook mee.

Waarom Astro plus een lichte CMS, en niet andersom

De reflex bij een overheidsmigratie is om eerst een groot headless CMS te kiezen en de frontend daar achteraan te laten lopen. Wij doen het omgekeerd. Astro bepaalt welke content-vormen überhaupt renderbaar zijn, daarna kiezen we de kleinste CMS die past.

Astro heeft op zo'n project bestaansrecht omdat 90% van de pagina's van een ministerie statische nieuwsberichten, dossiers en formulieren zijn. Die veranderen wekelijks, niet per request. Met de hybride output van Astro prerenderen we elk nieuwsbericht bij build en houden we een handvol dynamische routes (zoekfunctie, contactformulier, af en toe een ingelogde redacteurspreview) op een kleine Node-server.

Buildtijd doet ertoe. Een Drupal 7 site met 8.000 nieuwsberichten en 400 dossiers bouwt in Astro in ongeveer vier minuten op een 4-vCPU runner. Dat betekent dat een redacteur kan publiceren, binnen vijf minuten staging ziet en doorrolt. Geen PHP-FPM, geen Varnish-laag om te invalideren, geen module-update die de site een middag uit de lucht haalt.

De lichte CMS staat achter Astro en stelt een typed content-API beschikbaar. Wij defaulten op Directus of Payload, afhankelijk van het team. Directus als het redactieteam een Drupal-achtige admin wil met collecties en rollen die ze herkennen. Payload als er een eigen developer is die in het schema gaat wonen.

Het punt van 'licht' is dat we de CMS niets laten renderen. Geen twig, geen liquid, geen theme-laag. Hij slaat content op, spreekt JSON en blijft uit de weg.

De Drupal 7 database crawlen voor je code aanraakt

Voordat we ook maar iets provisioneren, mirroren we productie naar een afgeschermde sandbox en crawlen we 'm. Drie queries doen het meeste werk:

-- Elke gepubliceerde node met body en aliased URL
SELECT
  n.nid, n.type, n.title, n.created, n.changed,
  b.body_value AS body,
  ua.alias
FROM node n
LEFT JOIN field_data_body b ON b.entity_id = n.nid
LEFT JOIN url_alias ua ON ua.source = CONCAT('node/', n.nid)
WHERE n.status = 1;

-- Elke bestandbijlage die vanuit een node wordt gerefereerd
SELECT
  fm.fid, fm.filename, fm.uri, fm.filemime, fu.id AS nid
FROM file_managed fm
JOIN file_usage fu ON fu.fid = fm.fid
WHERE fu.type = 'node';

-- Elke redirect die al in de Redirect module staat
SELECT source, redirect, status_code
FROM redirect;

De derde is wat de meeste teams missen. Een 9 jaar oude Drupal site heeft honderden interne redirects die het redactieteam in de loop der jaren heeft toegevoegd om typo's te repareren, om na reorganisaties opnieuw te routeren, om oude microsite-URLs aan te haken. Die redirects zijn onderdeel van het contract met de pers, ook al heeft niemand ze gedocumenteerd. Ze moeten mee over.

We laten de drie resultaatsets door een klein Node-script lopen dat drie artefacten oplevert: een JSON content-boom per node-type, een manifest van bestanden die naar object storage moeten, en een platte redirect-map met de originele URL als sleutel.

Content modelleren tegen historische URLs

Nu modelleren we de CMS. De truc is dat het schema gevormd wordt door wat de URLs al zijn, niet door wat we wensen dat ze waren.

Als /nieuws/YYYY/MM/DD/slug de canonieke is, dan heeft een nieuwsbericht een published_at datetime die het pad bepaalt, plus de slug. We geven de redacteur niet de vrijheid om een willekeurige URL op een nieuwsbericht te kiezen. Die vrijheid is wat het persarchief brak bij de vorige redesign in 2019, toen een stagiair de nieuwssectie naar /actueel verplaatste.

In Directus ziet dat er zo uit:

// directus/extensions/hooks/news-slug/index.ts
import { defineHook } from '@directus/extensions-sdk';

export default defineHook(({ filter }) => {
  filter('news_item.items.create', async (payload) => {
    const date = new Date(payload.published_at);
    const y = date.getFullYear();
    const m = String(date.getMonth() + 1).padStart(2, '0');
    const d = String(date.getDate()).padStart(2, '0');
    payload.path = `/nieuws/${y}/${m}/${d}/${payload.slug}`;
    return payload;
  });
});

Het pad wordt berekend en opgeslagen. De redacteur ziet 'm wel, maar kan 'm niet bewerken. Astro leest 'm letterlijk in en rendert de pagina op exact die route.

Voor /node/[nid] houden we een kolom legacy_nid op elke geïmporteerde collectie. Er is geen editor-surface voor. Hij bestaat zodat de redirect-map kan resolven.

De redirect-map als bron van waarheid

Elke URL die de Drupal site ooit serveerde, gaat in één bestand. We noemen het redirects.json en het leeft in de Astro-repo, onder versiebeheer, gereviewd in PR.

[
  { "from": "/node/4421", "to": "/nieuws/2018/06/12/tk-debat-energie", "status": 301 },
  { "from": "/oud-dossier/klimaat", "to": "/dossier/klimaat", "status": 301 },
  { "from": "/persbericht/2017/89", "to": "/persberichten/2017/89-akkoord-getekend", "status": 301 }
]

In Astro draaien we 'm door een middleware die afgaat voordat een route matcht:

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import redirects from '../data/redirects.json';

const map = new Map(redirects.map(r => [r.from, r]));

export const onRequest = defineMiddleware(async (ctx, next) => {
  const hit = map.get(ctx.url.pathname);
  if (hit) {
    return ctx.redirect(hit.to, hit.status);
  }
  return next();
});

Aan de hostingkant (Vercel, Netlify, of zelf-gehost met Caddy) spiegelen we hetzelfde bestand in platform-level redirects, zodat ze aan de edge afgaan zonder de Astro-runtime aan te roepen. De middleware is een vangnet voor paden die de build is vergeten.

Let op

Als je de redirect-map één keer uit de database genereert en daarna vergeet, mis je de 200 tot 400 aliases die redacteuren in de laatste zes weken vóór cutover hebben toegevoegd. Draai de crawl opnieuw op de dag van go-live, regenereer, redeploy. We hebben ooit een persarchief verloren door deze stap over te slaan.

Het persarchief verifiëren vóór cutover

We vertrouwen onze eigen redirect-map niet. Drie weken voor cutover stellen we een lijst samen van bekende inkomende URLs uit het Nederlandse persarchief. NRC, Volkskrant, Trouw, ANP, RTL, NOS. We trekken de afgelopen vijf jaar aan links naar het ministeriedomein uit publieke zoek-APIs en uit de serverlogs van de klant zelf.

Die lijst, doorgaans 4.000 tot 9.000 URLs, wordt de testsuite.

#!/usr/bin/env bash
# verify-press-links.sh
while IFS= read -r url; do
  code=$(curl -s -o /dev/null -w "%{http_code}" -L "https://staging.example.nl${url}")
  final=$(curl -s -o /dev/null -w "%{url_effective}" -L "https://staging.example.nl${url}")
  printf "%s\t%s\t%s\n" "$code" "$url" "$final"
done < press-links.txt | tee press-link-report.tsv

We greppen op alles dat na redirect geen 200 is, en op elke 301-keten langer dan één hop. Lange redirect-ketens slopen Core Web Vitals en verwarren de Google News indexer waar ministeries om geven. De regel die we hanteren: één redirect, dan een 200. Geen uitzonderingen.

Dit is ook een waarden-vraag. Tim Berners-Lee legde de regel vast in 1998 in Cool URIs don't change, en een kwart eeuw later is het nog steeds het schoonste argument om de redirect-map als publieke verplichting te behandelen, niet als developer-karwei.

Cutover zonder het persarchief te breken

Cutover gebeurt op een vrijdagavond, tussen 22:00 en 02:00 CET. De volgorde:

  1. Bevries de redactie in Drupal om 21:00. Redacteuren krijgen een mail.
  2. Laatste crawl van url_alias, redirect en node tabellen. Regenereer redirects.json. Push naar main.
  3. Trigger de productiebuild van Astro. Kijk de build aan.
  4. Swap DNS op CDN-niveau. TTL staat al 48 uur op 300 seconden.
  5. Draai de pers-link verificatie opnieuw tegen productie. 4.000 tot 9.000 curls. Duurt ongeveer 12 minuten.
  6. Geeft een URL geen 200, rol DNS terug. We hebben twee keer teruggerold. Beide keren was het één dossier waarvan de alias een trailing-slash mismatch had.

De oorspronkelijke Drupal blijft twee weken warm en read-only. We houden een reverse proxy-regel die old-cms.example.nl naar de oude bak routeert, alleen toegankelijk vanuit het IP-bereik van het redactieteam. Dat geeft de afdeling communicatie een fallback waarvan ze een screenshot kunnen maken als iets er vreemd uitziet.

Wat we de ochtend erna maten

Het dashboard van de ochtend erna heeft drie cijfers.

Eén: de 301-hitrate op de redirect-map. Edge-logs gefilterd op status 301, gegroepeerd per bron-URL. Krijgt een pad in redirects.json na een week nul hits, dan is er waarschijnlijk nooit naar gelinkt. Krijgt een pad dat niet in redirects.json staat hits en geeft het een 404, dan is dat een miss die we moeten toevoegen.

Twee: time-to-first-byte voor de top 50 nieuwsberichten gemeten naar historisch verkeer. Statische output van Astro hoort onder de 80ms te zitten vanaf een Nederlandse CDN-edge. Drupal 7 met Varnish deed op een goede dag 180ms. Op een slechte dag 1,2 seconden.

Drie: Google Search Console dekking. De geïndexeerde pagina's moeten op dag 14 binnen 5% van het pre-migratie aantal liggen. Loopt het uiteen, dan kloppen de canonical tags of de sitemap niet.

We vieren niets vóór dag 30. Dat is wanneer het persarchief lang genoeg gecycled heeft om een gemist URL-patroon luid te laten worden. Is dag 30 stil, dan hield de migratie stand.

Het kleinste dat je vandaag kunt doen

Open de Redirect module-tabel van je huidige site (of de Apache rewrite config, of de nginx conf), exporteer elke regel naar een CSV en grep je laatste 90 dagen aan access logs ertegen. Alles dat in de logs zit maar niet in de CSV, is een URL die je serveert zonder het te weten. Die lijst, hoe kort ook, is de eerste pagina van je migratie-playbook.

Toen we afgelopen winter een landelijk agentschap van Drupal 7 naar Astro en Directus tilden, was wat we onderschatten hoeveel van de redirect-map in de hoofden van redacteuren leefde in plaats van in de database. We hebben uiteindelijk twee weken aan redacteursinterviews gedraaid voordat de crawl logisch werd. Onze praktijk rond legacy-migratie is uit die fout gegroeid.

Kern

Je persarchief leeft in URL-patronen die de redacteuren zich niet meer herinneren te hebben ingesteld. Behandel de redirect-map als publiek contract, niet als bijzaak.

FAQ

Waarom Astro en niet Next.js voor een overheidsmigratie?

De island-architectuur van Astro prerendert de 90% nieuws- en dossierpagina's die wekelijks veranderen, niet per request. Dat past bij overheids-contentpatronen en houdt build en TTFB goedkoop.

Welke CMS koppel je aan Astro bij deze migraties?

Directus als redacteuren een Drupal-achtige admin willen met collecties en rollen, Payload als er een interne developer is die in het schema gaat wonen. Beide leveren alleen getypte JSON, geen theme-laag.

Hoe lang duurt een migratie van Drupal 7 naar Astro?

Voor een site met 5.000 tot 10.000 nodes en een pers-URL contract: zes tot tien weken. Twee daarvan zijn redacteursinterviews en database-crawls. De rest is modelleren, bouwen en verifiëren.

Wat breekt het vaakst bij cutover?

Trailing-slash mismatches op aliased URLs en de redirects die redacteuren in de laatste zes weken voor de freeze hebben toegevoegd. Crawl de redirect-tabel opnieuw op de dag van go-live en redeploy vóór de DNS-swap.

drupalmigrationlegacy sitesphparchitecturecase study

Iets bouwen?

Start een project