The phone call landed on a Tuesday. A regional staffing agency with sixty-two offices across the Benelux had a Drupal 7 site, twelve hundred indexed URLs, three years of organic traffic feeding their recruiter funnel, and a board meeting in nine working days where their marketing lead needed to show that the EOL risk was retired and Core Web Vitals had stopped bleeding. Their last dev shop had quoted three months for an in-place Drupal 10 upgrade. We proposed nine days to a headless Next.js. We finished on day nine, lost no rankings, and this is the playbook.
Why Drupal 7 still hurts in 2026
Drupal 7 reached community end-of-life on January 5, 2025, after several extensions. The vendor-extended support paths that filled the gap are paid, scoped, and on their own clock. By mid-2026, a site still on D7 is running on PHP 7.4 or earlier, hundreds of contrib modules with no security cover, and a frontend Lighthouse score that politely refuses to round up.
The obvious fix is Drupal 10. The honest read is that for a marketing site, in-place is the wrong tool. A D7 to D10 upgrade is a content-migration project wearing the costume of an upgrade. You will rebuild theme, contrib modules, custom code, and a lot of editorial workflow. You will get a Drupal site at the end. For 80% of marketing sites with a few hundred to a few thousand URLs, the cheaper and faster move is to detach the front from the CMS entirely.
The constraint that shapes everything
When you migrate a site that ranks, SEO continuity is not a feature. It is the project. Performance, design, editor experience, all of that comes later. If you arrive on day ten with a beautiful Next.js site and 30% of your organic traffic gone, the rest of the win does not matter. Three rules drove every decision in the playbook below.
- Every indexed URL gets a 301 to the exact equivalent on the new site.
- Page titles, meta descriptions, canonical tags, and structured data ship to production on cutover day, not in week two.
- Internal links inside body HTML resolve. Old image paths resolve. Old PDF paths resolve.
Skip any of those and you spend the next quarter explaining lost traffic to the board.
Day 1: inventory and lock the URL list
The first day is a crawl, not a line of code. We ran the live site through Screaming Frog, pulled the XML sitemap, and joined that against the Drupal url_alias table and a 90-day Google Search Console export. Three sources, because each one knows about URLs the other two forgot.
The output is a CSV with five columns: old URL, status code, page type (node bundle), template, and "in scope for migration". About 9% of URLs are typically out of scope: archived job postings, taxonomy facets that should have been noindex years ago, old campaign landers. Decide their fate on day one. Anything not "keep" becomes a 410 or a 301 to a parent page, never a soft 404.
Drupal 7 routes the same node through two paths by default: the canonical /node/123 and the human alias /jobs/welder-rotterdam. Both are indexed somewhere. Your redirect map must cover both, and the new canonical must match what Google last saw, which is almost always the alias.
Day 2: extract content without the CMS in the loop
Drupal 7 does not ship JSON:API. The Services and REST modules exist but are slow and inconsistent for a one-time bulk export. We went straight to MySQL. The schema is verbose but predictable: node, field_data_body, field_data_field_*, taxonomy_term_data, file_managed.
A query that dumps a single bundle, with body text and image references, looks like this:
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 the output to JSON, one file per node. The CMS never runs. The site stays live and serves traffic while you work.
Day 3: pick a content destination and resist the urge to redesign
Next.js needs content from somewhere. Three options, in order of how often we end up choosing them.
The first is MDX in the repo. For a site whose content changes monthly, not daily, putting 1,200 Markdown files in a content/ directory is faster than wiring a CMS. Editors get a small Decap or Sveltia panel on top, or they edit through GitHub. We picked this for the staffing client. Their job postings are managed in their ATS, which we read from a separate feed.
The second is a real headless CMS. Sanity, Contentful, Storyblok, Payload. Use this when there is a content team larger than two people, or when editorial changes daily.
The third is keep Drupal 10 as a headless backend. Use this only when contrib editorial tooling (Workbench, Paragraphs, complex revisions) is core to the workflow. For a marketing site, it is almost never worth the operational tax of running two stacks.
Crucially: do not redesign during a migration. A new design and a new platform shipped together means you cannot tell which one broke traffic. Migrate the design as-is, ship, monitor for two weeks, then redesign in the next sprint.
Day 4: page templates and metadata parity
Next.js App Router with generateStaticParams and generateMetadata maps onto the Drupal node-template model cleanly. Every page emits the same <title>, the same meta description, the same canonical, and the same Open Graph as the old site. The migration scripts copy those values from the export. We do not rewrite them in flight.
// 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) emits as inline JSON-LD in the same component. Whatever Schema.org types the old site shipped, the new site ships on day one. Not "we will add it later". Day one.
Day 5: assets, and why filenames matter
Drupal 7 stores files under sites/default/files/. Body HTML references them as /sites/default/files/2019/hero-photo.jpg. Old backlinks reference them. Old social shares reference them. If those URLs 404, image search rankings die quietly.
The rule we follow: preserve the file path. We rsync the entire files/ directory to an R2 bucket, set up a route in next.config.js so /sites/default/files/* proxies to the bucket, and only then start optimising. New uploads go through next/image and a modern path. Old paths keep working forever.
Day 6: the redirect map
This is the day the migration succeeds or fails. The map is a single JSON file. Every old URL, every new URL, every status code. We generate it from the inventory CSV and review it by hand.
For sites under five thousand routes, Next.js middleware with a hashed lookup is fast enough and keeps the map in the 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/|.*\\..*).*)"],
};
For larger sites, push the table to Cloudflare KV or Vercel Edge Config so the bundle stays small. Either way, follow the Google guidance on redirects: 301 for permanent, single hop, no chains. A redirect that goes A to B to C costs you crawl budget for no good reason.
Day 7: crawl diff against a frozen production
Stand up the new site on a staging domain. Crawl staging, crawl production, diff the two crawls. The columns that matter: status code, title, meta description, canonical, h1, word count, internal link count.
We use Screaming Frog's compare mode, but a small Python script over two sitemaps does the same job. Anything that differs goes onto a fix list. Day seven exists to drive that list to zero. If you skip this day, you find out about the diff from Search Console four weeks after launch.
Day 8: cutover, in the right order
Drop the DNS TTL to 60 seconds twenty-four hours before. On cutover morning: flip the A or CNAME, verify the new site is serving, smoke-test the top 50 URLs from Search Console, submit the new sitemap.xml, and request reindexing on the homepage and the top five money pages. Leave the old origin running for forty-eight hours in case of rollback.
If your redirect map covers every indexed URL, preserves canonicals, and resolves images at their old paths, Google treats the migration as a move, not a rebuild. Rankings hold within the normal weekly variance. That is the entire game.
Day 9 and the two weeks after
Day nine is the easy day. Watch Search Console, watch the 404 log, patch any redirect you missed. Real launches produce 20 to 60 missed URLs in the first week, almost all of them old campaign URLs, old PDF links from external sites, or odd aliases that never made it into any sitemap. Add them to the map, redeploy, move on.
Indexing of the new URLs catches up over two to four weeks. Rankings either stay flat or wobble inside their normal band. If they drop further than that, the cause is almost always one of three things: a redirect that 302s instead of 301s, a canonical that points at the wrong URL, or a noindex header that leaked from staging into production. Check those three before anything else.
What we cut to make nine days work
Nine days is not a fast version of three months. It is a different shape of project. We cut the redesign. We cut the new content model. We cut the editorial workflow refactor. We cut the "while we are at it" features. The job was: get off Drupal 7, keep the rankings, ship on the new stack. That is the project. Every other improvement is a separate week.
When we ran this for the staffing-agency client, the trap we hit on day six was that 14% of their old URLs had query strings carrying tracking parameters that Drupal had quietly turned into separate canonical entries over the years. We solved it by normalising the redirect map keys on path only and routing parameters through at the destination. If your site is more than five years old and ran any campaigns through Drupal, expect a similar mess. That is the kind of legacy migration work we run at ABN, and the playbook above is the one we use.
Open your Drupal site's admin/reports/status page, write down the PHP version, and put today's date next to it. That number is your migration deadline.




