How do I migrate content from WordPress to Sanity?
There's no one-click importer. You write a script: it reads the WordPress REST API, turns each item into a Sanity document with a stable _id, converts the post HTML to Portable Text with @portabletext/block-tools, uploads images as assets, and writes everything with createOrReplace. Run it with npx sanity@latest migration run. Because each _id comes from the WordPress ID, you can run the script again as many times as you need before launch.
1. Pick an export route
- REST API (recommended):
https://<your-domain>/wp-json/wp/v2should return JSON. Your script always reads current content, so you don't need a content freeze at go-live (planning). Security plugins can block the API. One fix is to run WordPress locally on a copy of the production database. Multisite installs put the site name in the path. - XML export or a database dump: this works, but each re-run takes more manual steps and coordination. The XML format also needs different parsing logic than the REST examples below (course). A common pattern is to write an NDJSON file (one document per line) and load it with
npx sanity@latest dataset import. That import can also fetch images from URLs, but it has no dry run (scripting options).
2. Map the content model
Define the Studio schema first so imported documents show up right away: post, page, category, tag, author, a portableText array (block, image, externalImage), and a temporary externalImage object with a url field (schema lesson). WordPress users become author documents, because Sanity doesn't add project members to documents. A 1:1 copy is a fine starting point, but plan to split template-driven "pages" into real types, such as person.
Use stable IDs (${sanityType}-${wpDoc.id}) and build references from the WordPress IDs (complete documents):
const doc = {_id: `post-${wpDoc.id}`, _type: 'post'}
doc.title = decode(wpDoc.title.rendered).trim() // from html-entities
if (wpDoc.slug) doc.slug = {_type: 'slug', current: wpDoc.slug}
doc.categories = wpDoc.categories.map((catId) => ({
_key: uuid(), _type: 'reference', _ref: `category-${catId}`,
}))
if (wpDoc.author) doc.author = {_type: 'reference', _ref: `author-${wpDoc.author}`}
doc.date = wpDoc.date
doc.modified = wpDoc.modifiedA reference only works if the document it points to already exists. Import categories, tags and users before posts. Store editorial dates in your own datetime fields, not _createdAt/_updatedAt.
3. Script and run it
npx sanity@latest migration create "Import WP"
npm install -D wp-types html-entities p-limit @portabletext/block-tools jsdom
npx sanity@latest migration run import-wp --type=categories # dry run
npx sanity@latest migration run import-wp --no-dry-run --type=categories
npx sanity@latest migration run import-wp --no-dry-run --type=users
npx sanity@latest migration run import-wp --no-dry-run --type=postsIn defineMigration (from sanity/migrate), page through /posts?page=N&per_page=100 and yield docs.map((doc) => createOrReplace(doc)). The migration tooling batches mutations into transactions to stay under rate limits, and dry run is the default (post types). If you'd rather use sanity exec --with-user-token with your own client, you have to do the batching yourself. One huge transaction can be rejected.
4. Convert HTML to Portable Text
import {htmlToBlocks} from '@portabletext/block-tools'
import {JSDOM} from 'jsdom'
const blocks = htmlToBlocks(wpDoc.content.rendered, blockContentSchema, {
parseHtml: (html) => new JSDOM(html).window.document,
rules: [{
deserialize(node, next, block) {
const el = node as HTMLElement
if (node.nodeName.toLowerCase() === 'figure') {
const img = el.querySelector('img')
const url = img?.getAttribute('src')
if (!url) return undefined
return block({_type: 'externalImage', url, altText: img?.getAttribute('alt')})
}
return undefined
},
}],
})Get blockContentSchema from Schema.compile({types: schemaTypes}), using the post type's content field. Headings, paragraphs and lists convert with no extra setup. Images need a rule like the one above (HTML to Portable Text). content.rendered already has shortcodes expanded. If you use Gutenberg, the authenticated content.raw lets you map blocks such as columns to custom objects. Page-builder HTML (Elementor, Divi and similar) is hard to migrate cleanly.
5. Upload media and swap in references
deserialize is synchronous, so uploads happen in a second pass. For each externalImage block, find the WordPress media item, upload it with client.assets.upload('image', stream, metadata), and replace the block with {_type: 'image', asset: {_type: 'reference', _ref: assetId}}. Do the same for featured_media. Some practical points:
- The client the migration gives you can't upload assets. Create a full one with
createClient(context.client.config()). - Limit how many uploads run at once with
p-limit(the course uses 2 to 5). - Save
source: {id, name: 'WordPress', url}on each upload. Before a run, query*[_type == "sanity.imageAsset" && source.name == "WordPress"]to build an ID → asset cache, so re-runs don't upload the same image twice.
6. Slugs, redirects, re-runs
- Copy
wpDoc.sluginto aslugfield. If your URL structure changes, crawl the old site (for example with Screaming Frog), add a redirect for every changed URL (you can store these as Sanity documents), and set up broken-link checks (SEO lesson). - Write scripts that give the same result every time you run them. Expect missing images and broken references. Throw instead of silently importing bad data, and validate the results with
npx sanity@latest documents validate(principles).
Was this answer helpful?
Sanity – Build the way you think, not the way your CMS thinks
Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.