Migrating a Marketing Site to Next.js Without Losing Rankings
Change the technology or the URLs, never both. The redirect map, the internal-link diff that put 38 of our own pages in “Discovered — not indexed”, and the 90 days after.
Rankings survive a framework migration when the URLs do. Keep every path identical and you can replace Webflow, WordPress or Gatsby with Next.js and see almost nothing in Search Console. Rankings drop when the migration quietly changes three things at once — URLs, internal linking, and what the page says — and nobody can tell afterwards which one did it.
We publish two comparisons that end with "so migrate" and neither explains how. This is that half: the redirect map, the things that are not the redirect map, and what to watch for ninety days afterwards.
The one rule
Change the technology or change the URLs. Never both in the same deploy.
Almost every horror story is a violation of this. A team rebuilds in a new framework, takes the opportunity to tidy /blog/2019/03/my-post into /blog/my-post, restructures the nav, rewrites the copy "while we're in there," ships it all on a Tuesday, and watches traffic fall 40%. Six weeks later they still cannot say why, because they changed four variables simultaneously.
If the URLs must change, that is a second, separate deploy — after the framework migration has been stable for a few weeks and you have a clean baseline to compare against.
Baseline first, because you cannot prove recovery without it
Before you write a line of the new site, export what you have. This takes an hour and it is the difference between "traffic looks fine" and knowing.
| Export | From | Why you need it |
|---|---|---|
| Every indexed URL | Search Console → Pages, plus a crawl | The source list for the redirect map |
| Top 200 pages by clicks and impressions | Search Console, 12 months | The pages whose ranking you are protecting |
| Top queries per page | Search Console | Detects a content regression, not just a 404 |
| Current titles and meta descriptions | Crawl | The most commonly lost thing in a rebuild |
| Backlinked URLs | Any backlink tool | These must never 404, even if orphaned |
| Core Web Vitals, field data | Search Console / CrUX | So you can tell a real improvement from noise |
Use a 12-month window, not 3 months — seasonal pages that rank in November will not appear in an August export, and they are exactly the ones that get dropped.
The backlink export matters more than the traffic export. A page with no traffic and four editorial links is passing authority to your whole site; delete it and you lose that permanently. Traffic is recoverable, links generally are not.
The redirect map
If URLs are staying identical — the good case — you still need this document, because it is how you prove they stayed identical.
old_url, new_url, status, notes
/pricing, /pricing, 200, unchanged
/blog/2019/03/deploy-faster, /blog/deploy-faster, 301, date removed
/features/analytics, /features#analytics, 301, merged into features page
/old-webinar, /resources, 301, no equivalent — nearest parent
Four rules that decide whether it works:
- 301, not 302. A 302 says "this is temporary, keep the old URL indexed." Google does eventually treat a long-lived 302 as permanent, but "eventually" is months of ambiguity you do not need.
- One hop.
/a → /b → /cis a chain, and each hop is a place to lose signal and a crawl budget cost. Redirect/a → /cdirectly, even if that means regenerating the map after a later change. - Redirect to the closest equivalent, never blanket to the homepage. A mass redirect to
/is treated as a soft 404 and passes nothing. If there is genuinely no equivalent, redirect to the nearest category page — and if there is not one of those either, let it 404 honestly. A clean 404 is better than a lie. - Every row gets tested. Not spot-checked. This is a script, and it is ten lines.
// scripts/verify-redirects.ts — run against production after deploy
import { readFileSync } from "node:fs";
const rows = readFileSync("redirects.csv", "utf8")
.trim()
.split("\n")
.slice(1)
.map((line) => line.split(",").map((cell) => cell.trim()));
let failures = 0;
for (const [oldPath, newPath, expected] of rows) {
const response = await fetch(new URL(oldPath, "https://example.com"), {
redirect: "manual",
});
const location = response.headers.get("location");
const actual = location ? new URL(location, "https://example.com").pathname : oldPath;
if (String(response.status) !== expected || actual !== newPath) {
console.error(`FAIL ${oldPath} → ${actual} (${response.status}), want ${newPath} (${expected})`);
failures++;
}
}
console.log(failures === 0 ? "All redirects OK" : `${failures} failures`);
process.exit(failures === 0 ? 0 : 1);
Run it against production the hour you deploy, and again a week later. The second run is not paranoia — redirects get clobbered by later config edits, and nothing tells you.
In Next.js the redirects themselves live in next.config.ts:
// next.config.ts
const nextConfig = {
async redirects() {
return [
{
source: "/blog/:year(\\d{4})/:month(\\d{2})/:slug",
destination: "/blog/:slug",
permanent: true, // 301
},
];
},
};
For more than a few hundred rules, generate them from the CSV at build time rather than hand-writing them, and prefer patterns over enumerations where the transformation is regular. A config with 4,000 literal redirect objects is slow to evaluate and impossible to review.
The things that are not the redirect map
Redirects get all the attention because they are the part with a checklist. In practice, sites that lose rankings after a clean migration usually lost one of these instead.
Internal linking
This is the one we got wrong on our own site, and it cost us real indexing.
Our storefront has 111 product pages. Each one closes with a rail of six sibling products — and the mechanism behind that rail is a wrapping sliding window over catalog order, so every product receives exactly six inbound links from its siblings. That sounds like an over-engineered detail. It is not: the obvious implementations ("newest six", "six random", "six most popular") all produce the same failure, where a long tail of products ends up linked from nothing. Ours did, and Search Console reported 38 product pages sitting in "Discovered — currently not indexed" — known to Google, never crawled, because nothing pointed at them hard enough to be worth the trip.
The same failure mode hides in a migration. The old site's nav, footer, related-posts block and breadcrumbs formed a link graph, and the rebuild reproduces the pages while quietly reproducing only 60% of the links. Crawl the old site and the new one, and diff the internal link counts per URL. Any page that lost most of its inbound links is a page that will drift out of the index over the next few months, with no error anywhere to tell you.
We also learned a related lesson about hub pages: our seven category pages were noindex while we finished them, which is the worst of both worlds. Google eventually treats a long-lived noindex page as nofollow too, so the tier built to funnel crawl equity into the products was withholding it instead. If your migration parks a section behind noindex, treat that as a deadline, not a state.
Content parity
"We rebuilt the pages" usually means the visual sections were rebuilt. The FAQ block at the bottom that nobody looked at contained 400 words targeting the long-tail queries that were half your traffic for that page.
Diff the rendered text, not the design. Pull the old page's body text and the new one's, and look at the word count and the headings. A page that went from 1,400 words to 600 has lost rankings for reasons no redirect can fix.
Metadata
Titles and descriptions are regenerated from a template in a rebuild, and templated titles are usually worse than the hand-written ones they replaced. Check the top 200 against your baseline export. Also check the things that are easy to forget entirely: canonical tags (self-referencing, absolute URLs), robots.txt, the XML sitemap, and structured data.
On sitemaps, one rule we enforce in our own build: lastModified must be a real date, never the build clock. Emitting new Date() for every URL on every deploy tells Google that 100+ pages changed simultaneously — a claim it can check and find false, and it costs the crawl scheduling that decides whether a page gets indexed at all. During a migration, when you most need Google to recrawl accurately, this matters more than usual. Our product URLs use their newest changelog date; category hubs use the newest among their products.
Performance, in the direction you did not expect
Migrating to a React framework can make a content site slower on mobile even when the desktop scores go up. We measured our own: desktop Lighthouse is 100 across all eight main pages, mobile is 89–99, and when we blocked every image and prefetch the home page's LCP moved from 3786 ms to 3740 ms. Forty-six milliseconds — the gap is JavaScript execution, with react-dom alone at 221 KiB raw / 70 KiB gzipped.
That will not by itself cost you rankings; Core Web Vitals are a modest signal and ours are fine. But do not assume a modern framework is automatically a performance win for a content site, and do not let "the new site is faster" go unmeasured in the post-migration report. If your migration is motivated by performance, the Astro comparison has the numbers on when that reasoning holds.
Launch sequence
The order matters, and two of these are commonly done too early.
- Staging is
noindexand behind auth. Both. Anoindexstaging site that is publicly reachable still gets discovered and linked. - Deploy with redirects live in the same release. Not "redirects to follow" — the window between them is when the 404s get crawled.
- Run the redirect verification script against production. Within the hour.
- Submit the new sitemap in Search Console. Keep the old sitemap available for a few weeks if URLs changed; it gives Google a list of old URLs to recrawl and discover the 301s.
- Do not remove the old site's DNS or hosting until redirects have been serving for at least a month.
- Use the Change of Address tool only for a domain change. It does not apply to a framework migration on the same domain, and using it when nothing moved is a way to confuse things that were fine.
- Request indexing for your top 20 pages manually. It is rate-limited and worth it.
The ninety days after
Recovery from a well-executed migration usually looks like a small dip for two to four weeks, then a return to baseline. Watch these, in this order:
| Signal | Where | What is normal | What is a problem |
|---|---|---|---|
| Crawl errors | Search Console → Pages | Brief rise, then decline | 404s still rising at week 3 |
| Indexed page count | Search Console → Pages | Flat, or small dip | Steady decline over weeks |
| "Discovered — not indexed" | Search Console → Pages | Stable | Growing — an internal linking problem |
| Impressions | Performance report | Dip 2–4 weeks, then recovery | No recovery by week 6 |
| Average position, top queries | Performance report | Small movement | Specific pages falling and staying down |
| Core Web Vitals, field | Search Console | Rolls over 28 days | Sustained regression |
Two calibrations. Field Core Web Vitals are a 28-day rolling window, so they will not reflect the migration for a month — do not panic at week one, and do not declare victory at week two either. And check every signal against a control: if impressions are down 15% across your whole industry that month, your migration is not the cause, and a competitor's ranking chart will tell you faster than your own.
Mistakes and how they show up
| Mistake | What happens | Fix |
|---|---|---|
| Changing URLs and framework together | Traffic drops, cause unattributable | Two separate deploys, weeks apart |
| 302 instead of 301 | Old URLs stay indexed, signals split | permanent: true in Next.js |
| Redirect chains | Signal decay, wasted crawl budget | Collapse to one hop |
| Blanket redirect to homepage | Treated as soft 404, passes nothing | Nearest equivalent, or an honest 404 |
| Rebuilding pages, not the link graph | "Discovered — not indexed" grows quietly | Diff internal link counts old vs new |
| Dropping "unimportant" pages with backlinks | Permanent authority loss | Check the backlink export first |
| Templated titles replacing written ones | Slow, broad ranking decline | Diff metadata against the baseline |
Sitemap lastModified = build time | Every URL claims to change each deploy | Emit a real content date |
| Staging indexed | Duplicate content competing with you | noindex and auth |
| Killing the old host at cutover | Redirects stop; everything 404s | Keep it a month minimum |
| Declaring success at week two | The dip is normal; so is the recovery | Judge at 6–12 weeks |
Frequently asked questions
How long until rankings recover after a migration? For a same-domain, same-URL framework migration, often no visible dip at all. Where URLs changed, expect a dip within days, partial recovery in two to four weeks, and stability by six to twelve. Anything still down at week twelve is not "settling" — it is a defect, and it is almost always a redirect that was missed, a page that lost its internal links, or content that did not come across.
Do I need to keep the old URL structure forever? No. Keep the 301s for at least a year. After that, Google has consolidated the signals into the new URLs and the redirects mostly serve human visitors and old links. Consolidating them into pattern rules is fine; deleting them entirely is a small permanent loss for anything still linked externally.
Does moving from WordPress to Next.js hurt SEO by itself? No. Google indexes rendered HTML and a statically rendered Next.js site is as crawlable as a PHP-rendered one. What hurts is what tends to travel with the move: a plugin's structured data, sitemap and redirect handling all disappear at once, and each has to be deliberately rebuilt. Inventory what your CMS was doing for SEO before you switch it off — that list is longer than most teams expect.
Should I migrate the blog at the same time as the marketing pages? If they share a domain and a URL structure, yes — a half-migrated site means two stacks, two deploy paths and two sets of metadata conventions, which is how drift starts. If the blog is on a separate subdomain or a hosted platform, treat it as its own migration with its own baseline and its own redirect map.
Is a domain change different from a framework change? Substantially. A domain change needs everything here plus the Search Console Change of Address tool, updated canonicals and internal links pointing at the new domain, and outreach to update your most valuable backlinks. Expect a longer dip. Do not combine it with a framework migration.
Migrating to Next.js? Start from a finished site
The fastest way to keep a migration to one variable is to not redesign while you rebuild — take a template that already matches the shape of your current site and port the content into it.
ASoc Zenith is an AI-led growth-marketing studio site with six services, case studies and partner badges — the multi-service structure most agency sites are migrating out of a builder. ASoc Amplify is a social-media-management SaaS site with a services grid, results stats and a journal section, so the blog tier comes with it rather than being bolted on afterwards. ASoc Seeker markets an AI keyword-research tool across a benefit grid, use cases and two pricing tiers.
All three ship statically rendered with per-page metadata, canonicals and structured data already wired. Browse the Next.js landing page templates or the Tailwind landing page templates. If you have not settled the framework question yet, Webflow vs Next.js and Astro vs Next.js are the two decisions this post picks up from.
