Most migrations lose traffic for one reason. Someone changed the URLs and nobody wrote the redirects. The framework had nothing to do with it.
I get asked to do this often enough that I have a fixed order of operations. It is not complicated, but skipping any step costs you rankings that take months to earn back. So here is the whole thing, in the order I actually run it.
Why people move off WordPress
The reason is almost never that WordPress cannot do the job. It is that a site has collected eleven plugins over four years, three of them inject their own CSS on every page, and nobody remembers which one the contact form depends on. Page weight creeps up. Time to first byte creeps up. Editing still works fine, so nobody notices until traffic starts sliding.
A Next.js build changes the delivery model. Pages get rendered once at build time and served as static files, so a visitor gets HTML from a CDN edge instead of waiting for PHP to assemble a page and query MySQL. That is the whole trick. There is no magic in the framework, only in doing less work per request.
What actually breaks a migration
Traffic drops after a migration for a small number of reasons, and they repeat. In rough order of how much damage they do:
- 01URLs changed silently. The old site used
/2024/03/my-post/and the new one uses/blog/my-post/. Every link Google has stored now returns a 404. - 02Redirects chain or loop. An old URL 301s to a second URL that 301s again. Each hop loses a little, and a loop loses the page entirely.
- 03Metadata was not ported. Yoast or RankMath held hand written titles and descriptions in post meta. A fresh build generates its own, and suddenly every title is different from the one that was ranking.
- 04The sitemap still lists old URLs. You point crawlers at pages that no longer exist while the new ones go undiscovered.
- 05Content got trimmed in the rebuild. Someone decides the old post is too long and cuts it to a summary. That page was ranking because of the length.
Notice that four of those five have nothing to do with Next.js. They are content and routing problems. The framework only shows up in the fifth, and only because a rebuild tempts people to rewrite things.
Step one: freeze your URLs
Before writing any code, get the full list of live URLs out of the old site. Do not trust the sitemap alone, because it usually misses attachment pages, old category archives and anything a plugin generated.
Pull from three sources and combine them:
- The database, for everything published. WP-CLI gives you this in one command.
- Search Console, for what Google actually has indexed. Export the Pages report.
- Your analytics, for the last twelve months of landing pages. This catches URLs that still earn traffic even though you forgot they existed.
# Every published URL, straight from WordPresswp post list \ --post_type=post,page \ --post_status=publish \ --format=csv \ --fields=ID,post_name,post_date,guid > wp-urls.csv # Resolve the real permalinks, since post_name is not the full pathwp post list --post_type=post,page --post_status=publish --format=ids \ | tr ' ' '\n' \ | xargs -I{} wp post url {} > permalinks.txtNow decide, URL by URL, what stays and what moves. My default is that everything stays. A dated permalink structure is ugly, and I still keep it, because the cost of changing it is real and the benefit is cosmetic. Change URLs only when you have a reason that survives the question "is this worth losing rankings for a month?"
Step two: build the redirect map
For anything that does move, you need a 301. A 301 tells search engines the move is permanent and passes the ranking signals to the new URL. A 302 says the move is temporary and holds the signals at the old address, which is not what you want.
Keep the map as data, not as hand written config. Then generate the config from it. That way one file is the source of truth and you can test against the same file.
[ { "from": "/2024/03/moving-to-nextjs/", "to": "/blog/moving-to-nextjs" }, { "from": "/?p=1042", "to": "/blog/moving-to-nextjs" }, { "from": "/category/engineering/", "to": "/blog" }, { "from": "/about-us/", "to": "/about" }]import type { NextConfig } from 'next';import redirects from './redirects.json'; const nextConfig: NextConfig = { async redirects() { return redirects.map(({ from, to }) => ({ source: from, destination: to, permanent: true, // 308, which search engines treat like a 301 })); },}; export default nextConfig;Test the map before you launch
This is the step people skip, and it is the one that catches chains and loops. Write a script that walks every entry in the map and checks two things: the old URL returns a redirect, and following it lands on a 200 in one hop.
import redirects from '../redirects.json' with { type: 'json' }; const BASE = process.env.BASE_URL ?? 'http://localhost:3000';let failures = 0; for (const { from, to } of redirects) { // Do not follow automatically. We want to see each hop. const res = await fetch(BASE + from, { redirect: 'manual' }); const location = res.headers.get('location'); if (res.status !== 301 && res.status !== 308) { console.error('NOT REDIRECTED ' + from + ' got ' + res.status); failures++; continue; } const landed = new URL(location, BASE); if (landed.pathname !== to) { console.error('WRONG TARGET ' + from + ' -> ' + landed.pathname); failures++; continue; } // One more hop to prove the destination is not itself a redirect const final = await fetch(landed, { redirect: 'manual' }); if (final.status !== 200) { console.error('CHAIN OR 404 ' + to + ' got ' + final.status); failures++; continue; } console.log('ok ' + from + ' -> ' + to);} console.log('\n' + (redirects.length - failures) + '/' + redirects.length + ' passing');process.exit(failures ? 1 : 0);Wire that into your deploy so a broken redirect fails the build. It takes ten minutes to set up and it removes an entire category of post launch panic.
Step three: carry the metadata across
Yoast and RankMath store their titles and descriptions in the wp_postmeta table. Those strings were often written by hand and tuned over years. Export them with the content and map them onto the Next.js Metadata API instead of generating fresh ones.
# Yoast keys. RankMath uses rank_math_title and rank_math_description.wp post meta list <id> --keys=_yoast_wpseo_title,_yoast_wpseo_metadesc --format=jsonexport async function generateMetadata({ params }) { const { slug } = await params; const post = await getPost(slug); return { // Fall back to the post title only when no hand written one exists title: post.seoTitle || post.title, description: post.seoDescription || post.excerpt, alternates: { canonical: `https://example.com/blog/${post.slug}` }, openGraph: { type: 'article', title: post.seoTitle || post.title, description: post.seoDescription || post.excerpt, publishedTime: post.date, }, };}| What to port | Where it lives in WordPress | Where it goes in Next.js |
|---|---|---|
| SEO title | _yoast_wpseo_title | metadata.title |
| Meta description | _yoast_wpseo_metadesc | metadata.description |
| Canonical URL | _yoast_wpseo_canonical | metadata.alternates.canonical |
| Noindex flags | _yoast_wpseo_meta-robots-noindex | metadata.robots |
| Featured image | _thumbnail_id | metadata.openGraph.images |
| Publish date | post_date_gmt | openGraph.publishedTime and schema |
Generate the sitemap from the same data that generates the pages. If those two ever come from different sources, they will disagree eventually, and you will not notice until Search Console tells you.
Step four: prove it worked
Launch is not the end. For the first month you are watching for two things: crawl errors and ranking movement. Both live in Search Console.
- Submit the new sitemap the day you launch. Leave the old one in place if it still resolves, because crawlers will use it to find URLs that need redirecting.
- Watch the Pages report for a spike in
Not found (404). Every entry there is a URL missing from your redirect map. Add it and redeploy. - Compare Performance data over the same length of window before and after, not week against week. Seasonality will lie to you otherwise.
- Expect a small dip for one to two weeks while Google recrawls. A dip that keeps deepening past a month is a problem, not a settling period.
What you gain
Assuming you did the routing properly, the payoff shows up in field data rather than lab scores. Static HTML from an edge cache removes server render time from every request, which mostly moves your Largest Contentful Paint and your time to first byte.
The part that does not show up on a chart is maintenance. There is no plugin auto update that can take the site down at 2am, and no PHP version bump to schedule. That matters more than most performance numbers over a two year window.
Related readingCore Web Vitals for content sites: what actually moves the numbersIf you want the speed without giving up the WordPress editor, you do not have to choose. Point Next.js at the WordPress REST API and keep both.
Related readingKeep the WordPress editor, drop the WordPress front endResources
- Google Search Central: redirects and Google Search
What each redirect type signals, and how Google treats 301, 302 and 308.
- Google Search Central: site move with URL changes
The official checklist for a move that changes URLs.
- Next.js: redirects in next.config.js
Source and destination patterns, including wildcards and regex.
- Next.js: generateMetadata
Every metadata field the App Router supports.
- WP-CLI post commands
For exporting URLs, content and post meta in bulk.