DEV Community

Cover image for How I migrate WordPress to Sanity + Next.js: a technical walkthrough
Nayan Kyada
Nayan Kyada

Posted on • Originally published at nayankyada.com

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

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
Enter fullscreen mode Exit fullscreen mode

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
      ],
    }),
  ],
})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)