Joomla
Joomla 2.5 naar Astro + Sanity: het 2.800-URL playbook
Een 14 jaar oude Joomla 2.5 site met K2, 2.800 geïndexeerde URLs en een SEO-contractor die geen enkele URL wil hernummeren. Zo verhuisden we de boel naar Astro en Sanity zonder rankverlies.

Het bureau zit boven een boekhandel aan de Bruul in Mechelen. Negentien architecten, veertien jaar gebouwd werk en een portfoliosite op Joomla 2.5 met K2. Laatste secure update ergens eind 2014. De eigenaren belden ons in maart omdat hun host PHP 8.2 als ondergrens had aangekondigd voor Q3, en de oude stack die bump niet zou overleven. De site had 2.800 URLs geïndexeerd in Google. Een SEO-contractor op retainer was helder over één punt: niets hernummeren, geen padwijzigingen, geen rankverlies.
Dit is het playbook waarmee we ze naar Astro en Sanity hebben verhuisd. Het werkte. De crawl in oktober na de cutover liet zien dat 99,4% van de oude URLs een nette 200 teruggaf op hun nieuwe plek, de rest een 301 naar de juiste canonical. Geen daling in Search Console-impressies in de vier weken die we monitorden.
De randvoorwaarde die alles bepaalt
Voordat je over architectuur begint, dicteert de SEO-randvoorwaarde het schema. K2 genereert URLs als /projects/47-housing-collective-leuven.html waarbij 47 de id van de rij in jos_k2_items is. De contractor wilde die numerieke IDs letterlijk behouden. Praktische implicatie: elk project in Sanity draagt het originele K2-id als alleen-lezen veld, en de Astro-route is /projects/[id]-[alias].astro. Het nieuwe systeem spiegelt de oude URL-grammatica exact. De redirect-laag handelt de rest af.
Kun je de IDs niet behouden, dan kun je nog steeds redirecten. Maar elke behouden ID is een redirect die niet hoeft te vuren, en Google beloont het schonere pad.
Audit voordat je één regel code schrijft
Een migratie die in de editor begint, is al ontspoord. Onze eerste week ging op aan crawlen, exporteren en lezen. Drie bronnen van waarheid die het eens moeten zijn voordat er ook maar een schema wordt opgesteld: de eigen sitemap van de site, een externe full-depth crawl, en het index coverage-rapport van Search Console.
# Haal de live sitemap op en normaliseer naar een platte URL-lijst
curl -s "https://example.be/index.php?option=com_xmap&view=xml" \
| xmllint --xpath '//*[local-name()="loc"]/text()' - \
| tr ' ' '\n' \
| sort -u > sitemap.txt
wc -l sitemap.txt # 2.847 (komt ongeveer overeen met het cijfer van de contractor)
We draaiden ook Screaming Frog op volle diepte tegen de live site, exporteerden een CSV van elke interne link met anchor en HTTP-status, en haalden de Search Console-clicks per URL van de laatste twaalf maanden binnen. De crawl bracht 41 orphan pages aan het licht die nog steeds rankten, waar Joomla stilletjes naartoe was opgehouden te linken maar Google ze niet was vergeten. Die alleen al rechtvaardigden de auditkosten.
K2 mappen naar een Sanity-schema
K2 heeft drie dingen die je moet overzetten: categorieën (met parent-child), items (titel, alias, introtext, fulltext, image, gallery, extra fields) en tags. Het schema spiegelt dit in Sanity met het minimum dat de front-end echt nodig heeft.
// schemas/project.ts
import {defineType, defineField} from 'sanity'
export default defineType({
name: 'project',
type: 'document',
title: 'Project',
fields: [
defineField({name: 'legacyId', type: 'number', title: 'K2 ID', readOnly: true}),
defineField({name: 'legacyAlias', type: 'string', title: 'K2 alias', readOnly: true}),
defineField({name: 'title', type: 'string', validation: r => r.required()}),
defineField({name: 'slug', type: 'slug', options: {source: 'title', maxLength: 80}}),
defineField({name: 'category', type: 'reference', to: [{type: 'category'}]}),
defineField({name: 'intro', type: 'array', of: [{type: 'block'}]}),
defineField({name: 'body', type: 'array', of: [{type: 'block'}, {type: 'image'}]}),
defineField({name: 'hero', type: 'image', options: {hotspot: true}}),
defineField({name: 'gallery', type: 'array', of: [{type: 'image'}]}),
defineField({name: 'completedYear', type: 'number'}),
defineField({name: 'client', type: 'string'}),
defineField({name: 'surfaceSqm', type: 'number'}),
defineField({name: 'publishedAt', type: 'datetime'}),
],
})
legacyId is dragend. Elk K2-item wordt geïmporteerd met zijn originele numerieke id zodat de route-generator het deterministisch kan targeten. legacyAlias behoudt de slug die Google heeft geïndexeerd, los van de schonere slug die editors later kunnen aanpassen zonder links te breken.
K2 schoon exporteren
K2 bewaart de echte content in jos_k2_items. Categorieën staan in jos_k2_categories. Tags joinen via jos_k2_tags_xref. Draai de query tegen een verse dump, niet tegen de live database.
SELECT
i.id AS legacy_id,
i.alias AS legacy_alias,
i.title,
i.introtext,
i.fulltext_,
i.image,
i.gallery,
i.extra_fields,
i.created AS published_at,
c.name AS category_name,
c.alias AS category_alias
FROM jos_k2_items i
JOIN jos_k2_categories c ON i.catid = c.id
WHERE i.published = 1
AND i.trash = 0
ORDER BY i.id;
Een klein Node-script leest de rijen, loopt door /media/k2/items/cache/ om per item de grootste niet-thumbnail variant te pakken, en duwt alles via de officiële client Sanity in. Draai het tegen een kopie van de database met de asset-directory via rsync naar een lokaal volume, niet over een live SSH-verbinding. Die les hebben we bij een eerdere migratie op de harde manier geleerd.
De extra_fields kolom van K2 is een geserialiseerde PHP-blob, geen JSON. Bij deze klant bevatte hij de per-project metadata (vierkante meters, opdrachtgever, jaartal) waar de SEO-contractor het meest om gaf. We hebben hem op de bronserver uitgepakt met een kort PHP CLI-script voordat de rest van de export liep. Probeer dit niet in Node te parsen, dat kost je een dag.
Editor-HTML transformeren
Hier lopen de meeste migraties uit de bocht. De TinyMCE van Joomla heeft veertien jaar aan inline styles, deprecated <font> tags, Word-paste residu en image-paden die naar /images/stories/ wijzen. Portable Text van Sanity is strikt. Je kunt er geen rauwe HTML in duwen.
We gebruikten @portabletext/block-tools met een deterministische HTML-preprocessor.
import {htmlToBlocks} from '@portabletext/block-tools'
import {defaultSchema} from './sanity-schema'
import {JSDOM} from 'jsdom'
function cleanLegacyHtml(raw: string): string {
return raw
.replace(/style="[^"]*"/g, '')
.replace(/<font[^>]*>([\s\S]*?)<\/font>/g, '$1')
.replace(/<o:p>[\s\S]*?<\/o:p>/g, '') // Word paste residu
.replace(/\/images\/stories\//g, '/legacy/')
.replace(/ /g, ' ')
}
const projectBody = defaultSchema
.get('project').fields
.find(f => f.name === 'body').type
const blocks = htmlToBlocks(
cleanLegacyHtml(item.fulltext),
projectBody,
{parseHtml: html => new JSDOM(html).window.document},
)
Dit is ook het punt waar de verleiding om aan een autonome agent te delegeren het grootst is. Niet doen. Er loopt deze maand op Hacker News een terugkerend patroon van agents die in live-omgevingen op hol slaan, en die les schaalt naar beneden: een agent met schrijfrechten op 2.800 records aan historische content zal vrolijk koppen 'opruimen', paragrafen samenvoegen en stilletjes figure captions laten vallen. We gebruikten een LLM om per item een diff-rapport te maken (oude platte tekst tegen nieuwe platte tekst, gemarkeerd waar het aantal karakters meer dan 5% afweek). De werkelijke writes waren deterministische code die een mens heeft gelezen.
Astro-routes die de oude paden respecteren
Het routebestand is één pagina, geparametriseerd op het K2-id en de alias.
// src/pages/projects/[id]-[alias].astro
---
import {sanityClient} from '../../lib/sanity'
import Layout from '../../layouts/Project.astro'
export async function getStaticPaths() {
const projects = await sanityClient.fetch(`
*[_type == "project" && defined(legacyId)]{
"id": legacyId,
"alias": legacyAlias,
title, intro, body, hero, gallery,
completedYear, client, surfaceSqm
}
`)
return projects.map(p => ({
params: {id: String(p.id), alias: p.alias},
props: {project: p},
}))
}
const {project} = Astro.props
---
<Layout project={project}>
<h1>{project.title}</h1>
<slot />
</Layout>
Astro bouwt 2.800 statische pagina's op dezelfde canonical URLs die Google in 2018 zag. Zie de Astro-docs over dynamic routes voor het parametercontract. De enige bewuste verandering is de trailing .html, die de redirect-laag afhandelt.
De 301-map
Zelfs met identieke numerieke paden serveert K2 elk item ook op de niet-gerewritte URL index.php?option=com_k2&view=item&id=47:housing-collective-leuven. Die query-string varianten zitten ook in de index. Net als de K2-categorielisten, de date-archive views en de tag-pagina's. Elk patroon krijgt één regel.
# /etc/nginx/sites-available/architecten.conf
# K2-item via query string
location = /index.php {
if ($arg_option = "com_k2") {
set $kind $arg_view;
if ($kind = "item") { return 301 /projects/$arg_id; }
if ($kind = "itemlist") { return 301 /projects/category/$arg_layout; }
}
}
# K2-item met .html-suffix
rewrite ^/projects/([0-9]+)-([^/]+)\.html$ /projects/$1-$2 permanent;
# K2-categorielijst
rewrite ^/projects/category/([0-9]+)-([^/]+)\.html$ /projects/c/$2 permanent;
Op Netlify of Vercel gaat de equivalent in _redirects of vercel.json. We genereren ook een CSV van elk oud/nieuw paar uit de K2-export en voeren die aan een verificatiescript voordat we cutten.
# Verifieer elke redirect in de map
while IFS=, read -r old expected; do
actual=$(curl -o /dev/null -s -w "%{redirect_url}" -I "$old")
status=$(curl -o /dev/null -s -w "%{http_code}" -I "$old")
if [ "$status" != "301" ] || [ "$actual" != "$expected" ]; then
echo "MISS $status $old -> $actual (verwacht $expected)"
fi
done < redirect-map.csv
Elke geprinte regel is een miss. Op dag één van deze oefening hadden we 184 missers. Bij cutover stond de teller op nul.
Cutover en de 72 uur erna
De choreografie van de cutover-dag is met opzet saai. Saaie deploys laten Search Console-alerts niet afgaan.
- Deploy Astro naar een staging-subdomein. Crawl het met Screaming Frog op dezelfde diepte als de oorspronkelijke audit. Diff URL-aantal,
<title>tags en<meta name="description">waarden tegen de legacy-crawl. Elke afwijking is een Sanity-veld dat iemand vergeten is in te vullen. - Zet de TTL op het apex A-record een dag van tevoren op 300 seconden. De meeste DNS-providers laten dit toe zonder dat je de registrar nodig hebt.
- Cutover om 02:00 lokale tijd. Pas het apex aan. Houd de access log de eerste 15 minuten in de gaten voor 404-pieken.
- Laat de oude Joomla-VM 30 dagen draaien als fallback. Mocht er iets uit de export ontbreken, dan staat het antwoord nog op disk.
- Dien de nieuwe sitemap diezelfde ochtend in bij Search Console, ook al vindt Google hem zelf wel. Houd het index coverage-rapport twee weken lang dagelijks in de gaten.
Joomla 2.5 zelf bereikte end of life op 31 december 2014. Wat in 2026 nog op die branch draait, leeft op geleende tijd en de gepatchte PHP van iemand anders. Dat de host van het bureau in Mechelen de stekker eruit trok, is niet ongewoon. Het is de standaard uiteindelijke uitkomst.
Wat we niet hadden verwacht
Twee dingen beten ons die in geen enkele migratiegids voorkwamen.
De eerste was de extra_fields blob, hierboven al in de waarschuwing genoemd. De tweede was de com_xmap sitemap-component van Joomla, die de projectlijst al jaren stilletjes afkapte op 1.000 entries vanwege een obscure config-flag. Het cijfer '2.800 URLs' van de SEO-contractor kwam uit de index van Google, niet uit de eigen sitemap van de site. We merkten het pas op toen de staging-crawl overeenkwam met het cijfer van de contractor, maar 1.840 URLs scheelde met de gepubliceerde sitemap van Joomla. De les: vertrouw nooit op het bron-CMS om zijn eigen oppervlak te kennen. Vertrouw de crawl, de index en de access log, en kies de grootste van de drie als ze het oneens zijn.
Toen we deze migratie bouwden voor het bureau in Mechelen, was het moment dat zich tien keer terugverdiende de tweedaagse audit voordat er één regel code geschreven werd. Zit je op een legacy-CMS dat niemand meer ondersteunt en op een SERP-ranking die je je niet kunt veroorloven te verliezen, dan geldt hetzelfde playbook. Wij doen deze legacy-migraties als opdracht met vaste scope, en de audit is altijd fase één. Die fase slaan we niet over.
Als je vandaag één ding doet: open Search Console, exporteer je top 500 URLs op clicks, en gooi ze met curl -I langs een verse lokale crawl van je eigen site. Overal waar de twee elkaar tegenspreken, zit het deel van het probleem dat je nog niet hebt gezien.
Kern
Behoud de numerieke K2-IDs in je Sanity-schema, genereer Astro-routes daaruit, en verifieer elke 301 tegen een geëxporteerde map voordat je de DNS omzet.
FAQ
Waarom de numerieke K2-IDs behouden in plaats van overstappen op schone slugs?
Google heeft de numerieke vorm jarenlang geïndexeerd. IDs behouden betekent minder 301-hops, minder PageRank-verlies, en één ding minder dat het nieuwe schema bij cutover hoeft te verdedigen. Schone slugs kunnen later komen.
Werkt hetzelfde playbook ook voor K2 naar WordPress of Strapi?
Ja. De audit, SQL-extractie en 301-strategie blijven identiek. Alleen de schemadefinitie en de statische routelaag veranderen. Astro plus Sanity is wat wij gebruiken, maar het playbook is overdraagbaar.
Hoe lang duurde de volledige migratie van begin tot eind?
Elf weken kalendertijd voor 2.800 projecten. Twee voor audit en schema, drie voor transformatie en redactie, drie voor build en redirect-verificatie, drie voor staging-review met het bureau en de SEO-contractor.
Wat gebeurt er met comments en oude useraccounts in K2?
We hebben beide geëxporteerd naar JSON-archieven, ze standaard buiten de nieuwe site gehouden, en de klant een alleen-lezen publiek archief op een /legacy/-pad aangeboden als de comments echte waarde hadden. Voor dit bureau was dat niet zo.