Your rebuild will drop URLs. Google will tell you in six weeks.
A framework migration is where sites quietly lose their search history. We moved our own 10,723-page site to Next.js 16 in a week. This is the check that proved not one URL moved, the four things that check cannot see, and the failure that mattered more than any of it.
6 September 2026
Check it yourself. curl the sitemap before and after, then diff the URL sets
Nobody rebuilds a site intending to lose its URLs. It happens anyway, and it happens in a specific way: a slug gets tidied because the new one reads better, a template gets a cleaner path, a category folder is dropped because the new router does not need it. Every one of those decisions is defensible on its own. Together they are why traffic falls off a cliff six weeks after a launch nobody thought was risky.
The failure is silent
A dropped URL does not throw. Nothing in the build fails, no test goes red, and the new site looks correct — because it is correct, considered on its own terms. The old address simply stops existing, and the only party who notices is a crawler that will not tell you for weeks.
That delay is the whole problem. By the time the graph moves you have shipped a fortnight of other changes, and the question "which of these did it?" has no cheap answer. The fix has to happen before the deploy, or it costs ten times as much afterwards.
Why "keep the URLs" is not a plan
Everyone says it. On a twenty-page marketing site it even works, because twenty is a number a person can hold in their head and check by eye.
Our site has 10,723 pages generated from six templates. Nobody is eyeballing that. And the templates are exactly where it goes wrong: change one line in the function that builds a conversion slug and 9,045 URLs move at once. The blast radius of a one-character edit is the entire family.
So "keep the URLs" needs to become something a machine asserts. There are two halves to that, and they are not the same job. Prevention is a test in the build that fails when a URL, title or H1 changes — that lives in the application and it is what we wrote first. Verification is proving, from outside, that the site which actually went live kept them. This piece is about the second, because it is the half you can run against a site you did not build.
The check itself
It is deliberately unclever. Every site that cares about search already publishes an enumeration of its own URLs, and it is called a sitemap. Take it before the deploy and again afterwards.
1# Before the deploy, and again after it. Nothing clever —
2# the sitemap is the list of URLs you already publish.
3curl -s https://example.com/sitemap.xml > before.xml
4
5# ...deploy...
6
7curl -s https://example.com/sitemap.xml > after.xmlThen compare the sets. Not the counts — the sets. A count that matches can still hide a page dropped and another added, which is the case that hurts most, because the total looks reassuring.
1import re, sys
2
3def urls(path):
4 xml = open(path, encoding='utf-8', errors='ignore').read()
5 return set(re.findall(r'<loc>([^<]+)</loc>', xml))
6
7before, after = urls(sys.argv[1]), urls(sys.argv[2])
8
9print('before:', len(before))
10print('after: ', len(after))
11print('gone: ', len(before - after))
12
13# The count is reassurance. The list is the answer.
14for u in sorted(before - after):
15 print(' DROPPED', u)Three lines of output and a list. The list is the part that matters: it names the URLs that existed before the deploy and do not exist after it, which is the question everyone asks two months too late.
What it returned
We took the first snapshot on the morning of 6 September 2026 and the second after the deploy the same day.
10,722 URLs before, 10,723 after, nothing dropped. All 9,045 conversion pages survived a framework change, a rendering change and a routing change intact.
That is the claim, and it is worth being precise about what earns it. Not the intention to keep the URLs — everyone has that. The build test stopped them moving and the diff proved they had not, and the second one is checkable by a stranger.
What a diff does not catch
This is the part usually left out, and it is the reason a passing diff should not make anyone relax. A URL is one of at least six things a rebuild moves, and the sitemap only knows about one of them.
- The URL/convert/EST_to_FJTa diff catches this
- The page titlewhat the result shows in searcha diff does not
- The H1the heading Google matches against the querya diff does not
- The canonicalwhich version of the page countsa diff does not
- Internal linkshow authority moves between pagesa diff does not
- The redirect mapwhat happens to anything that did movea diff does not
A page can keep its address and lose its title, its H1, its canonical, and every internal link that pointed at it. The diff returns zero and the rankings still go. Our build test covers titles and H1s as well for that reason; the canonical and the internal link graph are still guarded by nothing but attention, which is not a guarantee and should not be described as one.
Where this breaks
The sitemap has to be generated from the same source as the routes. If it is written by hand, or generated by a separate job, it can happily describe a site that no longer exists and the diff will confirm a fiction. Ours is generated from the same data that builds the pages, which is what makes it usable as evidence.
A URL in the sitemap can still be broken. Present in the file, 404 at the server: the diff passes. So sample the survivors and check what they actually return.
1# A URL can survive the diff and still be broken: present in the
2# sitemap, 404 at the server. Sample the survivors.
3shuf -n 40 <(grep -o '<loc>[^<]*' after.xml | sed 's/<loc>//') |
4 xargs -P 8 -I{} sh -c 'printf "%s %s\n" "$(curl -s -o /dev/null -w %{http_code} "{}")" "{}"' |
5 grep -v '^200' || echo "all sampled URLs returned 200"Pages outside the sitemap are outside the check. Anything you never listed — old campaign URLs, PDFs, pages that were only ever linked from an email — is invisible here. For those, the equivalent snapshot is a Search Console export of pages with impressions, taken before the work starts.
And none of it matters if the site is not up. Ours was hard-down for about a week shortly before this migration — not a 503, which tells a crawler to come back, but a dead socket, which tells it nothing. Every URL was preserved perfectly throughout, and preserved URLs on an unreachable server are worth exactly nothing. We fixed the failure mode afterwards: nginx now serves a maintenance page from disk with a 503 and a Retry-After, so an outage looks like an outage rather than an absence.
It is the more useful lesson of the two. A migration that keeps every URL is a craft problem with a known solution. Availability is an operations problem, and it is the one that actually cost us.
Run it on your own migration
The whole method is two curls and twelve lines of Python, and it works on any site with a sitemap — including one you are being asked to take over from another agency. Take the snapshot before you touch anything; it is worthless afterwards, and afterwards is when you will want it.
You can check ours the same way. The sitemap is public, split by template family, and the conversion pages are the ones to look at: timezones.in/sitemap.xml. The longer write-up of that site — what it is, what it cost, what else went wrong — is in the project page.
Ready to Build Something
That Actually Works?
Stop patching legacy code. Let's engineer a platform that scales with your ambition.