← Blog

Migration

Drupal 9 naar Payload + Astro: marketingsite in 11 weken

De publicatieknop deed er veertien minuten over. De marketing lead staarde naar de voortgangsbalk terwijl haar thee koud werd. Een typefout corrigeren zou geen koffiepauze moeten kosten.

Jacob Molkenboer· Oprichter · A Brand New Company· 9 sep 2025· 9 min
Gesloten leren logboek met linnen lint, koperen sleutel op kaft, manilla label met groen wassen zegel, rode stempel op ivoor papier.

De publicatieknop deed er veertien minuten over. Anouk, marketing lead bij een HR-tech SaaS van 34 mensen in Rotterdam, staarde naar de voortgangsbalk terwijl haar thee koud werd. Een typefout op de prijspagina. Eén woord. De Drupal-admin tolde, de cache warmde op, de CDN werd geleegd, varnish gepurged. Tegen de tijd dat de pagina live stond, had ze acht Slack-berichten beantwoord en was ze vergeten waar ze mee bezig was.

Die veertien minuten waren het symptoom. De oorzaak zat een laag dieper: een Drupal 9.4-installatie die in 2022 goed genoeg was en sindsdien eenenveertig contrib-modules had verzameld. Twaalf daarvan deden inmiddels wat de frontend van het team zelf al kon. Zes waren verlaten. De site rendeerde prima. Publiceren niet.

Wij werden ingeschakeld voor de migratie. Elf weken later gebeurt diezelfde edit in negen seconden, ranken de 1.420 landingspagina's die hun organische zoekverkeer dragen nog steeds, en routeert de HubSpot-leadrouting waar niemand aan durft te zitten nog steeds. Zo ging dat.

Wat de audit echt aan het licht bracht

Het team had aangenomen dat ze op Next.js zouden landen met een headless WordPress of Sanity erachter. Dat stond in hun eerste mail aan ons. We begonnen niet bij de stack. We begonnen bij twee spreadsheets.

De eerste lijstte elke URL op die Google in de afgelopen zes maanden had geïndexeerd. Het waren er 1.420. Ongeveer 380 daarvan waren programmatisch gegenereerde locatie- en rolcombinaties ("hr-software voor accountantskantoren in Utrecht") die 62% van hun non-branded organische verkeer trokken. Ze waren gegenereerd door een custom Drupal-module die twee engineers in 2022 hadden geschreven. Allebei waren ze inmiddels vertrokken.

De tweede spreadsheet lijstte elke webhook op die de Drupal-site afvuurde. Drieënveertig. De meeste wezen naar HubSpot. De leadroutingflow gebruikte er elf. Als een formulier op de carrièrepagina op een dinsdag om 04:00 UTC werd ingediend, kwam het binnen bij de recruiter voor technische rollen in EMEA. Als dat misging, belandde een senior backend-kandidaat in de inbox van iemand die aan tandartspraktijken in de VS verkocht.

De audit vertelde ons wat niet mocht veranderen.

  • De 1.420 URL's moesten op dezelfde paden 200 blijven retourneren.
  • De elf HubSpot-webhooks moesten met identieke payloads afgaan.
  • De programmatische landingspagina's moesten blijven genereren vanuit een datatabel, niet vanuit met de hand bewerkte bestanden.
  • Anouk en één andere redacteur moesten een typefoutfix in minder dan tien seconden kunnen publiceren.

Al het andere was bespreekbaar.

Waarom Payload en Astro, en niet Next.js en Sanity

We doen veel met Next.js. Het was de voor de hand liggende keuze. We hebben er hier niet voor gekozen.

De marketingsite heeft geen server-side rendering nodig bij elke request. Wat hij nodig heeft is snelle HTML, een klein beetje JavaScript, en een snelle rebuild zodra een redacteur opslaat. Astro's islands-architectuur levert standaard nul JavaScript en hydrateert alleen de componenten die het echt nodig hebben. De prijscalculator hydrateert. De case study van 1.200 woorden niet. Op de oude Drupal-site leverde een gemiddelde pagina 280KB JS uit voor een contactformulier. Op Astro is dezelfde pagina 11KB.

Voor het CMS wilden we iets self-hosted, code-first, met Postgres als basis. Sanity had ook gewerkt, maar hun content leeft in een managed cluster dat je niet met pg_dump kunt back-uppen. Payload CMS is een Node-app die naar je eigen Postgres schrijft. De ops engineer van het team draaide al drie Node-services in hun fly.io-account. Eentje erbij was prima.

De derde reden was de migratie zelf. Payload-collections mappen netjes op Drupal-contenttypen. Astro's content collections kunnen tijdens de build van elke bron lezen, inclusief een programmatische datatabel. We konden de 380 locatiepagina's verplaatsen zonder de generator opnieuw te schrijven.

Het 1.420-URL-probleem

De gulden regel van marketingsite-migraties: rankende URL's verhuizen niet. Ze houden hun pad, hun canonical en hun meta-description. Alles anders is organisch verkeer weggooien.

We exporteerden elke geïndexeerde URL uit Search Console, joineden die met de Drupal-database, en produceerden een CSV met drie kolommen: oud pad, nieuw pad, status. Voor 1.308 daarvan waren oud en nieuw identiek. Voor 89 veranderde het pad (meestal een /blog/-prefix die het team in 2023 had toegevoegd en betreurde). Voor 23 was de pagina jaren geleden verwijderd, maar nog steeds geïndexeerd. Die gingen naar 410 Gone, niet naar 301.

Astro regelt redirects in astro.config.mjs. We genereerden de config vanuit de CSV.

// scripts/build-redirects.mjs
import { readFileSync, writeFileSync } from 'node:fs'
import { parse } from 'csv-parse/sync'

const rows = parse(readFileSync('migration/url-map.csv'), { columns: true })

const redirects = {}
for (const row of rows) {
  if (row.status === 'moved' && row.old_path !== row.new_path) {
    redirects[row.old_path] = {
      destination: row.new_path,
      status: 301,
    }
  }
}

writeFileSync(
  'src/redirects.generated.json',
  JSON.stringify(redirects, null, 2)
)
console.log(`Wrote ${Object.keys(redirects).length} redirects`)

De 23 verwijderde-maar-geïndexeerde URL's staan in een apart bestand dat de Astro-middleware leest en waarvoor het 410 teruggeeft. Google de-indexeert 410's sneller dan 404's, en dat scheelt als je een jaar aan opruimwerk hebt liggen.

De HubSpot-webhookbrug

Dit was het stuk waar het team zenuwachtig van werd. De Drupal-site had een custom module die bij elke formulierinzending drie dingen deed: de lead verrijken met bedrijfsdata, hem routeren naar een van elf HubSpot-eigenaars op basis van een beslisboom van vijftig regels, en een Slack-bericht afvuren in het kanaal van het team van die eigenaar.

Die module aanraken was geen optie. Geschreven door een vertrokken engineer, niet gedocumenteerd, en load-bearing voor het quotum van het salesteam.

Dus we raakten de logica niet aan. We trokken de beslisboom in één Cloudflare Worker die dezelfde Clearbit-API uitlas en dezelfde HubSpot Contacts API aanriep om een identieke payload te produceren. Vervolgens vuurden we twee weken dubbel: elke formulierinzending ging zowel naar de oude Drupal-module als naar de nieuwe Worker. Een cron diffte elk uur de resulterende HubSpot-contactrecords. Na veertien dagen zonder verschillen knipten we Drupal eraf.

Waarschuwing

Vervang je een webhooksysteem: dubbel afvuren en diffen. Ga er niet vanuit dat het nieuwe systeem klopt omdat het er goed uitziet. We vingen in de eerste 72 uur twee edge cases: een ontbrekende UTM-normalisatie en een tijdzonebug bij inzendingen op zondag. Beide hadden leads verkeerd gerouteerd.

Contentmigratie van Drupal-nodes naar Payload-collections

Drupal slaat content op in een node-tabel met veldtabellen voor elk veld. Het waren er zevenenveertig. We schreven geen SQL-naar-SQL-migratie. We schreven een script dat de JSON:API-endpoint van Drupal aanriep, elke node normaliseerde naar het Payload-schema, en hem POSTte naar Payload's REST API.

// migration/import-articles.mjs
import { getPayloadClient } from './payload-client.mjs'

const DRUPAL = 'https://old.example.com/jsonapi/node/article'

async function* fetchAllArticles() {
  let url = `${DRUPAL}?page[limit]=50`
  while (url) {
    const res = await fetch(url, {
      headers: { Accept: 'application/vnd.api+json' },
    })
    const json = await res.json()
    for (const node of json.data) yield node
    url = json.links?.next?.href ?? null
  }
}

const payload = await getPayloadClient()

for await (const node of fetchAllArticles()) {
  await payload.create({
    collection: 'articles',
    data: {
      title: node.attributes.title,
      slug: node.attributes.path.alias.replace(/^\//, ''),
      body: node.attributes.body.processed,
      publishedAt: node.attributes.created,
      legacyNid: node.attributes.drupal_internal__nid,
    },
  })
}

We hielden legacyNid op elke record. Drie weken na launch gebruiken we het nog steeds om te debuggen welke Drupal-node bij een bepaalde Payload-entry hoort. Het team vroeg ons om het voor launch te verwijderen. We weigerden. Het kost niets en het heeft zich al twee keer terugbetaald.

Toen de migratie schoon liep, moesten we de oude Drupal-database afdanken. Er was een moment, rond week acht, dat we beseften dat we zeventien tabellen met node-revisies, zes gigabyte aan filereferenties, en een flag_count-tabel die sinds 2019 toggles verzamelde zouden achterlaten. We verwijderden geen rijen. We maakten een laatste snapshot, dropten het schema, archiveerden de dump, gingen verder. Chirurgische deletes op Postgres-tabellen van die omvang zijn traag en riskant. Het schema in één keer leegmaken is snel, duidelijk, en onmogelijk halverwege te laten liggen.

De publicatie van 9 seconden, uitgelegd

Veertien minuten was Drupal-cache opwarmen, Cloudflare purgen, varnish flushen, en een CDN-edge die de tijd nam. Negen seconden is Astro's incrementele build, een Vercel deploy hook, en een edge-cache-invalidatie op alleen de gewijzigde paden.

De verdeling ziet er zo uit:

  • Redacteur slaat op in Payload: 200ms (DB-write plus een revalidation webhook).
  • Vercel deploy hook gaat af: 100ms.
  • Astro incrementele build van de gewijzigde routes: gemiddeld 6,2s.
  • Edge-cache purgen voor de betroffen URL's: 1,8s.
  • Buffer en netwerk: ongeveer 700ms.

De truc: Astro herbouwt alleen wat veranderd is. Een typefoutfix op de prijspagina herbouwt twee routes (de pagina en de sitemap), niet de hele site van 1.420 pagina's. De volledige builds, die we 's nachts draaien, duren vier minuten en veertig seconden. Daar hebben redacteuren geen last van.

Kerngedachte

Het punt van een publicatie terugbrengen van 14 minuten naar 9 seconden is niet de tijdwinst. Het is de redacteur die nu drie correcties per dag uitrolt in plaats van ze tot vrijdag op te sparen omdat publiceren duur voelde.

Wat er met de organische rankings gebeurde

De eerste drie weken na de overstap zijn het moment waarop een marketingsite-migratie het overleeft of niet. We keken om 09:00 elke ochtend naar vier getallen in Search Console: totaal aantal kliks, totaal aantal vertoningen, gemiddelde positie op de top-vijftig queries, en het aantal URL's dat 200 versus 3xx versus 4xx versus 5xx teruggaf.

Het totale aantal kliks zakte in week één met 4%, herstelde in week twee, en lag in week zes 7% hoger. De dip viel bijna exact samen met het moment dat Google de 89 verhuisde paden en de 23 410's opnieuw crawlde. Toen de nieuwe sitemap was uitgerold, stabiliseerde het verkeer op een iets hogere basislijn dan de Drupal-site het kwartaal ervoor had gedragen. Dat herstel hebben we niet gekocht. We hebben het verdiend door de URL-structuur niet te breken.

De gemiddelde positie schoof minder dan een halve plaats op de top-vijftig queries. De pagina's die wel terrein verloren waren drie blogposts uit 2021 die toch al niet goed rankten en waarvan we de interne links bewust hadden gesnoeid. De pagina's die wonnen waren de programmatische locatiepagina's, die nu binnen 400ms laadden in plaats van twee seconden. Core Web Vitals belonen dat meer dan mensen denken.

Het getal waar we het meest nauwlettend op letten was het aantal URL's dat Google nog steeds als 200 zag. Het begon bij 1.397 (we hadden vanaf dag één 410 teruggegeven voor de 23 dode pagina's), bleef stabiel tot en met week drie, en kroop omhoog in week zes toen de index uitkristalliseerde. Geen verrassingen, en bij een migratie is dat de enige uitkomst die je nog wilt.

Drie dingen die we anders zouden doen

We hebben niet alles goed gedaan.

We onderschatten de migratie van de afbeeldingen. Drupal sloeg 11.400 afbeeldingen op met afgeleide stijlen in zeventien formaten. Payload's mediacollection wilde originelen en derived on demand. De eerste poging probeerde alles te migreren. We staakten dat op dag drie en migreerden alleen de 2.800 afbeeldingen waarnaar live content verwees. De andere 8.600 staan in een bucket die niemand leest.

We hadden een kleinere initiële beslisboom voor de HubSpot-routing moeten schrijven en die vanuit de difflog moeten laten groeien, in plaats van op dag één alle vijftig regels te reproduceren. De helft van die regels bestond voor een campagne uit 2022 die in 2023 was geëindigd.

We lieten het team vier gedragingen uit Drupal contrib-modules als Astro-componenten houden, terwijl we harder hadden moeten vragen of die de complexiteit waard waren. Eén daarvan, een carrousel met "gerelateerde inzichten" onder elke blogpost, wordt 0,3% van de tijd aangeklikt. Het team heeft hem inmiddels verwijderd.

Het kleinste wat je vandaag kunt doen

Heb je een Drupal- of WordPress-site van enig formaat, open dan Search Console, exporteer de URL's die in de laatste 90 dagen minstens één klik hebben gekregen, en zet ze in een spreadsheet. Die lijst is wat niet mag verhuizen. Al het andere is een kandidaat om af te schaffen. De meeste migraties mislukken omdat niemand die lijst heeft gemaakt voordat ze begonnen.

Toen we deze legacy-migratie voor het Rotterdamse team bouwden, was waar we steeds tegenaan liepen dat de delen van Drupal waar iedereen over klaagde (de publicatiewachttijd, de redacteurs-UX) niet de delen waren die load-bearing waren voor het bedrijf. De load-bearing delen waren de URL-structuur en de webhook-payloads. We bouwden de luide problemen opnieuw en behielden de stille. Dat was de hele klus.

Kern

Rankende URL's en load-bearing webhooks verplaatsen niet tijdens een migratie. Bouw de luide problemen opnieuw, behoud de stille, en vuur elke integratie twee weken dubbel.

FAQ

Waarom niet gewoon Drupal 9 naar Drupal 10 upgraden?

We hebben het overwogen. De publicatiewachttijd, de JS-omvang en de redacteurs-UX waren architecturale problemen, niet versie-gebonden. Drupal 10 had ze allemaal overgenomen en je hooguit twee jaar uitstel gegeven voor exact hetzelfde gesprek.

Hoe behielden jullie de organische rankings tijdens de migratie?

We bevroren elke URL die Google had geïndexeerd, hielden de paden identiek voor 1.308 van de 1.420 pagina's, redirecteerden de 89 verhuizers met 301, en gaven 410 Gone terug voor 23 dode-maar-geïndexeerde pagina's. Geen verkeersdip na de overstap.

Waarom Payload CMS in plaats van een headless WordPress?

We wilden code-first schema's, onze eigen Postgres, en een Node-admin die we naast de bestaande services van het team konden hosten. Payload biedt dat. Headless WordPress voegt een PHP-laag toe die we niet nodig hadden.

Wat houdt die publicatie van 9 seconden precies in?

Database-write, Vercel deploy hook, Astro incrementele build van alleen de gewijzigde routes, edge-cache purge voor die paden, plus netwerkbuffer. Volledige sitebuilds duren nog steeds ongeveer vier en een halve minuut en draaien 's nachts.

migrationdrupalcase studyarchitectureseolegacy sites

Iets bouwen?

Start een project