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.
Top comments (0)