Het telefoontje kwam op een dinsdag. Een regionaal uitzendbureau met tweeënzestig kantoren in de Benelux had een Drupal 7-site, twaalfhonderd geïndexeerde URLs, drie jaar aan organic traffic die hun recruiterfunnel voedde, en een boardvergadering over negen werkdagen waarin hun marketinglead moest laten zien dat het EOL-risico was afgedekt en dat Core Web Vitals niet langer bloedden. Hun vorige dev shop had drie maanden geoffreerd voor een in-place upgrade naar Drupal 10. Wij stelden negen dagen voor naar headless Next.js. We waren klaar op dag negen, verloren geen enkele ranking, en dit is het draaiboek.
Waarom Drupal 7 in 2026 nog steeds pijn doet
Drupal 7 bereikte op 5 januari 2025 het community end-of-life, na meerdere uitstelrondes. De betaalde vendor-extended support-paden die het gat vullen lopen op hun eigen klok. Halverwege 2026 betekent een site die nog op D7 staat: PHP 7.4 of eerder, honderden contrib modules zonder security cover, en een Lighthouse-score op de frontend die beleefd weigert af te ronden naar boven.
De voor de hand liggende oplossing is Drupal 10. De eerlijke lezing is dat in-place voor een marketingsite de verkeerde tool is. Een D7-naar-D10 upgrade is een content-migratieproject in een upgrade-kostuum. Je herbouwt theme, contrib modules, custom code en een flink stuk redactionele workflow. Aan het einde heb je een Drupal-site. Voor 80% van de marketingsites met een paar honderd tot een paar duizend URLs is de goedkopere en snellere zet: de frontend volledig loskoppelen van de CMS.
De randvoorwaarde die alles bepaalt
Bij het migreren van een site die rankt is SEO-continuïteit geen feature. Het is het hele project. Performance, design, editor experience, dat komt allemaal later. Als je op dag tien aankomt met een prachtige Next.js-site en 30% van je organic traffic kwijt bent, doet de rest van de winst er niet toe. Drie regels stuurden elke beslissing in het draaiboek hieronder.
- Elke geïndexeerde URL krijgt een 301 naar het exacte equivalent op de nieuwe site.
- Page titles, meta descriptions, canonical tags en structured data gaan op cutover-dag naar productie, niet in week twee.
- Interne links in body-HTML resolven. Oude image paths resolven. Oude PDF-paths resolven.
Sla er één over en je bent het volgende kwartaal verloren traffic aan het uitleggen aan de board.
Dag 1: inventarisatie en de URL-lijst bevriezen
De eerste dag is een crawl, geen regel code. We haalden de live site door Screaming Frog, trokken de XML-sitemap erbij en joinden dat tegen de Drupal url_alias-tabel en een Search Console-export over 90 dagen. Drie bronnen, want elke bron kent URLs die de andere twee vergeten zijn.
De output is een CSV met vijf kolommen: oude URL, statuscode, paginatype (node bundle), template en 'in scope voor migratie'. Doorgaans valt zo'n 9% van de URLs buiten scope: gearchiveerde vacatures, taxonomy-facetten die jaren geleden al noindex hadden moeten zijn, oude campagne-landers. Beslis op dag één wat ermee gebeurt. Alles wat geen 'behouden' is wordt een 410 of een 301 naar een parent page, nooit een soft 404.
Drupal 7 stuurt dezelfde node standaard via twee paden: het canonical /node/123 en de menselijke alias /jobs/welder-rotterdam. Beide zijn ergens geïndexeerd. Je redirect map moet allebei dekken, en het nieuwe canonical moet matchen met wat Google het laatst gezien heeft. Dat is bijna altijd de alias.
Dag 2: content extracten zonder de CMS in de loop
Drupal 7 levert geen JSON:API. De Services- en REST-modules bestaan, maar zijn traag en inconsistent voor een eenmalige bulkexport. Wij gingen direct op MySQL. Het schema is verbose maar voorspelbaar: node, field_data_body, field_data_field_*, taxonomy_term_data, file_managed.
Een query die één bundle dumpt, inclusief body-tekst en image references, ziet er zo uit:
SELECT
n.nid,
n.title,
n.created,
ua.alias AS path_alias,
b.body_value AS body_html,
GROUP_CONCAT(DISTINCT t.name) AS tags,
GROUP_CONCAT(DISTINCT fm.uri) AS image_uris
FROM node n
LEFT JOIN field_data_body b
ON b.entity_id = n.nid AND b.entity_type = 'node'
LEFT JOIN url_alias ua
ON ua.source = CONCAT('node/', n.nid)
LEFT JOIN field_data_field_tags ft
ON ft.entity_id = n.nid
LEFT JOIN taxonomy_term_data t
ON t.tid = ft.field_tags_tid
LEFT JOIN field_data_field_hero_image fi
ON fi.entity_id = n.nid
LEFT JOIN file_managed fm
ON fm.fid = fi.field_hero_image_fid
WHERE n.type = 'job_offer' AND n.status = 1
GROUP BY n.nid;
Pipe de output naar JSON, één file per node. De CMS draait niet mee. De site blijft live en bedient traffic terwijl jij werkt.
Dag 3: kies een content-destination en weersta de neiging tot redesign
Next.js heeft content nodig vanuit ergens. Drie opties, in volgorde van hoe vaak we hierop uitkomen.
De eerste is MDX in de repo. Voor een site waarvan de content maandelijks verandert, niet dagelijks, is 1.200 Markdown-files in een content/-directory zetten sneller dan een CMS koppelen. Editors krijgen er een klein Decap- of Sveltia-paneel bovenop, of ze editen via GitHub. Wij kozen dit voor de uitzendklant. Hun vacatures worden beheerd in hun ATS, die we via een aparte feed inlezen.
De tweede is een echte headless CMS. Sanity, Contentful, Storyblok, Payload. Gebruik dit als er een contentteam is van meer dan twee mensen, of als de redactie dagelijks wijzigt.
De derde is Drupal 10 behouden als headless backend. Gebruik dit alleen als contrib-editorial tooling (Workbench, Paragraphs, complexe revisies) centraal staat in de workflow. Voor een marketingsite zijn de operationele kosten van twee stacks draaien bijna nooit te verantwoorden.
Cruciaal: ga niet redesignen tijdens een migratie. Een nieuw design en een nieuw platform tegelijk live betekent dat je niet kunt aanwijzen wat de traffic brak. Migreer het design as-is, ship het, monitor twee weken, en redesign daarna in de volgende sprint.
Dag 4: page templates en metadata-pariteit
Next.js App Router met generateStaticParams en generateMetadata mapt netjes op het Drupal node-templatemodel. Elke pagina emit dezelfde <title>, dezelfde meta description, dezelfde canonical en dezelfde Open Graph als de oude site. De migratiescripts kopiëren die waarden uit de export. We schrijven ze niet on the fly opnieuw.
// app/(site)/[...slug]/page.tsx
import { getPostByAlias, getAllAliases } from "@/lib/content";
import type { Metadata } from "next";
export async function generateStaticParams() {
const aliases = await getAllAliases();
return aliases.map((a) => ({ slug: a.split("/") }));
}
export async function generateMetadata(
{ params }: { params: { slug: string[] } }
): Promise<Metadata> {
const post = await getPostByAlias(params.slug.join("/"));
return {
title: post.metaTitle ?? post.title,
description: post.metaDescription,
alternates: { canonical: `/${post.alias}` },
openGraph: {
title: post.ogTitle ?? post.title,
description: post.ogDescription ?? post.metaDescription,
images: post.ogImage ? [post.ogImage] : [],
},
};
}
Structured data (JobPosting, Organization, BreadcrumbList) emit als inline JSON-LD in dezelfde component. Welke Schema.org-types de oude site ook leverde, de nieuwe site levert ze op dag één. Niet 'we voegen het later toe'. Dag één.
Dag 5: assets, en waarom filenames ertoe doen
Drupal 7 slaat files op onder sites/default/files/. Body-HTML verwijst ernaar als /sites/default/files/2019/hero-photo.jpg. Oude backlinks verwijzen ernaar. Oude social shares verwijzen ernaar. Als die URLs een 404 geven, sterven je image search-rankings stilletjes.
De regel die wij volgen: bewaar het filepath. We rsyncen de volledige files/-directory naar een R2-bucket, zetten een route in next.config.js zodat /sites/default/files/* naar de bucket proxyt, en pas dan beginnen we met optimaliseren. Nieuwe uploads gaan via next/image en een modern path. Oude paths blijven voor altijd werken.
Dag 6: de redirect map
Dit is de dag waarop de migratie slaagt of sneuvelt. De map is één JSON-file. Elke oude URL, elke nieuwe URL, elke statuscode. We genereren hem uit de inventarisatie-CSV en lopen hem met de hand na.
Voor sites onder de vijfduizend routes is Next.js middleware met een gehashte lookup snel genoeg en houdt de map binnen de repo:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import redirects from "./redirects.json";
const map = new Map<string, string>(Object.entries(redirects));
export function middleware(req: NextRequest) {
const target = map.get(req.nextUrl.pathname);
if (target) {
const url = req.nextUrl.clone();
url.pathname = target;
return NextResponse.redirect(url, 301);
}
}
export const config = {
matcher: ["/((?!_next/|api/|.*\\..*).*)"],
};
Voor grotere sites duw je de tabel naar Cloudflare KV of Vercel Edge Config zodat de bundle klein blijft. Hoe dan ook, volg de Google-richtlijn voor redirects: 301 voor permanent, één hop, geen ketens. Een redirect die van A naar B naar C gaat kost je crawl budget zonder reden.
Dag 7: crawl diff tegen een bevroren productie
Zet de nieuwe site op een staging-domein. Crawl staging, crawl productie, diff de twee crawls. De kolommen die ertoe doen: statuscode, title, meta description, canonical, h1, woordaantal, aantal interne links.
Wij gebruiken de compare mode van Screaming Frog, maar een klein Python-scriptje over twee sitemaps doet hetzelfde werk. Alles wat afwijkt komt op een fixlijst. Dag zeven bestaat om die lijst naar nul te drijven. Sla je deze dag over, dan hoor je vier weken na launch via Search Console over de diff.
Dag 8: cutover, in de juiste volgorde
Zet de DNS TTL vierentwintig uur van tevoren op 60 seconden. Op de cutover-ochtend: flip de A- of CNAME-record, verifieer dat de nieuwe site serveert, smoke-test de top 50 URLs uit Search Console, dien de nieuwe sitemap.xml in, en vraag herindexering aan op de homepage en de top vijf money pages. Laat de oude origin nog achtenveertig uur draaien voor het geval van rollback.
Als je redirect map elke geïndexeerde URL dekt, canonicals bewaart en images op hun oude paths resolved, behandelt Google de migratie als een verhuizing, niet als een herbouw. Rankings houden zich binnen de normale wekelijkse variantie. Dat is het hele spel.
Dag 9 en de twee weken daarna
Dag negen is de makkelijke dag. Houd Search Console in de gaten, houd de 404-log in de gaten, patch elke redirect die je gemist hebt. Echte launches produceren 20 tot 60 gemiste URLs in de eerste week, vrijwel allemaal oude campagne-URLs, oude PDF-links vanuit externe sites, of vreemde aliases die nooit in een sitemap zijn beland. Voeg ze toe aan de map, deploy opnieuw, ga door.
De indexering van de nieuwe URLs loopt over twee tot vier weken bij. Rankings blijven plat of wiebelen binnen hun normale band. Zakken ze verder, dan is de oorzaak vrijwel altijd een van drie dingen: een redirect die 302 stuurt in plaats van 301, een canonical die naar de verkeerde URL wijst, of een noindex-header die van staging naar productie is gelekt. Controleer die drie voor alles.
Wat we schrapten om negen dagen haalbaar te maken
Negen dagen is geen snelle versie van drie maanden. Het is een ander type project. We schrapten het redesign. We schrapten het nieuwe content model. We schrapten de refactor van de editorial workflow. We schrapten de 'als we toch bezig zijn'-features. De opdracht was: van Drupal 7 af, rankings behouden, live op de nieuwe stack. Dat is het project. Elke andere verbetering is een aparte week.
Toen we dit voor de uitzendklant uitvoerden, was de val waar we op dag zes intrapten dat 14% van hun oude URLs query strings had met trackingparameters, die Drupal in de loop der jaren stilletjes als aparte canonical entries had geïndexeerd. We losten het op door de redirect-map keys te normaliseren op alleen het path en de parameters via de destination door te leiden. Is je site ouder dan vijf jaar en draaiden er campagnes door Drupal: verwacht een vergelijkbare puinhoop. Dit soort legacy-migratiewerk doen we bij ABN, en het draaiboek hierboven is wat we daarbij gebruiken.
Open de admin/reports/status-pagina van je Drupal-site, noteer de PHP-versie, en zet de datum van vandaag ernaast. Dat getal is je migratiedeadline.




