Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

WordPress to Sanity + Next.js: How to Migrate Without Losing Your SEO Traffic

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 yea

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 with createOrReplace. 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.xml and robots.txt generated by Next.js
  • Open Graph and Twitter tags
  • Structured data (Article, Breadcrumb, Organization) re-added as JSON-LD
  • Image alt text carried over, and next/image used for performance

6. Pre-launch QA

Run these on staging, not production:

  1. Crawl staging and compare URL-by-URL with the old crawl (titles, descriptions, H1s, canonicals).
  2. Test every redirect from the redirect map.
  3. Check Core Web Vitals (Lighthouse or PageSpeed Insights) on your top templates.
  4. Confirm staging is noindex, and remember to remove that before launch.
  5. 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 noindex still 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.