Moving from WordPress to a headless stack like Sanity + Next.js usually gets you faster pages, a cleaner editing experience and more control over the front end. The risk is that a badly planned migration can wipe out years of SEO work in a few days.
This post is the checklist I'd follow to move a content-heavy WordPress site to Sanity and Next.js without losing rankings. It covers the order of work, the code for the risky parts, and the mistakes that cause traffic drops.
1. Audit before you touch anything
Before writing any migration code, capture the current state of the site:
- Crawl every URL (Screaming Frog or any crawler) and export URL, title, meta description, H1, canonical and status code.
- Pull your top pages from Google Search Console (clicks and impressions for the last 12 months). These are the pages you must not break.
- List content types: posts, pages, custom post types, categories, tags, authors, media.
- Note plugin-driven features: SEO plugin fields, redirects plugin rules, forms, schema markup, shortcodes.
That crawl file becomes your source of truth. You'll compare against it after launch.
2. Model your content in Sanity first
Don't copy WordPress's structure one-to-one. Decide what the content should look like in Sanity, then map into it.
// schemaTypes/post.ts import {defineType, defineField} from 'sanity' export const post = defineType({ name: 'post', title: 'Post', type: 'document', fields: [ defineField({name: 'title', type: 'string', validation: (r) => r.required()}), defineField({ name: 'slug', type: 'slug', options: {source: 'title'}, validation: (r) => r.required(), }), defineField({name: 'publishedAt', type: 'datetime'}), defineField({name: 'excerpt', type: 'text'}), defineField({name: 'body', type: 'array', of: [{type: 'block'}, {type: 'image'}]}), defineField({name: 'seoTitle', type: 'string'}), defineField({name: 'seoDescription', type: 'text'}), ], }) Two fields people forget: seoTitle / seoDescription (so you keep the metadata you already optimised) and the slug (so URLs stay identical).
3. Export and transform the content
You can read WordPress content from the REST API (/wp-json/wp/v2/posts) or from a WXR export. The hard part is the body: WordPress stores HTML, Sanity stores structured Portable Text.
// scripts/migrate.ts import {createClient} from '@sanity/client' import {htmlToBlocks} from '@portabletext/block-tools' import {Schema} from '@sanity/schema' import {JSDOM} from 'jsdom' const client = createClient({ projectId: process.env.SANITY_PROJECT_ID!, dataset: 'production', apiVersion: '2025-01-01', token: process.env.SANITY_WRITE_TOKEN!, useCdn: false, }) // Build a block content type from your compiled schema const defaultSchema = Schema.compile({ name: 'default', types: [ { type: 'object', name: 'post', fields: [{name: 'body', type: 'array', of: [{type: 'block'}]}], }, ], }) const blockContentType = defaultSchema .get('post') .fields.find((f: any) => f.name === 'body').type async function migratePost(wp: any) { const blocks = htmlToBlocks(wp.content.rendered, blockContentType, { parseHtml: (html) => new JSDOM(html).window.document, }) await client.createOrReplace({ _id: `wp-post-${wp.id}`, // deterministic ID = safe to re-run _type: 'post', title: wp.title.rendered, slug: {_type: 'slug', current: wp.slug}, // keep the slug exactly publishedAt: wp.date_gmt + 'Z', excerpt: wp.excerpt.rendered.replace(/<[^>]+>/g, '').trim(), body: blocks, }) } Notes that save you pain:
- Use deterministic
_ids withcreateOrReplace. You can re-run the script as often as you like without creating duplicates. - Migrate images separately: upload them with
client.assets.upload('image', ...)and swap the references in the body. Keep alt text. - Run it on a staging dataset first and diff the output against your crawl file.
- Shortcodes and page-builder markup will not convert cleanly. Find them early and handle them with custom block types.
4. Keep URLs identical (or redirect every change)
The single biggest cause of traffic loss is changed URLs without redirects. Best case, your slugs and paths stay the same. If anything changes (for example /2023/05/my-post/ to /blog/my-post), add a 301.
// next.config.js module.exports = { async redirects() { return [ { source: '/:year(\\d{4})/:month(\\d{2})/:slug', destination: '/blog/:slug', permanent: true, // 301 }, { source: '/category/:slug', destination: '/blog/category/:slug', permanent: true, }, ] }, } For large sites, generate a redirect map from your crawl file and load it from JSON instead of hand-writing rules. Test it: every old URL in the crawl should return one 301 to a 200 page, never a chain.
5. Rebuild the SEO layer in Next.js
WordPress SEO plugins handled a lot silently. In Next.js you have to do it on purpose.
// app/blog/[slug]/page.tsx import type {Metadata} from 'next' import {client} from '@/sanity/client' const query = `*[_type == "post" && slug.current == $slug][0]{ title, seoTitle, seoDescription, excerpt, publishedAt }` export async function generateMetadata( {params}: {params: Promise<{slug: string}>} ): Promise<Metadata> { const {slug} = await params const post = await client.fetch(query, {slug}) return { title: post.seoTitle ?? post.title, description: post.seoDescription ?? post.excerpt, alternates: {canonical: `https://example.com/blog/${slug}`}, } } Checklist for this layer:
- Title, meta description and canonical on every page
-
sitemap.xmlandrobots.txtgenerated by Next.js - Open Graph and Twitter tags
- Structured data (Article, Breadcrumb, Organization) re-added as JSON-LD
- Image
alttext carried over, andnext/imageused for performance
6. Pre-launch QA
Run these on staging, not production:
- Crawl staging and compare URL-by-URL with the old crawl (titles, descriptions, H1s, canonicals).
- Test every redirect from the redirect map.
- Check Core Web Vitals (Lighthouse or PageSpeed Insights) on your top templates.
- Confirm staging is
noindex, and remember to remove that before launch. - Check internal links: no links pointing back to old WordPress URLs or to staging.
7. Launch day and the first 30 days
- Switch DNS, then submit the new sitemap in Search Console.
- Keep the old WordPress site reachable (not public) for a while so you can compare content if something looks off.
- Watch Search Console coverage and crawl errors daily for the first two weeks.
- Re-crawl production and diff against the original crawl.
- Expect small ranking movement in the first weeks. A sustained drop on your top pages means something in steps 4 or 5 is wrong, so go back and check redirects and metadata first.
Common mistakes
- Changing URL structure "while we're at it"
- Forgetting to migrate meta titles and descriptions
- Redirect chains (old URL to intermediate URL to final URL)
- Launching with the staging
noindexstill on - Dropping structured data that the old SEO plugin was adding
- Treating the migration as a one-day cutover instead of a staged process
Wrapping up
A WordPress to Sanity + Next.js migration is mostly about discipline: audit first, model content deliberately, keep URLs stable, rebuild the SEO layer, and verify with a crawl before and after.
If you'd rather not run this yourself, the team I work with at RW Infotech does these migrations as a service. They have a step-by-step write-up of the WordPress to Sanity process and a WordPress to Sanity migration page that covers scope and approach. The same checklist applies if you're moving to Contentful, Strapi, Prismic or Storyblok instead.
Have you done a CMS migration recently? What broke first for you: redirects, metadata or images? Tell me in the comments.
