.NET to Next.js Migration: A Zero Ranking Loss Playbook
A .NET to Next.js migration keeps its rankings when every legacy URL, including every casing and query-string variant, resolves in one 301 hop to a page whose title, canonical, schema and server-rendered copy already match the old one, proven on staging before DNS moves.
- Migration
- .NET
- Next.js
- Technical SEO
- Case Study
What does a legacy .NET site look like the day before migration?
In 2024 I moved the Thrifty UAE web platform off a PHP Laravel monolith onto Next.js while it kept taking bookings, and on Dollar UAE I replaced a .NET backend with six shared Node.js microservices behind a React front end that stayed where it was. The published outcome is on the work page: zero ranking positions lost, zero minutes of downtime, 100% of URLs redirect-mapped, 30 days of post-launch monitoring. What follows is the runbook, not the highlight reel, and it is the same sequence I would hand another engineer asked to replatform without losing SEO. Your legacy stack may well be .NET rather than Laravel. The URL contract is what decides the outcome, not the language it was written in.
The starting state matters more than the destination, so describe it honestly first. This was server-rendered .NET with WebForms-era URL conventions, which produces five specific problems that a React rebuild does not solve on its own.
- File extensions in the path. URLs ending in
.aspxwere carrying real rankings. Next.js will never emit one, so every extension URL must be redirected — and, awkwardly, anything with a dot in the path is excluded by the standard proxy matcher, so those rules cannot live in the same layer as the rest. - Case-insensitive routing. IIS matches paths case-insensitively, so
/Locations/Dubai.aspxand/locations/dubai.aspxboth returned 200 for years, and both got linked, shared and indexed. Node is case-sensitive. Every casing variant that ever existed becomes a 404 the moment you cut over, unless you enumerate them. - State in the query string. Culture, list filters, sort order and legacy tracking parameters all lived after the question mark. Each distinct string was crawled as a separate URL, and a minority of them were indexed.
- Duplicated content across market subpaths. The same copy was reachable under more than one market or locale prefix, with no consistent canonical between them, which meant the equity for one page was split across several addresses.
- No single source of truth for what existed. The sitemaps listed what the CMS knew about. The application served considerably more than that, and had been doing so for years.
The good news, stated plainly: server-rendered .NET is a much better starting point than a JavaScript single-page app. The body copy was already in the HTML, so the parity target was "do not regress" rather than "start rendering". That distinction is worth more every year — instrumented testing found GPTBot downloading JavaScript on roughly 11.5% of requests and ClaudeBot on 23.8%, and never executing it (SearchOptimo, 2026). A rebuild that moves copy behind a client boundary trades a working legacy stack for an invisible modern one.
How do you build one URL table that makes priority a data question?
Four sources, reconciled into one table keyed on a normalised path. Nothing gets designed, redirected or deleted until that table exists, because until it exists every priority decision is somebody guessing which pages matter.
- Search Console, Performance, Pages — exported over the full 16 months. Clicks, impressions, average position per URL. This is both the ranked list and the before-baseline you will compare against for the next 30 days.
- Every XML sitemap the platform published, walked from the index file rather than trusting one document. On a legacy stack these are usually generated by a component nobody has looked at since it was written.
- Server logs, 30 to 90 days, filtered to Googlebot and Bingbot and then re-filtered to real users. This is the only source that finds URLs with no inbound link and no sitemap entry that still return 200 and still get fetched. On a WebForms-era application it is where the query-string variants surface.
- A full crawl, JavaScript rendering off, no page limit, nofollow followed — plus the internal-links export. You need the link graph, not just a list of addresses.
I add a fifth for weighting: the analytics landing-page report, so the table carries sessions alongside impressions. Impressions tell you what search engines think the page is for; sessions tell you what people actually landed on. A page with modest impressions and strong sessions from branded queries is not a candidate for consolidation, and impressions alone will not tell you that.
Then normalise, then join. Normalisation is the actual work: lower-case the path, strip the trailing slash, strip the extension, and decide per query parameter whether it defines a distinct page or is noise. Two rows that normalise to the same key are the same page, however many ways the old platform spelled it.
# Reconcile the exports into one URL table. sqlite3 is enough for this;
# nothing here needs a database server, and the file is a deliverable.
sqlite3 migration.db <<'SQL'
.mode csv
-- With .mode csv and a table that does not exist yet, sqlite3 takes the
-- first row of the file as the column names.
.import gsc-pages.csv gsc
.import ga4-landing.csv ga4
.import bot-hits.csv logs
-- One key per real page. Lower-case, drop the .aspx, drop the trailing
-- slash. Everything downstream joins on this and nothing else.
CREATE VIEW norm AS
SELECT url AS raw,
rtrim(replace(lower(url), '.aspx', ''), '/') AS key,
CAST(impressions AS INTEGER) AS impressions,
CAST(clicks AS INTEGER) AS clicks
FROM gsc;
CREATE TABLE inventory AS
SELECT n.key,
count(DISTINCT n.raw) AS legacy_variants,
sum(n.impressions) AS impressions_16mo,
sum(n.clicks) AS clicks_16mo,
coalesce(sum(CAST(g.sessions AS INTEGER)), 0) AS sessions,
coalesce(max(CAST(l.hits AS INTEGER)), 0) AS bot_hits
FROM norm n
LEFT JOIN ga4 g ON rtrim(lower(g.landing_page), '/') = n.key
LEFT JOIN logs l ON rtrim(lower(l.url), '/') = n.key
GROUP BY n.key
ORDER BY impressions_16mo DESC;
SQLThe output is one row per canonical page, carrying every legacy variant that resolved to it, its 16-month impressions, its sessions, its bot hits and its inbound internal links. Priority then becomes arithmetic rather than opinion: sort by impressions descending, take the running cumulative share, and the URLs above the 80% line get individually verified before launch. Everything below it is verified by rule and spot-checked. On a site with years of accumulated URLs that distinction is what makes the pre-launch gate finishable.
How do you choose the new URL structure without spending equity?
One rule: change a URL only where the change is forced, and where it is forced, change it exactly once. A replatform is the moment everyone wants to fix the slugs they have disliked for three years. Do that on the same day you change the platform, the rendering and the templates, and you will never know which change moved which number.
- Forced: the extension.
.aspxcannot survive. Every one of those URLs is redirected, and this is the largest single block of the map. - Forced: casing. Pick lower-case, everywhere, permanently, and make it a lint rule rather than a convention. The new platform is case-sensitive whether or not anyone remembers that.
- Forced: the duplicated market subpaths. One canonical path per page. The duplicates become redirects, not canonical tags — a canonical is a hint, a 301 is an instruction, and this is a case where you want the instruction.
- Not forced: slug wording. Keep the words. If a slug is genuinely bad, change it in a separate release, weeks later, with its own before-and-after.
- Decided per parameter: query strings. A parameter that defines a distinct page with real demand becomes a path segment. A parameter that only reorders or decorates the same content is dropped, and its variants collapse onto the base page.
That last decision is where the inventory earns its cost. You are not guessing whether a filter deserves a URL; you are reading the impressions column for every variant of it. A filter combination with meaningful impressions across two years gets a real, indexable, server-rendered page. One with none gets folded into the base page and its parameters dropped. This is also the point to settle the locale strategy for good: one path family, the locale as the first segment, reciprocal hreflang with an x-default, decided before a single route is built rather than retrofitted. It is the same structural decision behind Next.js development generally.
How do you collapse casing and query-string variants into a single hop?
The specification is four words long: one hop, one 200. It is difficult only because a legacy .NET application emits URL shapes that a hand-written map never anticipates, and because Next.js resolves redirects in two different layers that can quietly stack on each other.
| Legacy URL shape | Why it existed | Destination | Which layer |
|---|---|---|---|
/Locations/Dubai.aspx | IIS matched paths case-insensitively, so every casing variant returned 200 and several got linked | /locations/dubai | next.config.ts — dotted paths never reach the proxy |
/locations/dubai.aspx?lang=ar | Culture was chosen by query string, not by path | /ar/locations/dubai | next.config.ts, using has, ordered before the plain rule |
/default.aspx | The WebForms landing document, still sitting in old emails and CRM templates | / | next.config.ts |
/Fleet.aspx?category=suv&sort=price | Faceted list rendered server-side; only category had demand behind it | /fleet/suv | next.config.ts; sort is dropped rather than carried forward |
/ae/en/locations/dubai | A market subpath serving the same copy as the unprefixed path | /en/locations/dubai | proxy.ts, resolved before locale routing runs |
/Locations/Dubai/ | Mixed-case extensionless variant with a trailing slash | /locations/dubai | proxy.ts after normalisation — one hop, not a 308 then a map lookup |
// next.config.ts — Next.js 16
import type {NextConfig} from 'next';
// Generated from the inventory table by a script. One entry per legacy
// variant actually observed, INCLUDING each casing variant: path matching
// here is case-sensitive and IIS was not. Never typed by hand.
import {ASPX_MAP} from './src/lib/legacy/aspx-map';
// Next.js does not export the Redirect type. Deriving it from the config
// keeps `has: [{type: 'query'}]` from widening to string and failing.
type Redirect = Awaited<
ReturnType<NonNullable<NextConfig['redirects']>>
>[number];
const aspx: Redirect[] = Object.entries(ASPX_MAP).flatMap(([from, to]) => [
// Order matters. The query-conditioned rule must be evaluated before the
// unconditional one, or /X.aspx?lang=ar takes the English hop first and
// an Arabic reader pays for two.
{
source: from,
has: [{type: 'query', key: 'lang', value: 'ar'}],
destination: `/ar${to}`,
statusCode: 301
},
// statusCode: 301 rather than permanent: true, which emits 308. Google and
// Bing treat them identically; old log pipelines and link checkers do not.
{source: from, destination: to, statusCode: 301}
]);
const nextConfig: NextConfig = {
// Decide the slash policy once, here, so nothing adds an implicit 308.
trailingSlash: false,
async redirects() {
return [
...aspx,
{source: '/default.aspx', destination: '/', statusCode: 301},
// A named capture group in `value` makes the match available in the
// destination. Sort order was never an indexable distinction, so it
// simply does not appear on the right-hand side.
{
source: '/fleet.aspx',
has: [{type: 'query', key: 'category', value: '(?<category>[a-z-]+)'}],
destination: '/fleet/:category',
statusCode: 301
}
];
}
};
export default nextConfig;Extensionless legacy paths are the other half, and they belong in the proxy — the file Next.js 16 renamed from middleware.ts. This is where casing and the market prefixes get normalised, and it is the one place where the ordering against locale routing genuinely matters.
// src/proxy.ts — Next.js 16 renamed middleware.ts to proxy.ts.
import createMiddleware from 'next-intl/middleware';
import {NextResponse, type NextRequest} from 'next/server';
import {routing} from '@/i18n/routing';
import {LEGACY_PATHS} from '@/lib/legacy/paths';
const intl = createMiddleware(routing);
// Every legacy spelling of a page collapses to one key. One key is what
// makes the redirect a single hop instead of a chain.
function normalise(pathname: string): string {
const lower = pathname.toLowerCase().replace(/\/+$/, '');
const withoutMarket = lower.replace(/^\/(ae|sa|qa)(?=\/|$)/, '');
return withoutMarket || '/';
}
export function proxy(request: NextRequest) {
const {pathname} = request.nextUrl;
const destination = LEGACY_PATHS[normalise(pathname)];
// Resolve legacy URLs BEFORE next-intl sees the request. Let the locale
// middleware run first and an old URL earns two hops — locale redirect,
// then legacy redirect — which is the exact thing this exercise exists
// to prevent. The equality check stops an already-canonical path looping.
if (destination && destination !== pathname) {
return NextResponse.redirect(new URL(destination, request.url), 301);
}
return intl(request);
}
export const config = {
matcher: ['/((?!api(?:/|$)|_next/|_vercel/|.*\\..*).*)']
};.aspx rule lives in next.config.ts instead.Then verify by script, never by clicking. Run every row of the map through curl -sS -o /dev/null -L -w "%{http_code} %{num_redirects} %{url_effective}" and assert three things per URL: status 200, exactly one redirect, and the landing URL equal to the mapped destination. Any row that fails is a launch blocker until it is either fixed or written down as an accepted exception. That script is also the first thing you run against production ten minutes after cutover.
How do you prove metadata and structured data parity before launch?
By exporting the old values, not rewriting them. On a .NET platform the titles and descriptions live in the page model or in a database table; pull them once, ship them as a fixture the build reads, and have generateMetadata return the same string the old page returned, character for character. Improve the copy afterwards, in its own release, where the effect is attributable to it.
- Set `metadataBase` in the root layout to the absolute production origin. Without it, canonicals and Open Graph URLs resolve against whichever host is serving — which on staging means canonicals advertising the staging domain to anything that crawls it.
- Canonicals absolute, self-referential, on the host you actually serve. Google papers over a mismatch; Bing does not. Conflicting canonicals are a documented cause of Bing indexing the wrong URL or nothing at all (Microsoft Q&A, 2026).
- Robots directives checked in both directions. Whatever was
noindexstaysnoindex, and more urgently, nothing new becomes it. A staging-widenoindexshipped to production is the single most efficient way to undo a year of work in an afternoon. - Structured data node types match or improve. Diff the old URL against its staging counterpart in the Rich Results Test. Dropping a
BreadcrumbListsitewide is not a design decision, it is a rich-result regression. - Rendered word count within a few per cent, measured by crawling staging twice — once with JavaScript rendering on and once with it off. A gap between the two runs is copy that only exists after hydration.
A booking platform adds one more category the parity diff has to enforce from the opposite direction: the funnel. Search results, date and vehicle selection, and checkout steps are parameter explosions that were noindex on the old platform and must be noindex on the new one from the very first deploy, absent from the sitemap, and absent from internal link modules that crawl-follow. The pre-launch gate for those URLs is not "did they come across correctly" but "are they still excluded".
How do you rebuild internal linking so pages keep their inbound links?
Every internal href in migrated content gets rewritten at build time through the same map that drives the redirects, so that no link on the new site points at a URL which then has to be redirected. Re-crawl staging afterwards and assert that internal 3xx responses are zero. A link with no mapping is a bug in the map, not a bug in the link — fix it upstream, in the table, where the fix applies everywhere at once.
Then compare link counts, not just link validity. Join the inbound-internal-links report from the staging crawl to the same report from the live site, one row per mapped URL pair. This is the check that gets skipped, and it is the one that quietly costs rankings: a legacy application accumulates navigation nobody has counted — footer directories, cross-brand modules, location grids, related-vehicle blocks, pagination. A rebuild ships the links a designer drew. A page that received twenty internal links and now receives two has lost the thing that made it rank, and no redirect returns that.
How do you stage the cutover so rollback takes minutes?
- 1
T-7d — drop the DNS TTL
Lower it to 300 seconds and let the old value expire from resolvers everywhere. The TTL is your rollback budget: whatever it says is how long a mistake stays live after you decide to undo it.
- 2
T-72h — run the new platform in parallel
The new build live on its own hostname,
noindexand access-restricted, with the complete redirect map active and the verification script passing against it. Parallel does not mean "deployed" — it means the old system is still authoritative and the new one is answering the same URLs correctly. - 3
T-24h — freeze content, re-export, re-diff
Anything published on the old platform after the final export does not exist on the new one. Re-export metadata, re-run the full parity diff. Code and content have both moved since the last run; assume nothing you have not re-measured today.
- 4
T-0 — cut over and verify from outside
Point DNS. Then fetch the top 50 legacy URLs by impressions from a machine that has never seen the site — not the browser you have had the staging cookie in all week — and assert one hop to a 200.
- 5
T+15m — robots.txt, sitemaps, firewall
Confirm production
robots.txtis not the staging deny-all. Submit the new sitemaps in Search Console and Bing Webmaster Tools. Keep any managed bot ruleset in log-only mode: 2026 crawlability data from Anagram found 17.6% of top sites that allow GPTBot in robots.txt return 403 to it in practice, and a WAF nobody reviewed is exactly how that happens. - 6
T+1h — tracking parity
GA4, GTM and ad pixels firing on the new templates with the same event names and the same parameters as the day before. Keeping rankings and losing conversion data still reads as a failed migration to everyone who is not an engineer. This is analytics and tracking work, and it belongs on the cutover checklist rather than after it.
- 7
T+24h — first crawl-stats read
Search Console, Settings, Crawl stats. Successful requests rising, no spike in 404 or 5xx, average response time inside the pre-launch range.
The rollback plan is one sentence and it has to be true: the DNS record is the switch, and the old platform stays running and reachable on its own hostname until the watch window closes. Keep serving the old sitemap for a few weeks too, if you still control it — Google site-move guidance is explicit that leaving old URLs discoverable speeds up how fast they get crawled, redirected and reprocessed. Deliberately absent from this list: the Change of Address tool, which applies only when the domain itself changes. A rebuild on the same domain does not use it.
What do you watch for 30 days after cutover?
Daily for week one, then twice weekly. The whole job is separating normal re-crawl wobble from an actual defect, and the only way to do that is against the baseline exported before launch — compared per URL pair, never against a site total. Totals hide a page that lost everything behind a page that gained.
| Signal | Source | Healthy pattern | Trigger to act |
|---|---|---|---|
| Indexation by reason | Search Console, Page indexing | Page with redirect rises then plateaus as the map is worked through | Not found (404) still climbing after week one |
| Coverage errors | Search Console, Page indexing | Server-error count flat at the pre-launch number | Any Server error (5xx) at all |
| Redirect volume | Server logs, status code by day | 301 volume decays week over week | 301 volume still flat at month one — something of yours still links old URLs |
| Crawl response time | Search Console, Settings, Crawl stats | Average response time at or below the old platform | A steady climb, meaning crawlers are paying for cache misses |
| Field Core Web Vitals | Search Console Core Web Vitals report, CrUX field data | URL groups move from old templates to new ones with no Poor bucket appearing | A new Poor group, or LCP past 2.5s at the 75th percentile |
| Per-URL search performance | Search Console Performance export, joined to the baseline | Each mapped pair recovers to its own pre-launch level | Impressions recover but average position stays worse — that points at content, not redirects |
| Event parity | GA4 realtime and DebugView, plus daily event counts | Same event names, same parameters, comparable daily volume | Any pre-launch event name missing for 24 hours |
| Bing | Bing Webmaster Tools, URL Inspection and sitemap report | Sitemap discovered, zero redirect errors on submitted URLs | A canonical mismatch reported on any sampled URL |
One nuance worth internalising: the field Core Web Vitals report is a 28-day rolling window. For roughly a month after cutover it is mixing old-platform and new-platform sessions, so a flat line there in week two means nothing at all. Read the lab numbers and your own real-user measurement for that first month and treat the field report as the confirmation that arrives late. That is also why Core Web Vitals work is scoped as its own exercise rather than folded into launch week.
What was actually measured, and what was not?
Precision here is the difference between a case study and a brochure, so here is the operational definition. "No ranking loss" on this migration means that per-URL impressions and average position for the mapped set, compared against each URL against its own pre-cutover baseline in Search Console, held through the 30-day watch. It does not mean a keyword tracker showed a green arrow, and it is not a claim about a vanity phrase list.
- Measured: per-URL impressions, clicks and average position against the 16-month Search Console baseline, joined on the mapped old-to-new pair.
- Measured: page-indexing counts by reason, coverage errors, and the daily status-code distribution from server logs.
- Measured: redirect behaviour, by re-running the one-hop assertion script against production rather than trusting the config.
- Measured: analytics event names and volumes before and after, which is how you catch a tracking regression while it is still a day old.
- Not measured, and not claimed: causal isolation. Search results move for reasons unrelated to any migration — algorithm updates, competitors, seasonality in a rental market with obvious peaks. The honest statement is that nothing broke, not that the migration caused something to improve.
- Not measured cleanly: field Core Web Vitals in the first month, for the 28-day rolling-window reason above.
- Not attempted: a revenue attribution model isolating the replatform from every other commercial change happening in the same quarter.
The same method sits under the other build I worked on day to day: Cariva, 326 server-rendered SEO pages in Next.js, where the URL structure, sitemap and schema were designed in the first sprint rather than retrofitted. Different problem, same discipline: decide the URL contract before writing the templates.
If you are sitting on a legacy stack and the question in the room is whether a replatform without losing SEO is realistic, the answer is yes, and it is decided by the inventory rather than by the framework. That work is the SEO-safe migration and rebuild service, and it is most of what Next.js development means once a site already has rankings to protect. Every project starts with an introductory project call: I crawl what you have and tell you where the risk is concentrated before anyone scopes a rebuild. Send me the domain and I will run it.
What should you know about how I work on this?
That is the design goal, and it is a method rather than a promise, because nobody can guarantee a search ranking. The method is a complete URL inventory reconciled from Search Console, sitemaps, server logs and a full crawl; a redirect map generated from that table with one hop per legacy URL and no catch-all to the home page; metadata, canonical and structured-data parity verified by a crawl diff on staging; and 30 days of monitoring afterwards. That is the sequence I used to move Thrifty UAE off a PHP Laravel monolith onto Next.js, which came through with no ranking loss. The legacy platform differs from project to project. The URL contract and the parity gates do not.
Yes, on the web platform side. The pattern I work in is replatforming a server-rendered legacy front end, whether that is Laravel, .NET or WebForms-era URLs with template logic buried in the application, onto Next.js with the App Router, while the existing back-end services keep running. The front end moves first because that is where the search equity, the Core Web Vitals and the conversion tracking live. Back-end modernisation can then happen behind a stable URL contract, one service at a time, instead of as one high-risk event that changes everything on the same afternoon. On Dollar UAE that second step was replacing a .NET backend with six shared Node.js microservices, with the React front end left where it was.
Through the interfaces you already have, and without rewriting them as part of the migration. A legacy platform usually exposes booking, inventory or pricing through an API, a database or an internal service; the new front end consumes those and nothing about them changes on cutover day. Third-party tags, payment gateways and CRM integrations get inventoried the same way URLs do, with a before-and-after check for each one. Changing the rendering layer and the integration layer in the same release is what makes a migration unattributable when something moves.
By making each of them a gate with a measurable pass condition rather than a hope. Performance is a budget checked on staging before launch, not a score read afterwards. Security means the new headers, the bot rules and the funnel exclusions are verified in place before DNS moves, with any managed WAF ruleset left in log-only mode so it cannot silently block crawlers. Delay risk is reduced by sequencing: the inventory and redirect map come first, because they determine what the build has to produce, and a build started before them gets redone.
For a legacy replatform, the honest range is six to twelve weeks, and page count is a poor predictor of it. What drives the timeline is template count, how many URL shapes need individual handling, and how much of the old application logic has no documentation. The inventory and redirect map are usually two weeks on their own and they come first. The final week is content freeze, parity diff and cutover. A brochure rebuild with five templates is much faster; a booking or commerce platform with a funnel to exclude is not.
No, and I would be wary of anyone who does. Positions are decided by Google against competitors who are also changing, and no developer or agency controls that. What is controllable is every technical cause of a migration-driven drop: redirect chains, unmapped URL variants, lost internal links, drifted titles and canonicals, copy that only exists after hydration, and slower server response times. Those get engineered out and verified. You get the parity diff before launch and the per-URL baseline comparison after it, so any movement can be attributed rather than argued about.
The 30-day watch described in this post is part of the migration scope, not an upsell — indexation, coverage errors, redirect volume in the logs, field Core Web Vitals and analytics event parity, checked daily in week one and twice weekly after that. Beyond that window, ongoing maintenance is a separate fixed-scope arrangement rather than an open-ended retainer, because most sites need a defined piece of work rather than a monthly line item. Either way, the redirect map and inventory table are handed over as files you own.