← Blog

SEO

Van Joomla naar Astro: migreren zonder verkeer te verliezen

Een Nijmeegse touroperator van 22 man had 380.000 organische sessies per maand op Joomla 3. Dit is het draaiboek waarmee we de site verhuisden zonder verkeer te bloeden.

Jacob Molkenboer· Oprichter · A Brand New Company· 22 jun 2026· 12 min
Open leren grootboek met copperplate-tekst, messing paperclip op groene fiche, rode lakzegel, ijzeren bagagelabel.

De marketinglead van een Nijmeegse reisorganisatie van 22 man liet ons op een dinsdagmiddag in maart een Search Console-export zien. 380.000 organische sessies per maand, ruwweg, vrijwel alles landend op bestemmingspagina's gebouwd in Joomla 3.10. Joomla 3 was negen maanden eerder end-of-life gegaan. Het CMS werd in de lucht gehouden door één freelancer die mailde wanneer het hem uitkwam, en de boekingsflow liep via een PHP 7.4-plugin die niemand op kantoor kon lezen.

De opdracht was simpel en niet simpel. Verhuis naar iets dat het team zelf kan beheren. Verlies geen verkeer. Het verkeer is de business.

Hier volgt het draaiboek dat we gedraaid hebben, inclusief de stukken die telden en de momenten waarop we bijna iets sloopten. De stack waar we op uitkwamen: Astro voor de front-end, Sanity voor de content, en een dunne Node-worker voor de boekingshandoff. Niet dat dat het interessante deel is. Het interessante deel is de URL-map, de 410-versus-301-beslisboom, en de structured-data parity check die we nu draaien op iedere TourPackage-node voordat die staging verlaat.

De inventarisatie van 14.200 URL's

Eerst moesten we weten wat we hadden. Een Screaming Frog-crawl met custom extractors leverde 14.243 indexeerbare URL's op. Die kruisten we met twaalf maanden GSC Performance > Pages-data via de API, en met de access logs die de host nog kon leveren (Joomla's eigen logging stond sinds 2021 uit).

De drie lijsten waren het oneens. Screaming Frog vond pagina's die GSC nooit had gezien. GSC had pagina's die op een verse crawl 404 teruggaven. De access logs hadden een lange staart van ?Itemid=-varianten die de canonical tag jarenlang stilletjes had geconsolideerd. We voegden de drie samen in één Postgres-tabel en gaven iedere rij een van zes disposities.

create table url_inventory (
  url           text primary key,
  source        text[] not null,           -- {crawl, gsc, logs}
  last_seen     date,
  clicks_12mo   int default 0,
  impressions_12mo int default 0,
  backlinks     int default 0,
  disposition   text check (disposition in (
    'keep-301', 'merge-301', 'retire-410',
    'noindex-200', 'soft-404-fix', 'review'
  ))
);

De regel die het meeste werk deed: had een URL nul clicks, nul impressies en nul verwijzende domeinen over twaalf maanden, en zat logs niet in de source set, dan kreeg hij retire-410 als startpositie. Een mens bekeek iedere review-rij, maar de lange staart van retire-410 ging zonder toezicht door. Zo'n 4.100 URL's gingen direct naar 410.

De andere regel die telde: we retireerden nooit automatisch iets met actieve backlinks. Eén verwijzend domein van een echte publisher hield een URL in review voor menselijke ogen. De backlink-data kwam uit Ahrefs Site Explorer en werd 's nachts in dezelfde tabel geïmporteerd. Twee pagina's uit 2017 die we zonder meer geschrapt zouden hebben, bleken de canonieke referenties te zijn voor een Nederlandse reisblogger met een mailinglijst van vijf cijfers. Die killen had ons meer goodwill gekost dan welke link equity-winst ook had goedgemaakt.

De 410-versus-301-beslisboom

De meeste migratiegidsen vertellen je om alles te 301'en. Dat klopt niet, en dat klopt al een tijdje niet. Google's eigen richtlijn is dat 410 het eerlijke signaal is wanneer een pagina definitief weg is, en de crawler haalt 'm dan sneller uit de index. Een afgevoerde pagina 301'en naar een vaag verwante parent verwatert die parent en leert Google je redirects als soft-404 te behandelen, waarna hij ze uiteindelijk gaat negeren.

Onze regel, in gewone taal:

  • Pagina heeft een duidelijke één-op-één-opvolger in de nieuwe IA? 301.
  • Pagina heeft een duidelijke veel-op-één-opvolger (vijf hotelpagina's die opgaan in één bestemming)? 301 naar de parent, maar alleen als die parent het onderwerp al dekt. Zo niet, dan is de 301 een soft-404 in wording.
  • Pagina is weg, het onderwerp is weg, niemand linkt ernaar, niemand zoekt erop? 410.
  • Pagina heeft backlinks maar geen equivalent op de nieuwe site? 301 naar de dichtstbijzijnde thematische parent en accepteer de verdunning — de link equity is meer waard dan de schoonheid.
  • Pagina is een dunne tag-archief of een paginated category-pagina 4 van 12 die nooit gerankt heeft? 410.

We codificeerden dat als een functie op de inventory-tabel en draaiden 'm 's nachts gedurende het cutover-window. De output was een platte redirects.json die de Astro-middleware aan de edge leest.

// astro middleware, runs on Cloudflare Workers
import redirects from '../data/redirects.json' assert { type: 'json' }

export const onRequest = async ({ request }, next) => {
  const url = new URL(request.url)
  const hit = redirects[url.pathname + url.search] ?? redirects[url.pathname]
  if (!hit) return next()
  if (hit.status === 410) {
    return new Response('Gone', { status: 410 })
  }
  return Response.redirect(new URL(hit.to, url).toString(), 301)
}
Let op

Als je een afgevoerde pagina 301't naar de homepage, behandelt Google die binnen een paar weken als soft-404 en verdampt de link equity alsnog. Je hebt een schoon signaal ingeruild voor een vies signaal. 410 is niet eng.

Informatiearchitectuur: de diff, niet de redesign

De Joomla-site was gegroeid als een bos. Zes niveaus diep op sommige plekken, met bestemmingspagina's genest onder continent, land, regio, sub-regio en thema. De nieuwe IA platte dat af tot drie niveaus: bestemming, reis, vertrek. Iedere node in de oude boom moest ergens in de nieuwe boom landen, of hij moest sterven.

We bouwden de mapping eerst in een spreadsheet — 14.243 rijen, drie kolommen: oude URL, nieuwe URL of NULL, dispositie. De content lead bij de klant was zes weken eigenaar van dat sheet. Zij wist welke Toscane-pagina de canonieke was en welke vier overgebleven SEO-experimenten waren uit 2018. Geen enkel slim script vervangt die kennis. We exporteerden het sheet iedere vrijdag naar de inventory-tabel en diften het tegen de week ervoor.

De slug-regel

Nieuwe URL's zijn voorspelbaar. /bestemming/{country}/{region}/ voor bestemmingen, /reis/{slug}/ voor reizen, /vertrek/{slug}/{yyyy-mm-dd}/ voor specifieke vertrekken. De oude Joomla-slugs waren een museum van eerdere beslissingen — component/k2/itemlist/category/47-toscane.html en consorten. We hebben er geen enkele gehouden. Voorspelbaar wint van vertrouwd zodra de redirect-map het zware werk doet.

Structured-data parity, per node

Dit is het deel dat de meeste migraties overslaan en waarvan de meeste migraties spijt krijgen. De oude site emitteerde TouristTrip- en Offer-schema via een Joomla-plugin. De rich results waren een serieus aandeel van het SERP-vastgoed — prijs, duur, beoordeling en vertrekdatum allemaal zichtbaar vóór de klik.

Als de nieuwe site een net iets ander schema emitteert, kun je wekenlang rich results kwijt zijn terwijl Google opnieuw valideert. We bouwden een parity check die in CI draait op iedere TourPackage die wil publiceren. Hij crawlt de staging-URL, trekt de JSON-LD eruit, normaliseert die, en vergelijkt hem met de JSON-LD van dezelfde node op productie. Iedere property die in productie aanwezig is en in staging ontbreekt, laat de check falen. Extra properties op staging zijn toegestaan.

// scripts/schema-parity.ts
import { fetchJsonLd, normalise } from './lib/jsonld'

const REQUIRED = ['name','description','offers','itinerary','image']

export async function parity(slug: string) {
  const [oldLd, newLd] = await Promise.all([
    fetchJsonLd(`https://www.client.nl/reis/${slug}`),
    fetchJsonLd(`https://staging.client.nl/reis/${slug}`),
  ])
  const a = normalise(oldLd, 'TouristTrip')
  const b = normalise(newLd, 'TouristTrip')
  const missing = Object.keys(a).filter(k => !(k in b))
  const requiredMissing = REQUIRED.filter(k => !(k in b))
  if (missing.length || requiredMissing.length) {
    throw new Error(`Parity failed for ${slug}: ${[...missing, ...requiredMissing].join(', ')}`)
  }
}

Hij vangt domme dingen. Een Sanity-veld dat in een refactor van price naar basePrice hernoemd is, waardoor de Offer.price-emit breekt. Een image-URL die van absoluut naar relatief gaat. Een priceCurrency die wegvalt omdat de redacteur het vinkje per ongeluk uitzette. We vingen er 47 tijdens de migratie, en we draaien de check nog steeds bij elke publicatie.

De parity check is sindsdien standaard bij iedere migratie die we doen. We hebben hem geport naar Drupal-naar-Astro, WooCommerce-naar-Shopify, en één Magento-naar-headless-rebuild. De schemas veranderen, het principe niet: rendert productie vandaag rich results, dan moet staging morgen dezelfde velden renderen. Even door de volledige Schema.org-vocabulaire bladeren is een nuttige herinnering aan hoeveel properties een complexe node kan dragen, en hoeveel stille regressies er schuilgaan in een refactor die niemand als risicovol heeft gemarkeerd.

De boekingshandoff

De dunne Node-worker was het enige stuk dynamische code op de nieuwe site. De oude PHP-plugin postte rechtstreeks naar een SOAP-endpoint bij de GDS-leverancier; wij wikkelden dat in een getypeerde Node-service en hingen die achter één enkele /api/availability-route. Zelfde payloads, zelfde upstream, twintig regels lijm. We weerstonden de neiging om de boekingsflow zelf te refactoren tijdens de migratie, want twee enge dingen tegelijk doen is hoe je er vier krijgt. De boekings-rebuild is een apart project, ingepland voor Q4.

Het cutover-window

We zetten over op een zondagavond om 22:00, het rustigste verkeerspunt van de week voor deze business. De DNS-swap was het laatste. Daarvoor:

  1. Astro-build twee weken eerder gedeployed naar zijn productiedomein op new.client.nl, met noindex op iedere pagina en basic-auth aan de edge.
  2. Google Search Console een week eerder geverifieerd voor new.client.nl, zonder submissions.
  3. De redirects.json met 14.243 regels getest tegen een steekproef van 800 URL's met een script dat de redirect chain afliep en de uiteindelijke status checkte.
  4. Een verse Screaming Frog-crawl van staging met de noindex eraf, waarbij elke canonical, title, meta description en JSON-LD-blob tegen productie werd vergeleken.
  5. Een rollback-plan dat één DNS-record verderop lag.

Bij cutover: noindex eraf, basic-auth eraf, de apex A- en AAAA-records omklappen, dertig minuten lang tail -f op de worker-logs. Nieuwe sitemap indienen bij GSC. Wachten.

De eerste 72 uur hielden we de oude Joomla-installatie warm op zijn oorspronkelijke IP, met de redirect-logica gespiegeld op de oude origin voor het geval we DNS terug moesten draaien. Dat hebben we nooit gedaan. We hielden de oude sitemap.xml ook 14 dagen bereikbaar zodat Google in zijn eigen tempo kon vergelijken en hercrawlen, wat hij ook deed: het crawlvolume op het nieuwe domein verdrievoudigde in de eerste week en zakte tegen dag 21 in op een nieuwe constante baseline. De monitoringstack was bewust saai — één Grafana-bord met drie panels (5xx-rate, redirect-hitrate, indexeerbare paginacount) en een Slack-alert die precies één keer afging, op een typefout in een robots.txt-regel waar elf minuten zaten tussen spotten en drie minuten tussen fixen.

Wat er met het verkeer gebeurde

Week één: 14% dip in organische sessies, wat exact is wat ieder migratiedraaiboek je vertelt te verwachten en exact is waar iedere oprichter over in paniek raakt. Week drie: terug op baseline. Week zes: 4% boven baseline, wat we voorzichtig toeschrijven aan de page speed-verbetering (LCP zakte van 4,2s naar 1,1s op het bestemmings-template) en niet aan iets dat we slim hebben gedaan.

De rich results kwamen sneller terug dan we vreesden, rond dag 11 voor de meeste TourPackage-nodes. De parity check was de reden. De twee nodes die hun rich results verloren, bleken allebei al maanden stilletjes kapot te zijn op de oude site — Google had alleen nog geen tijd gehad om ze te droppen.

Kern

Een migratie die verkeer behoudt, is grotendeels inventarisatiewerk en beslisboomwerk. De nieuwe stack is het makkelijke deel. Het lastige deel is eerlijk zijn over welke URL's mogen blijven leven.

Wat we anders zouden doen

Drie dingen, achteraf.

Eén: we hadden de parity check moeten bouwen vóór we ook maar één Sanity-schema schreven, niet erna. We schreven 'm in week zes van een build van acht weken, en vonden drie schema-beslissingen die we moesten terugdraaien. Was parity een randvoorwaarde geweest vanaf dag één, dan waren die beslissingen nooit gemaakt.

Twee: we begrootten de tijd van de content lead op de URL-mapping te krap. Ze stak er over zes weken zo'n 60 uur in. Dat zat niet in de oorspronkelijke offerte en hebben we voor lief genomen, maar de les is dat de menselijke kennis over welke URL's ertoe doen, de bottleneck is bij iedere migratie die we sindsdien hebben gedaan. Begroot daarop.

Drie: we kozen voor Cloudflare Workers voor de redirect-map vooral omdat we de runtime al kenden. Achteraf had een statisch _redirects-bestand op dezelfde edge het werk gedaan met minder bewegende delen en één ding minder om te monitoren. Workers verdienen hun plek wanneer de regels dynamisch zijn — de onze waren dat niet, en het JSON-bestand ging de eerste vier maanden ongewijzigd live.

Toen we de Astro-front-end en de Sanity-studio voor deze klant bouwden, onderschatten we vooral hoeveel van het werk URL-boekhouding was in plaats van daadwerkelijke website-ontwikkeling. De redirect-map was het product. De site was de wikkel eromheen.

Zit je op een Joomla 3- of Drupal 7-site en tikt de EOL-klok, dan is het kleinste nuttige dat je vandaag kunt doen: een Screaming Frog-crawl draaien, de laatste twaalf maanden GSC pages-data exporteren, en beide in hetzelfde sheet leggen. Kijk naar de rijen waar de twee elkaar tegenspreken. Dat is je migratie-backlog, nog voor je één tool hebt gekozen.

Kern

Een migratie die zijn verkeer vasthoudt is vooral inventarisatiewerk en een eerlijke 410-versus-301-beslisboom. De nieuwe stack is het makkelijke deel.

FAQ

Moet ik tijdens een CMS-migratie iedere oude URL 301'en?

Nee. 301 de URL's met een duidelijke opvolger of echte backlinks. 410 de URL's die echt weg zijn. Een blanket-301 naar de homepage wordt soft-404 en verliest alsnog de equity.

Hoe lang duurt het voor rich results terugkomen na een migratie?

Bij ons rond de 10 tot 14 dagen voor de meeste nodes, mits de JSON-LD-parity exact is. Ontbreken er properties of zijn ze hernoemd, reken dan op wekenlange degradatie in de SERP.

Kan dit zonder een staging-omgeving die het productie-schema spiegelt?

Niet veilig. De parity check tussen oude en nieuwe JSON-LD is wat stille regressies vangt. Zonder staging op een echt subdomein zit je te gokken.

Is Astro de juiste keuze voor een content-zware reissite?

Voor deze klant wel. Statische output, snelle LCP, geen plugin-ecosysteem om bij te houden. Hebben je redacteuren WYSIWYG-inline editing op de live pagina nodig, dan moet je elders zoeken.

Hoe groot mag de redirect-map worden voor de edge er last van krijgt?

Een platte JSON van 14k entries laadt in enkele milliseconden op Cloudflare Workers. We hebben maps van 80k entries gezien zonder meetbare impact, mits gekeyed als object.

seomigrationjoomlalegacy sitesarchitecturecase study

Iets bouwen?

Start een project