How I Migrate WordPress to Sanity + Next.js: A Technical Walkthrough
By Nayan Kyada · · 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=100I 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 configFor 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.
Frequently asked questions
01How do I preserve SEO rankings when migrating WordPress to Sanity + Next.js?
Store the original WordPress slug as a `legacySlug` field on every migrated document, then generate a 301 redirect matrix from a single GROQ query mapping old slugs to new ones — wired into `next.config.ts` redirects for small sites or middleware for larger ones. Submit a fresh sitemap and crawl with a tool like Screaming Frog afterward to confirm there are no redirect chains or unexpected 404s.
02What's the best way to convert WordPress HTML content to Sanity's Portable Text?
For most blog content, a manual HTML-to-block mapper (parsing with `htmlparser2` or `jsdom` and handling the limited set of tags actually in your content — `p`, headings, lists, links, bold/italic, figures) is more predictable than relying on a third-party conversion library, several of which have shifting APIs. Audit a sample post's actual tags before writing the full mapper rather than guessing.
03How long does a WordPress to Sanity + Next.js migration take?
For a roughly 300-post blog, about two focused days: half a day on export and schema design, half a day on the import script, and a full day on redirect audit and staging QA. Larger or messier content (inconsistent formatting, custom post types, images scattered across multiple locations) pushes that out — see [signs your WordPress site needs a headless CMS rebuild](/blog/signs-your-wordpress-site-needs-a-headless-cms-rebuild) for how to scope the decision before committing to a timeline.