The premise
"Migrating off WordPress will tank our rankings" is a fear we hear weekly. It's well-founded — most migrations do tank rankings, because most teams treat the migration as a redesign and silently break the URL contract Google has been honoring for years. We've watched competitors lose 60-80% of organic traffic in a single botched cutover.
It doesn't have to. The seven-step playbook below preserves rankings because every step is calibrated against the existing SEO surface. We've used it on sitplay.media, seoagencybangkok.com, and 21 client sites. None of them lost rankings post-cutover. Several gained rankings within 4-8 weeks because CWV improvements + cleaner schema = better positioning.
Why migrate at all
Common reasons we hear, in order of frequency:
- Plugin sprawl is killing CWV (LCP > 4s on shared hosting)
- The team wants to ship faster — WP block editor frustrates non-technical authors
- Custom features keep requiring developer hours that should be self-serve
- Security incidents (XML-RPC abuse, plugin CVEs)
- The marketing brief now requires React-grade interactivity (configurators, live calculators)
If your reason is just "we want React" — don't migrate. WordPress with the right caching (see our Hostinger LiteSpeed notes) is a great tool. Migrate when WP is actively limiting you, not when it's just unfashionable.
Step 1: Full URL inventory
Before you write a line of Next.js, you build the URL inventory. Three sources, merged:
# 1. The current sitemap
curl -s https://example.com/sitemap.xml \
| xmllint --xpath "//*[local-name()='loc']/text()" - \
| sed 's| |\n|g' \
> urls-sitemap.txt
# 2. Google Search Console (Performance → Pages → Export)
# Save as urls-gsc.csv
# 3. Top organic URLs from Ahrefs / SEMrush over last 12 months
# Export → urls-organic.csv
Merge into a single canonical list, deduped, with metadata: url, monthly_clicks, monthly_impressions, top_query, last_modified. We use a simple Node script:
// scripts/build-inventory.mjs
import { readFileSync, writeFileSync } from 'node:fs'
import { parse } from 'csv-parse/sync'
const sitemap = readFileSync('urls-sitemap.txt', 'utf8').split('\n').filter(Boolean)
const gsc = parse(readFileSync('urls-gsc.csv'), { columns: true })
const organic = parse(readFileSync('urls-organic.csv'), { columns: true })
const inventory = new Map()
for (const url of sitemap) inventory.set(url, { url, source: 'sitemap', clicks: 0 })
for (const row of gsc) {
const e = inventory.get(row.url) ?? { url: row.url, source: 'gsc' }
e.clicks = +row.clicks; e.impressions = +row.impressions; e.top_query = row.query
inventory.set(row.url, e)
}
for (const row of organic) {
const e = inventory.get(row.URL) ?? { url: row.URL, source: 'ahrefs' }
e.organic_traffic = +row.Traffic
inventory.set(row.URL, e)
}
writeFileSync('inventory.json', JSON.stringify([...inventory.values()], null, 2))
console.log(`${inventory.size} unique URLs catalogued`)
For one recent client this surfaced 4,200 unique URLs when the sitemap claimed 1,400. The remaining 2,800 were paginated archives, tag pages, and orphan pages still bringing in 14% of organic traffic. Without this step you'd lose all of that on cutover.
Step 2: Build the new URL map
For each old URL, decide:
- Keep identical (most blog posts, top-priority pages)
- Redirect to consolidated page (thin tag/category archives → one hub page)
- Redirect to new structure (only if SEO benefit is clear — e.g., flat URLs)
- Soft 410 (truly dead pages with zero traffic and no inbound links)
Keep ratios sensible: ≥80% of URLs should map 1:1. If you're consolidating more than 20%, you're doing a redesign, not a migration. We pause and re-scope when this happens.
Step 3: Schema parity audit
WordPress (with Yoast or RankMath) emits a specific schema graph. The new Next.js site must emit at minimum the same schema, ideally improved. We diff schema before/after with this script:
// scripts/schema-diff.mjs
import { JSDOM } from 'jsdom'
async function extractSchema(url) {
const html = await fetch(url).then(r => r.text())
const dom = new JSDOM(html)
const scripts = dom.window.document.querySelectorAll('script[type="application/ld+json"]')
return [...scripts].map(s => JSON.parse(s.textContent)).flat()
}
const oldSchema = await extractSchema('https://example.com/post-slug')
const newSchema = await extractSchema('https://staging.example.com/post-slug')
const oldTypes = new Set(oldSchema.map(s => s['@type']))
const newTypes = new Set(newSchema.map(s => s['@type']))
const missing = [...oldTypes].filter(t => !newTypes.has(t))
if (missing.length) console.error('MISSING TYPES:', missing)
else console.log('OK schema parity')
Run this against 20 representative URLs before cutover. Any missing schema type is a regression. Our SaaS schema deep-dive covers the high-value types beyond the WP defaults.
Step 4: Build the 301 redirect map
Every URL from Step 1 that isn't 1:1 needs an explicit redirect rule in Next.js. We never use regex catch-alls in production — every rule is explicit and testable.
// next.config.mjs
import redirects from './redirects.json' with { type: 'json' }
export default {
async redirects() {
return redirects.map(({ from, to, permanent = true }) => ({
source: from,
destination: to,
permanent,
}))
},
}
// redirects.json (generated from Step 2 output)
[
{ "from": "/2023/04/old-post-slug", "to": "/blog/old-post-slug" },
{ "from": "/category/news", "to": "/insights" },
{ "from": "/?p=812", "to": "/blog/specific-post" }
]
For sites with thousands of redirects, Next's config-time redirects get slow. Move them to middleware reading from a KV store (Upstash, Vercel KV) — performance stays sub-millisecond per lookup.
Step 5: CWV calibration
The new site must beat the old site on every Core Web Vital before cutover. Not match — beat. Use web-vitals RUM on staging, deploy in real conditions, measure for 7 days minimum.
// app/layout.tsx — RUM beacon
import Script from 'next/script'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>{children}
<Script id="web-vitals" strategy="afterInteractive">{`
import('https://unpkg.com/web-vitals@4?module').then(({ onLCP, onINP, onCLS }) => {
const send = (m) => navigator.sendBeacon('/api/vitals', JSON.stringify(m))
onLCP(send); onINP(send); onCLS(send)
})
`}</Script>
</body>
</html>
)
}
The patterns from our Next.js 15 article apply — RSC by default, priority on LCP image, font preloading. We also pre-warm the cache on staging by hitting our top-200 URLs from the inventory before measuring.
Step 6: Cutover scripting
The actual cutover is dangerous. Window: 5-15 minutes during low-traffic hours. We run a tested script, never manual DNS edits at 2am.
#!/usr/bin/env bash
# cutover.sh — run from a checklist, with two engineers watching
set -euo pipefail
DOMAIN="example.com"
OLD_IP="123.45.67.89" # Hostinger
NEW_TARGET="cname.vercel-dns.com"
echo "1/7 → Final WP backup"
ssh wp@${OLD_IP} 'cd /var/www && tar czf /tmp/wp-final.tar.gz wp-content/'
scp wp@${OLD_IP}:/tmp/wp-final.tar.gz ./backups/
echo "2/7 → Lower TTL on DNS (we did this 24h earlier)"
echo "3/7 → Push final Next build"
vercel deploy --prod --token ${VERCEL_TOKEN}
echo "4/7 → Smoke-test top 50 URLs through new build"
node scripts/smoke-test.mjs
echo "5/7 → Cut DNS"
curl -X POST https://api.cloudflare.com/client/v4/zones/${ZONE}/dns_records/${REC_ID} \
-H "Authorization: Bearer ${CF_TOKEN}" \
-d "{\"type\":\"CNAME\",\"name\":\"${DOMAIN}\",\"content\":\"${NEW_TARGET}\",\"ttl\":300}"
echo "6/7 → Resubmit sitemap to GSC"
curl "https://www.google.com/ping?sitemap=https://${DOMAIN}/sitemap.xml"
echo "7/7 → Watchdog: monitor 404 rate for 2h"
node scripts/watch-404.mjs --threshold 1.5
The smoke test hits the top 50 URLs against the new origin (bypassing DNS) before we cut DNS. If any return non-200 or have a smaller HTML payload than the old, we abort.
Step 7: Post-cutover monitoring
The first 14 days post-cutover are when problems surface. Monitor:
- 404 rate via server logs. Should be < baseline within 2 hours.
- GSC Coverage report daily. Watch for "Crawled, not indexed" spikes.
- Organic clicks in GSC, by page. Drop >10% on any high-value URL is a red flag.
- Server response code distribution. If 301 ratio is climbing, your redirect map has gaps.
We also keep the old WordPress instance running on a subdomain (old.example.com) for 90 days. Three times in 23 migrations, we've needed to pull a piece of legacy content back.
Common mistakes we still see
1. Forgetting the trailing slash
WordPress defaults to trailing slashes. Next defaults to no trailing slash. Mix them and you double your indexed URLs and dilute rankings. Pick one in next.config.mjs:
export default {
trailingSlash: true, // match the WP default — almost always the right call
}
2. Forgetting feed URLs
WordPress emits /feed/, /comments/feed/, /post/feed/. Some are linked from external sites. Map them or let them 404 deliberately — but don't leave them as silent 200 dead pages.
3. Image URLs change
WP serves /wp-content/uploads/2024/01/foo.jpg. Next serves /_next/image?url=... or your CDN URL. External hotlinks from other sites break unless you redirect /wp-content/uploads/* to your new image origin.
4. noindex meta gets accidentally inherited
Yoast staging environments often emit noindex. If you copy meta logic naively, your production site will too. Always grep your render output for noindex before going live:
$ curl -s https://example.com/ | grep -i 'noindex'
# expect: empty
What success looks like
From our 23 migrations, the consistent pattern post-cutover:
- Week 1: Slight dip in impressions (5-10%) as Google re-crawls. Clicks usually flat.
- Week 2-4: Recovery. Impressions back to baseline or above.
- Week 4-8: Net gain of 8-22% on average from CWV improvements (matches what our SEO partner reports).
- Month 3+: Editorial velocity 2-3x because authors prefer the new tooling.
If you're considering a migration
The full migration takes us 12-18 days for content sites under 1,000 URLs (within our 15-day ship window for typical projects). Larger sites scale linearly. The cost is recouped within 6-9 months for most clients via CWV-driven ranking gains and reduced hosting/plugin maintenance.
Email umma@xx.gg with your sitemap URL and we'll send you a free migration risk assessment within 48 hours — same diagnostics we run internally before quoting. Or browse our case studies for examples of migrations we've completed.
migration wordpress 301-mapping nextjs schema-parity