Everything *[NYC] 2026: see what we announced. →
Skip to content

How do I migrate content from WordPress to Sanity?

0repliesLast updated: Nov 6, 2026

I'm moving a WordPress site to Sanity. What's the recommended way to get posts, pages, categories, authors and media out of WordPress and into my dataset, convert the post HTML to Portable Text, and make the import safe to re-run until we go live?
AI Update

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

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.modified

A 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=posts

In 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:

6. Slugs, redirects, re-runs

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.

Related contributions