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.

Plugin-heavy WP1460 KBStatic Next.js338 KBJSCSSFontsHTML
Illustrative page weight for the two setups, excluding images. The point is the proportion, not the exact totals: on the WordPress side most of the payload is JavaScript and CSS that individual plugins added, and almost none of it is the content itself. Measure your own site with the Network panel before you quote any number.

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:

  1. 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.
  2. 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.
  3. 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.
  4. 04The sitemap still lists old URLs. You point crawlers at pages that no longer exist while the new ones go undiscovered.
  5. 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.
terminalbash
# 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.txt

Now 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.

redirects.jsonjson
[  { "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" }]
next.config.tstypescript
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.

scripts/check-redirects.mjsjavascript
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.

terminalbash
# Yoast keys. RankMath uses rank_math_title and rank_math_description.wp post meta list <id> --keys=_yoast_wpseo_title,_yoast_wpseo_metadesc --format=json
app/blog/[slug]/page.tsxtypescript
export 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 portWhere it lives in WordPressWhere it goes in Next.js
SEO title_yoast_wpseo_titlemetadata.title
Meta description_yoast_wpseo_metadescmetadata.description
Canonical URL_yoast_wpseo_canonicalmetadata.alternates.canonical
Noindex flags_yoast_wpseo_meta-robots-noindexmetadata.robots
Featured image_thumbnail_idmetadata.openGraph.images
Publish datepost_date_gmtopenGraph.publishedTime and schema
The fields that change rankings if you drop them. Meta keys shown are Yoast's; RankMath and SEOPress use their own prefixes.

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.

TTFB780ms90msLCP3400ms1200msJS shipped820KB180KBWordPressStatic Next.js
Illustrative figures showing the shape of the change, not a measurement of a specific site. Time to first byte drops because no server assembles the page per request. Run your own before and after in Search Console's Core Web Vitals report and quote those numbers instead of these.

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 numbers

If 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 end

Resources