WordPress to Next.js Migration Without Losing Rankings
Rankings survive a WordPress to Next.js migration when every legacy URL keeps a single 301 hop to a page with the same title, H1, canonical and server-rendered body copy — which you prove with a crawl diff on staging before DNS moves, not after.
- Migration
- Next.js
- WordPress
- Technical SEO
- Redirects
Why do most WordPress to Next.js migrations lose rankings?
Not because you swapped PHP for React. Because six signals change on one day and nobody diffs them: the URL, the redirect path to it, the internal links pointing at it, the title and H1, whether the body copy is in the HTML, and how fast the server answers.
- Redirect chains. The old URL 301s to a slug that 301s to a trailing-slash variant that finally 200s. Google follows it; little else does. Redirect testing found OAI-SearchBot and Claude-SearchBot abandon a chain at three hops, GPTBot and PerplexityBot at five, Googlebot at ten (CaptainDNS, 2026).
- A blanket redirect to the home page. A redirect to a page that is not a close equivalent is treated as a soft 404: the destination inherits nothing, the source is dropped. Google says so in its site move documentation.
- Internal links that quietly vanished. A theme ships hundreds nobody counted — related-post blocks, archives, sidebars, breadcrumbs, pagination. A rebuild ships the links a designer drew. Pages that received twenty now get two, and rank accordingly.
- Rewritten titles and H1s. Change every title pattern on the day you change every URL and you will never know which change moved what.
- Body copy that only exists after hydration. Googlebot renders JavaScript; almost nothing else does. Instrumented testing found GPTBot downloading JavaScript on roughly 11.5% of requests and ClaudeBot on 23.8%, and never executing it (SearchOptimo, 2026). Copy fetched on mount is copy Bing will not read.
- Slow TTFB on a cold cache. WordPress behind a page cache answers in milliseconds; a route that revalidates on demand and calls a CMS on a cold instance takes seconds, and crawl stats record it as rising average response time.
None of that shows up in a design review. All of it shows up in a crawl diff — which is why I treat a replatform as a migration project.
How do you build a complete URL inventory first?
Take the union of six sources, not one. The sitemap lists what WordPress wants you to know about; the inventory lists what earns something. On every migration I have run, the sitemap was the smallest of the six.
- Search Console → Performance → Pages, over the full 16 months. Your ranked URL list and your before-baseline: clicks, impressions, position, CTR.
- Search Console → Page indexing, every status, including Crawled — currently not indexed and Duplicate without user-selected canonical. Google knows these; your sitemap does not list them.
- Every sitemap WordPress publishes. Yoast and Rank Math split post, page, category, tag, author, product and attachment indexes. Walk
/sitemap_index.xml; never trust one file. - A full crawl with Screaming Frog or Sitebulb: JavaScript off, nofollow followed, no limit. Export the internal-links report too — you need the link graph, not just addresses.
- Server logs, 30 to 90 days, filtered to Googlebot and Bingbot. The only source that finds URLs with no inbound link and no sitemap entry still being crawled.
- Search Console → Links → Top linked pages. External links point at URLs your crawler never reaches, and those redirects matter most.
Normalise the six into one sheet keyed on path, deduplicate case and trailing slash, and record per URL: status code, canonical, 16-month impressions, inbound internal links. That sheet is the migration.
How do you build a 1:1 301 map that never chains?
One legacy URL, one rule, one hop, one 200. That is the whole specification. It is hard only because WordPress emits URL shapes a hand-written map never anticipates.
/?p=123and/?page_id=45— permalink-independent forms that still resolve, and still sit in old emails and CRM templates./category/…,/tag/…,/author/…— archives that often hold rankings and have no obvious equivalent after a rebuild./feed/,/comments/feed/,/wp-json/…,/xmlrpc.php— machine endpoints external services keep polling./some-post/amp/— indexed if the AMP plugin was ever active, whether or not anyone remembers it.- Query strings:
?replytocom=, WooCommerce?add-to-cart=, Elementor?elementor-preview=. The orphan redirects — 200 on WordPress, silent 404 afterwards. /wp-content/uploads/2019/06/photo.jpgand attachment pages like/some-post/photo/— image rankings and thin indexed URLs.
Then the trailing slash. WordPress serves /about/; Next.js serves /about and 308s the slashed form. Map only the un-slashed source and every old inbound link earns a two-hop chain. Map both at the final destination, and test both with curl -I.
// next.config.ts — Next.js 16
import type {NextConfig} from 'next';
// Generated from the inventory sheet by a script, never typed by hand:
// { '/2019/06/old-post-slug': '/blog/new-post-slug', ... }
import {LEGACY_URLS} from './src/lib/legacy-urls';
const exact = Object.entries(LEGACY_URLS).flatMap(([from, to]) => [
// permanent: true emits 308. Use statusCode when you want the literal 301
// that old logs, analytics filters and link checkers expect — Google and
// Bing treat 301 and 308 identically, but your tooling may not.
{source: from, destination: to, statusCode: 301},
// WordPress served the trailing-slash form. Send it straight to the final
// destination so an old inbound link resolves in ONE hop, not two.
{source: `${from}/`, destination: to, statusCode: 301}
]);
const nextConfig: NextConfig = {
// Decide the slash policy once, here, so nothing adds an implicit hop.
trailingSlash: false,
async redirects() {
return [
// Exact matches first: a specific rule must always beat a pattern.
...exact,
// Shapes, not pages.
{source: '/category/:slug', destination: '/blog/topic/:slug', statusCode: 301},
{source: '/:path*/amp', destination: '/:path*', statusCode: 301},
{source: '/feed', destination: '/rss.xml', statusCode: 301},
// Image URLs carry Google Images rankings of their own. They have file
// extensions, so they never reach proxy.ts — handle them here.
{
source: '/wp-content/uploads/:path*',
destination: '/media/:path*',
statusCode: 301
}
];
}
};
export default nextConfig;Past roughly a thousand exact rules, move the lookup into proxy.ts — the file Next.js 16 renamed from middleware.ts. A hash-map lookup is constant time, and it is the only sane place to resolve ?p=123, because that mapping is data rather than a pattern.
// src/proxy.ts — Next.js 16 renamed middleware.ts to proxy.ts.
import {NextResponse, type NextRequest} from 'next/server';
import {LEGACY_URLS, LEGACY_POST_IDS} from '@/lib/legacy-urls';
export function proxy(request: NextRequest) {
const {pathname, searchParams} = request.nextUrl;
// /?p=123 and /?page_id=45 — the ID form WordPress never stops serving.
const id = searchParams.get('p') ?? searchParams.get('page_id');
const byId = id ? LEGACY_POST_IDS[id] : undefined;
// Slash-insensitive key so /about/ and /about hit the same entry, and both
// land on the destination in a single hop.
const key = pathname.length > 1 ? pathname.replace(/\/$/, '') : pathname;
const destination = byId ?? LEGACY_URLS[key];
if (destination) {
return NextResponse.redirect(new URL(destination, request.url), 301);
}
return NextResponse.next();
}
export const config = {
// Never run the map over assets or route handlers: a redirect rule that
// catches /_next/static is an outage, not a ranking problem.
matcher: ['/((?!api(?:/|$)|_next/|_vercel/|.*\\..*).*)']
};next.config.ts.How do titles, canonicals and schema come across without drift?
As an export, not a rewrite. Yoast and Rank Math store per-post titles and descriptions in wp_postmeta, reachable through the REST API or one SQL query. That export becomes a fixture the build reads, so generateMetadata returns the same string the old page returned, character for character. Improve the copy afterwards, when the effect is attributable.
- Set `metadataBase` in the root layout to the absolute production origin. Without it, canonicals and Open Graph URLs resolve against whatever host is serving — on staging, that means canonicals pointing at the staging domain. The classic way to deindex yourself an hour after launch.
- Canonicals must be absolute, self-referential and on the host you 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 both ways. Whatever was
noindexstaysnoindex; more urgently, nothing else becomes it. A staging-widenoindexshipped to production is the most common single cause of total traffic loss. - Structured data must match or improve. Yoast emits an
@graphwithOrganization,WebSite,WebPage,BreadcrumbListandArticlenodes. Ship onlyOrganizationand you have dropped breadcrumb rich results sitewide. Diff old against staging in the Rich Results Test. - hreflang, if the site has it. Every alternate reciprocal, with a self-reference and
x-default.
What happens to internal links, images and alt text?
They get rewritten at build time through the same map that drives the redirects, so no internal link points at a URL that has to be redirected. Extract every <a href> from post_content during the export, resolve each through the map, rewrite it, then re-crawl staging and assert internal 3xx responses are zero. A link with no mapping is a mapping bug — fix the map, not the link.
Then compare link counts, not just validity. Join the staging inbound-links report to the same report from the live site. A URL that had twenty internal links and now has one has lost the thing that made it rank, and no redirect gives that back. Delete category and tag archives and you must replace the links they carried — a topic hub, a related-content block, a footer directory. Choosing not to is valid. Not noticing is not.
Images carry two assets: the ranking of the image URL in Google Images, and the alt text describing the page subject. Keep /wp-content/uploads/… reachable or 301 it to the new asset URLs. Pull alt text from the _wp_attachment_image_alt meta key rather than retyping it, and set explicit width and height on every next/image so you do not trade a ranking problem for a layout-shift one.
How do you prove parity on staging before DNS moves?
Crawl both sites and diff them column by column: same crawler, same user agent, same rendering setting, HTTP auth for staging. Join the exports on the mapped URL pair. The output is one row per URL with a delta per column, and every non-zero delta is fixed or explained.
| Signal | Required state after migration | How it is verified |
|---|---|---|
| HTTP status | 200 after exactly one hop | Scripted curl over the whole map |
| Title tag | Identical string from generateMetadata | Crawl diff, Title 1 |
| Meta description | Identical, or changed and logged | Crawl diff, Meta Description 1 |
| H1 | Same text, exactly one per page | Crawl diff, H1-1 and H1 count |
| Canonical | Absolute, self-referential, production host | Crawl diff plus URL Inspection |
| Indexability | Unchanged; no staging-wide noindex | Crawl filter on Indexability |
| Rendered words | Within ~5% with JavaScript off | Crawl staging twice, JS on and off |
| Structured data | Same node types, zero errors | Rich Results Test, old against staging |
| Internal links in | No page loses links undecided | Join crawls on inbound-link count |
| Images and alt | URL reachable, alt string identical | Crawl image report, diff alt column |
| Sitemap entries | All destinations, no redirects | Assert 200 on each entry |
#!/usr/bin/env bash
# One hop, one 200, correct destination — for every row in the map.
# redirect-map.csv holds: old_path,new_path
# A URL whose path does not change should not be in the map at all.
BASE=https://staging.example.com
while IFS=, read -r old new; do
read -r status hops final <<<"$(curl -sS -o /dev/null -L \
-w '%{http_code} %{num_redirects} %{url_effective}' "${BASE}${old}")"
if [ "$status" != "200" ] || [ "$hops" != "1" ] || [ "${final%%\?*}" != "${BASE}${new}" ]; then
printf 'FAIL %-44s status=%s hops=%s landed=%s\n' "$old" "$status" "$hops" "$final"
fi
done < redirect-map.csvWhat does the launch-day runbook look like?
- 1
T-24h — drop the DNS TTL, freeze content
TTL to 300 seconds so rollback takes minutes. Anything published after the final export does not exist on the new site.
- 2
T-1h — re-run the parity diff
Content and code have both moved since the last run. Assume nothing you have not re-measured today.
- 3
T-0 — cut over, verify from outside
Fetch 50 of your highest-impression legacy URLs from a machine that has never seen the site and assert one hop to a 200 — not from the browser you tested in all week.
- 4
T+15m — robots.txt, sitemaps, firewall
Confirm production
robots.txtis not the staging deny-all, submit new sitemaps in Search Console and Bing Webmaster Tools, and put the host bot rules in log-only mode. Anagram crawlability data from 2026 found 17.6% of top sites allowing GPTBot in robots.txt return 403 to it in practice, and a managed WAF ruleset is how that happens. - 5
T+1h — leave the old sitemap live
Keep serving it with the old URLs for a few weeks if you still control it. Google site-move guidance says this speeds discovery of the redirects.
- 6
T+2h — analytics, tags, conversions
GA4, GTM and ad pixels firing on the new templates with the same event names. Tracking parity is part of migration parity.
- 7
T+24h — first crawl-stat read
Search Console → Settings → Crawl stats. Rising successful requests, no spike in 404s or 5xx, response time in the pre-launch range.
Deliberately absent: the Change of Address tool. It applies only when the domain itself changes.
What do you monitor for 30 days after launch?
Daily for week one, then twice weekly, always against the baseline you exported before launch. The job is separating normal re-crawl wobble from a real problem.
- Page indexing. Watch reasons, not totals. Page with redirect rising is correct — Google is working through your map. Not found (404) rising means a URL shape you missed. Duplicate, Google chose a different canonical means canonicals and redirects disagree.
- Crawl stats. Average response time is your TTFB signal. If it climbs, revalidation is making crawlers pay for cache misses; move expensive routes to static generation.
- Performance, page by page against the 16-month export. Compare each new URL with its mapped predecessor, never with the site total — totals hide a page that lost everything behind a page that gained.
- Bing Webmaster Tools. Verify the property before launch or you spend the critical week blind. URL Inspection on ten mapped URLs, resubmit the sitemap on the correct host, and read the AI Performance report Microsoft launched on 9 February 2026 — the only first-party data on answer-engine citations.
- Logs again. Bot hits by status code by day. A 301 still firing thousands of times a week after month one means something of yours still links at old URLs.
A dip in weeks one and two is normal while the map is reprocessed. What is not normal is impressions recovering while average position stays worse than baseline: the URLs were found, but the pages are judged differently. That points at content, title and H1 changes rather than redirects.
What did this look like on a real migration?
The migration I refer to most is not WordPress. It is the PHP Laravel monolith behind Thrifty in the UAE, which I moved to Next.js as Senior Developer at Thrifty Car Rental UAE. In the same period the .NET backend behind Dollar was replaced by six shared Node.js microservices. Different source stack, identical procedure, and the reason I trust it: no ranking loss.
The parts that did the work were unglamorous. The inventory came from Search Console, the platform sitemaps and server logs — the logs surfaced query-string URLs the old application had served quietly for years. Redirects were generated from that sheet, not hand-written. Metadata came across as an export, so titles were identical on day one. The launch gate was the parity diff with a written list of allowed deltas. The same method sits under Cariva, 326 server-rendered SEO pages — server-rendered so the copy is in the HTML for Bing and for crawlers that never run JavaScript.
If you want this run on your site, it is the SEO-safe migration and rebuild service, and most of what Next.js development means in practice. Every project starts with an introductory project call: I crawl what you have and tell you where the risk sits. Send me the domain.
What should you know about how I work on this?
That is the design goal, and it is a method rather than a promise — nobody can guarantee a search ranking. What I commit to is the procedure: a complete URL inventory from Search Console, sitemaps, a full crawl and server logs; a 1:1 301 map with no chains and no catch-all to the home page; metadata, canonical and schema parity verified by a crawl diff on staging; and 30 days of monitoring in Search Console and Bing Webmaster Tools afterwards. That method carried Thrifty’s Laravel to Next.js migration across with no ranking loss.
For a typical content site, six to twelve weeks end to end, and page count is a poor predictor. Template count, plugin dependencies and how much traffic sits on URL shapes needing individual handling are what drive the timeline. A 60-page brochure site with five templates is faster than a 400-page site with fifteen. The inventory and redirect map are usually two weeks on their own, and they come first, because the map determines what the build has to produce. Content freeze and launch sit in the final week.
Only if you keep a CMS, and that decision belongs at the start of the project rather than the end. For a content-heavy site the usual answer is headless WordPress: the editors, roles and workflow you already know stay exactly as they are, and Next.js renders the front end. Where content changes a few times a year, a Git-based or typed-content approach is cheaper to run. If nobody scopes this, the migration ends with a fast site marketing cannot edit — a worse outcome than the WordPress site you started with.
No, and be wary of anyone who does. Results move for reasons unrelated to your site: algorithm updates, competitors publishing, seasonality. What is controllable is every technical cause of a migration-driven drop, and those are the ones engineered out — redirect chains, lost internal links, missing metadata, JavaScript-only body copy, slow first-byte times. You get the parity diff before launch and the baseline comparison after it, so if something does move, you can see whether the migration caused it.
Look at what is actually failing. If the site looks dated but loads fast, ranks and converts, that is a design problem, and a replatform is an expensive way to solve it. Rebuild when the platform itself is the constraint: Core Web Vitals that will not pass because of theme and plugin weight, a plugin stack that breaks on every update, security exposure, or content the framework cannot render server-side. A crawl plus a field-data check answers this in about an hour, which is what the project discussion covers.
Stores migrate too, with far more URL surface to inventory. Product, category and filtered URLs all need explicit handling, and WooCommerce query strings such as add-to-cart and filter parameters are the classic orphan redirects that return 200 on WordPress and 404 silently afterwards. Product schema, variant canonicals and pagination need parity like everything else. The bigger decision is where commerce logic lives after the move — a headless commerce backend or a rebuilt checkout — and that changes scope far more than the redirect map does.
Yes. The source platform changes the export mechanics and nothing else. The inventory comes from Search Console, sitemaps, a crawl and server logs regardless of what generated the HTML, and the parity diff compares rendered output rather than source code. The Laravel migration behind Thrifty, and the .NET backend replacement behind Dollar, both used exactly the process described here. The thing worth checking early on any legacy stack is which URL shapes it emits that nobody documented — that is what log analysis is for, and where most surprises live.