How I migrate WordPress to Sanity + Next.js: a technical walkthrough

Jul 09, 2026 · 6 min read

Part of The Sanity + Next.js Guide

Migrating WordPress to Sanity + Next.js is mostly a data engineering problem. The decisions around whether to migrate are covered elsewhere — this post is purely about the mechanics: getting content out of WordPress, shaping it into typed Sanity documents, and making sure Google never sees a broken URL.

I'll walk through the four stages I use on every migration: export, schema mapping, import scripting, and redirect wiring.

Exporting WordPress content

WordPress gives you two export paths. The WXR (WordPress eXtended RSS) XML file you get from Tools → Export is fine for small sites, but it serialises everything into one flat file and the HTML inside <content:encoded> nodes is a pain to parse. I prefer the REST API for anything above a few hundred posts.

For a typical site with posts, pages, and categories I pull:

https://old-site.com/wp-json/wp/v2/posts?per_page=100&page=1
https://old-site.com/wp-json/wp/v2/pages?per_page=100&page=1
https://old-site.com/wp-json/wp/v2/categories?per_page=100
https://old-site.com/wp-json/wp/v2/media?per_page=100

I paginate through all of them and write each collection to a local JSON file. The media endpoint is worth fetching separately — it gives you source_url, alt_text, and mime_type for every attachment, which you need for image migration.

One gotcha: custom post types registered by plugins won't appear under /wp/v2/posts. Check wp-json/wp/v2 for the full namespace list and add any CPTs you find.

Mapping WordPress types to Sanity schemas

WordPress has a flat mental model: everything is a post with a post type. Sanity wants explicit document types with typed fields. I do the mapping on paper before writing a single line of import code.

Typical equivalences:

| WordPress | Sanity document type | |---|---| | post | blogPost | | page | page | | category | category | | tag | tag | | attachment | inlined as image field or figure block |

The Sanity schemas for blogPost and category look like this in my projects:

// sanity/schemas/blogPost.ts
import { defineType, defineField } from 'sanity'
 
export const blogPostSchema = defineType({
  name: 'blogPost',
  title: 'Blog post',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string', validation: Rule => Rule.required() }),
    defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
    defineField({ name: 'publishedAt', type: 'datetime' }),
    defineField({
      name: 'categories',
      type: 'array',
      of: [{ type: 'reference', to: [{ type: 'category' }] }],
    }),
    defineField({
      name: 'excerpt',
      type: 'text',
      rows: 3,
    }),
    defineField({
      name: 'body',
      type: 'array',
      of: [{ type: 'block' }, { type: 'image' }],
    }),
    defineField({
      name: 'seo',
      type: 'object',
      fields: [
        defineField({ name: 'metaTitle', type: 'string' }),
        defineField({ name: 'metaDescription', type: 'text', rows: 2 }),
        defineField({ name: 'canonicalUrl', type: 'url' }),
        defineField({ name: 'legacySlug', type: 'string' }), // stores original WP slug
      ],
    }),
  ],
})

Notice seo.legacySlug. I always store the original WordPress slug on every document. It makes the redirect matrix trivial to generate later.

Writing the import script

The import script has three jobs: upload images to Sanity's asset store, convert HTML body content to Portable Text, and create the documents.

I use @sanity/client directly and the sanity-block-content-to-hyperscript / html-to-portable-text ecosystem. The cleanest library I've found for the HTML conversion is @portabletext/html-input — but it's new and the API shifts. A more stable option is running the WordPress HTML through htmlparser2 and mapping nodes manually. For most blog content the tags involved are limited enough that a manual mapper is 100 lines and fully predictable.

// scripts/migrate.ts
import { createClient } from '@sanity/client'
import { JSDOM } from 'jsdom'
import fs from 'node:fs'
 
const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: 'production',
  apiVersion: '2024-06-01',
  token: process.env.SANITY_WRITE_TOKEN!,
  useCdn: false,
})
 
const wpPosts: WpPost[] = JSON.parse(fs.readFileSync('./export/posts.json', 'utf8'))
const wpMedia: Record<number, WpMedia> = JSON.parse(fs.readFileSync('./export/media-map.json', 'utf8'))
 
async function uploadImage(sourceUrl: string, altText: string) {
  const res = await fetch(sourceUrl)
  const buffer = Buffer.from(await res.arrayBuffer())
  return client.assets.upload('image', buffer, {
    filename: sourceUrl.split('/').pop(),
    description: altText,
  })
}
 
function htmlToBlocks(html: string): PortableTextBlock[] {
  const dom = new JSDOM(html)
  const blocks: PortableTextBlock[] = []
  for (const node of dom.window.document.body.children) {
    const tag = node.tagName.toLowerCase()
    const text = node.textContent ?? ''
    if (['p', 'h2', 'h3', 'h4', 'blockquote'].includes(tag)) {
      blocks.push({
        _type: 'block',
        _key: crypto.randomUUID(),
        style: tag === 'p' ? 'normal' : tag,
        children: [{ _type: 'span', _key: crypto.randomUUID(), text, marks: [] }],
        markDefs: [],
      })
    }
    // extend: handle <ul>, <ol>, <figure> etc.
  }
  return blocks
}
 
async function run() {
  for (const post of wpPosts) {
    const featuredMedia = post.featured_media ? wpMedia[post.featured_media] : null
    let featuredImageRef = null
    if (featuredMedia) {
      const asset = await uploadImage(featuredMedia.source_url, featuredMedia.alt_text)
      featuredImageRef = { _type: 'image', asset: { _type: 'reference', _ref: asset._id } }
    }
 
    const doc = {
      _type: 'blogPost',
      _id: `wp-post-${post.id}`,
      title: post.title.rendered,
      slug: { _type: 'slug', current: post.slug },
      publishedAt: post.date_gmt,
      excerpt: post.excerpt.rendered.replace(/<[^>]+>/g, '').trim(),
      body: htmlToBlocks(post.content.rendered),
      featuredImage: featuredImageRef,
      seo: {
        legacySlug: post.slug,
        canonicalUrl: post.link,
      },
    }
 
    await client.createOrReplace(doc)
    console.log(`Imported: ${post.slug}`)
  }
}
 
run().catch(console.error)

A few things worth noting here. I prefix _id with wp-post- so re-running the script is idempotent — createOrReplace won't create duplicates. I upload images before creating the document so the reference is ready. The htmlToBlocks function above is a skeleton — you'll need to add handlers for <ul>, <ol>, <a>, <strong>, <em>, and <figure> based on what's in the actual content. Dump a sample post and audit the tags before writing the full mapper.

Run this script locally against a staging dataset first, never production. Check a random 10% of posts in Sanity Studio before promoting to production.

Preserving SEO with a 301 redirect matrix

WordPress URLs are usually /year/month/post-slug/ or just /post-slug/ depending on the permalink setting. Next.js App Router typically uses /blog/[slug]. That's a different path structure, so every old URL needs a 301.

After the import is done I query Sanity for every legacySlug and the new slug in one GROQ query:

// GROQ query to generate redirect map
*[_type == "blogPost"] {
  "from": seo.legacySlug,
  "to": slug.current
}

I export that as a JSON array and write it into next.config.ts as static redirects for small sites, or into a middleware lookup for large ones. The next.config.ts approach is simpler and gets baked into the Vercel edge config:

// next.config.ts — for sites with < 1000 legacy URLs
import type { NextConfig } from 'next'
import redirectMap from './redirects.json' // generated from GROQ export
 
const config: NextConfig = {
  async redirects() {
    return redirectMap.map(({ from, to }) => ({
      source: `/${from}`,           // e.g. /old-post-slug
      destination: `/blog/${to}`,    // e.g. /blog/new-post-slug
      permanent: true,
    }))
  },
}
 
export default config

For sites with category archives at /category/[slug]/ I build the same matrix for category documents. WordPress tag pages at /tag/[slug]/ usually don't need preserving unless they have inbound links — check Search Console before deciding.

Once the redirects are live I submit a new sitemap via Search Console and run a crawl with Screaming Frog to verify there are no chains (301 → 301) and no 404s. Category and pagination pages from WordPress that have no equivalent in the new site should redirect to the nearest logical parent — usually /blog or /.

The whole migration for a 300-post blog typically takes two focused days: half a day on export and schema design, half a day on the import script, and one day on redirect audit and staging QA.

Related posts

All posts ↗