Type something to search...
How to Migrate From WordPress to Sanity

How to Migrate From WordPress to Sanity

To migrate from WordPress to Sanity, audit what's in WordPress, model each post type, taxonomy and custom field as a Sanity schema, then write a migration script that reads the WordPress REST API and creates Sanity documents with predictable IDs such as post-123. Import authors and categories first so posts can reference them, upload featured images to Sanity's asset pipeline, convert post HTML to Portable Text, and run the script with npx sanity migrations run against a test dataset before production. Finish by mapping old URLs to new ones with redirects, and switch DNS only when the new front end is checked.

A WordPress migration is less about moving data and more about deciding what your content should look like once it's structured. Done well, you end up with clean, reusable content instead of HTML blobs and shortcodes. This guide walks through the full process: the audit, the content model, setting up a migration script, importing taxonomies, authors, posts and images, handling SEO fields, redirects and the final cut-over.

Before You Start: Audit Your WordPress Site

Every surprise you find before the migration is one you won't find after launch. Spend an hour listing what you actually have:

  • Post types: posts, pages and any custom post types registered by your theme or plugins, such as product, event or case_study.
  • Taxonomies: categories, tags and custom taxonomies.
  • Custom fields: Advanced Custom Fields (ACF), Meta Box or similar. These often hold the most important structured data.
  • Media: how many images and files exist, and which are actually used.
  • Shortcodes and blocks: anything in post bodies that isn't plain HTML, such as galleries, embeds, buttons or forms.
  • SEO data: titles, descriptions and canonical URLs from Yoast or Rank Math.
  • URLs: your permalink structure, so you can redirect every old path.

The quickest way to see what the REST API exposes is to open https://your-site.com/wp-json/wp/v2/types in a browser. It lists every post type available over the API. Custom post types only appear if they were registered with show_in_rest, and ACF fields only appear if REST output is enabled for each field group.

This is also the moment to decide what not to migrate. Old drafts, spam comments, plugin settings and pages nobody visits can stay behind. If you're still weighing whether to move at all, Sanity vs WordPress compares the two platforms.

Map WordPress Concepts to Sanity

WordPress and Sanity describe content differently. This table shows the usual mapping:

WordPressSanity
Post, page, custom post typeDocument type (post, page, event)
Category, tag, custom taxonomyDocument type, linked with references
User (author)author document type
Featured image, media libraryimage field pointing to an image asset
Post content (HTML or blocks)Portable Text array
ACF field groupFields on the document, or a reusable object type
Post status (publish, draft)Published document or drafts. document
Permalinkslug field
Yoast or Rank Math fieldsA reusable seo object

The important shift is that taxonomies become documents. A category in Sanity is a category document, and a post holds an array of references to categories. That gives you full control over category fields, such as descriptions and images, and lets GROQ query in both directions. How to use references in Sanity schemas explains the reference model in depth.

Step 1: Create the Target Schemas

Build your Studio and schemas before writing any migration code. Seeing an empty document form in Studio is the best way to catch modelling mistakes. A minimal set for a blog looks like this:

// schemaTypes/post.ts
import { defineArrayMember, defineField, defineType } from 'sanity';

export const post = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string', validation: (rule) => rule.required() }),
    defineField({
      name: 'slug',
      type: 'slug',
      options: { source: 'title' },
      validation: (rule) => rule.required(),
    }),
    defineField({ name: 'publishedAt', type: 'datetime' }),
    defineField({ name: 'excerpt', type: 'text', rows: 3 }),
    defineField({ name: 'author', type: 'reference', to: [{ type: 'author' }] }),
    defineField({
      name: 'categories',
      type: 'array',
      of: [defineArrayMember({ type: 'reference', to: [{ type: 'category' }] })],
    }),
    defineField({
      name: 'mainImage',
      type: 'image',
      options: { hotspot: true },
      fields: [defineField({ name: 'alt', type: 'string' })],
    }),
    defineField({ name: 'body', type: 'blockContent' }),
    defineField({ name: 'seo', type: 'seo' }),
  ],
});

Create category, author, blockContent and seo types in the same way. Keep field names in camelCase and drop WordPress-isms you don't need, such as comment_status or ping_status. If you're new to schemas, how to create your first schema in Sanity covers the basics.

Step 2: Set Up a Migration Script

Sanity's migration tooling is designed for changing content in bulk. It batches mutations into transactions to stay within rate limits, runs as a dry run by default and shows progress in the terminal. You can use it to create new documents from an external source, not just to transform existing ones.

From your Studio folder, scaffold a migration and install a few helpers:

npx sanity migrations create "Import WordPress"
npm install -D wp-types html-entities @portabletext/block-tools jsdom @sanity/client

Choose the minimal template when prompted. You'll get a migrations/import-wordpress/index.ts file to replace. Next, add a small fetch helper that pages through the REST API. WordPress returns at most 100 items per page and sends the total number of pages in the X-WP-TotalPages header:

// migrations/import-wordpress/lib/wpFetchAll.ts
const BASE_URL = 'https://your-site.com/wp-json/wp/v2';

export async function* wpFetchAll<T>(type: string, params: Record<string, string> = {}) {
  let page = 1;
  let totalPages = 1;

  do {
    const url = new URL(`${BASE_URL}/${type}`);
    url.searchParams.set('per_page', '100');
    url.searchParams.set('page', String(page));
    for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);

    const res = await fetch(url);
    if (!res.ok) throw new Error(`${type} page ${page} failed: ${res.status}`);

    totalPages = Number(res.headers.get('X-WP-TotalPages') ?? 1);
    yield (await res.json()) as T[];
    page++;
  } while (page <= totalPages);
}

Using a generator keeps memory use flat, even on sites with thousands of posts.

Step 3: Import Authors and Taxonomies First

Posts reference authors and categories, so create those documents first. The trick that makes the whole migration repeatable is a deterministic _id built from the WordPress ID. category-12 is always the same document, so re-running the script updates it instead of creating a duplicate, and posts can reference it before you've even looked it up.

// migrations/import-wordpress/index.ts
import { decode } from 'html-entities';
import { createOrReplace, defineMigration } from 'sanity/migrate';
import type { WP_REST_API_Categories, WP_REST_API_Users } from 'wp-types';
import { wpFetchAll } from './lib/wpFetchAll';

export default defineMigration({
  title: 'Import WordPress',
  async *migrate() {
    for await (const users of wpFetchAll<WP_REST_API_Users[number]>('users')) {
      yield users.map((user) =>
        createOrReplace({
          _id: `author-${user.id}`,
          _type: 'author',
          name: decode(user.name),
          slug: { _type: 'slug', current: user.slug },
          bio: user.description,
        }),
      );
    }

    for await (const categories of wpFetchAll<WP_REST_API_Categories[number]>('categories')) {
      yield categories.map((category) =>
        createOrReplace({
          _id: `category-${category.id}`,
          _type: 'category',
          title: decode(category.name),
          slug: { _type: 'slug', current: category.slug },
          description: category.description,
        }),
      );
    }

    // Posts come next (Step 4)
  },
});

Names and titles from WordPress contain HTML entities such as &amp;, which is why decode() is used. Repeat the same pattern for tags and custom taxonomies. The users endpoint only lists authors who have published posts unless you authenticate, which is usually what you want anyway.

Step 4: Import Posts With References

Now add posts to the same generator. Each post references its author and categories by the IDs you created above:

import type { WP_REST_API_Posts } from 'wp-types';
import { htmlToPortableText } from './lib/htmlToPortableText';

// inside migrate(), after the taxonomies
for await (const posts of wpFetchAll<WP_REST_API_Posts[number]>('posts', { status: 'publish' })) {
  const docs = [];

  for (const wpPost of posts) {
    docs.push({
      _id: `post-${wpPost.id}`,
      _type: 'post',
      title: decode(wpPost.title.rendered),
      slug: { _type: 'slug', current: wpPost.slug },
      publishedAt: new Date(wpPost.date_gmt + 'Z').toISOString(),
      excerpt: decode(wpPost.excerpt.rendered.replace(/<[^>]+>/g, '')).trim(),
      author: { _type: 'reference', _ref: `author-${wpPost.author}` },
      categories: wpPost.categories.map((id) => ({
        _type: 'reference',
        _ref: `category-${id}`,
        _key: `category-${id}`,
      })),
      mainImage: await featuredImage(wpPost.featured_media),
      body: htmlToPortableText(wpPost.content.rendered),
    });
  }

  yield docs.map((doc) => createOrReplace(doc));
}

A few details matter here:

  • Array keys: every item in a Sanity array needs a _key. Using the WordPress ID keeps keys stable between runs.
  • Dates: date_gmt has no time zone suffix, so add Z before converting it to an ISO string.
  • Excerpts: WordPress wraps excerpts in p tags. Strip them for a plain text field.
  • Drafts: requesting status=publish only fetches published posts. If you need drafts too, authenticate with an application password and write them with an _id of drafts.post-123 so they arrive as unpublished drafts.

Run pages and custom post types through the same loop with their own endpoints and document types.

Step 5: Migrate Featured Images

The migration context's client is deliberately restricted to reads, so upload assets with a separate @sanity/client instance that has a write token. Store the WordPress media ID on the asset's source so you can skip images that already exist when you re-run the script:

// migrations/import-wordpress/lib/featuredImage.ts
import { Readable } from 'node:stream';
import { createClient } from '@sanity/client';

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  token: process.env.SANITY_WRITE_TOKEN,
  apiVersion: '2025-02-19',
  useCdn: false,
});

const uploaded = new Map<number, string>();

export async function featuredImage(mediaId: number) {
  if (!mediaId) return undefined;

  if (!uploaded.has(mediaId)) {
    const existing = await client.fetch<string | null>(
      `*[_type == "sanity.imageAsset" && source.name == "wordpress" && source.id == $id][0]._id`,
      { id: String(mediaId) },
    );

    if (existing) {
      uploaded.set(mediaId, existing);
    } else {
      const media = await fetch(`https://your-site.com/wp-json/wp/v2/media/${mediaId}`).then((r) => r.json());
      const file = await fetch(media.source_url);
      if (!file.ok || !file.body) return undefined;

      const asset = await client.assets.upload('image', Readable.fromWeb(file.body), {
        filename: media.source_url.split('/').pop(),
        source: { name: 'wordpress', id: String(mediaId), url: media.source_url },
      });
      uploaded.set(mediaId, asset._id);
    }
  }

  return {
    _type: 'image',
    asset: { _type: 'reference', _ref: uploaded.get(mediaId) },
  };
}

Sanity also deduplicates identical files by content hash, so uploading the same image twice won't store it twice, but the lookup saves time and bandwidth. Add the media item's alt_text to the image's alt field while you're there. How to handle images in Sanity explains the asset model, and how to add alt text requirements shows how to enforce alt text from now on.

Step 6: Convert Post Content

Post bodies are HTML, sometimes with block editor comments and shortcodes mixed in. Convert them to Portable Text with htmlToBlocks() from @portabletext/block-tools, wrapped in the htmlToPortableText helper used above. The full technique, including compiling your block schema, JSDOM parsing and custom rules, is covered in how to convert HTML to Portable Text, so here are the WordPress-specific points:

  • Shortcodes: text like [gallery ids="1,2,3"] survives conversion as plain text. Find them before converting with a regular expression and replace them with custom blocks, or with HTML your rules recognise.
  • Block editor markup: the <!-- wp:paragraph --> comments are ignored by the converter, but blocks such as wp:embed or wp:gallery deserve a rule. The @wordpress/block-serialization-default-parser package can parse raw block markup if you fetch content with context=edit.
  • Inline images: images inside the body need the same upload treatment as featured images. Collect their URLs, upload them, then return image blocks that reference the new assets.
  • Internal links: links to your-site.com/2024/05/old-slug/ should be rewritten to new paths, or turned into internal reference annotations in a later pass.

Expect this step to take most of your time. Convert a dozen representative posts first, open them in Studio and iterate on your rules before running everything.

Step 7: Bring Over SEO Fields

If you use Yoast, the REST API includes a yoast_head_json object on each post with the title, description and Open Graph data. Map it to your seo object:

seo: wpPost.yoast_head_json
  ? {
      _type: 'seo',
      metaTitle: wpPost.yoast_head_json.title,
      metaDescription: wpPost.yoast_head_json.description,
    }
  : undefined,

yoast_head_json isn't part of the wp-types definitions, so you'll need to extend the type or cast it. Rank Math and other plugins expose their data differently, often only through custom fields. Check one post's JSON before writing the mapping. How to manage SEO metadata in Sanity shows how to build the seo object and use it in your front end.

Step 8: Run the Migration

Migrations run as a dry run by default, which prints the mutations without writing anything. Run against a test dataset first:

npx sanity datasets create wp-import-test
npx sanity migrations run import-wordpress --dataset wp-import-test
npx sanity migrations run import-wordpress --dataset wp-import-test --no-dry-run

Set SANITY_PROJECT_ID, SANITY_DATASET and SANITY_WRITE_TOKEN in your shell for the image helper, pointing at the same dataset. Because every document uses createOrReplace with a deterministic ID, you can fix a rule and run the whole thing again as many times as you need. When the test dataset looks right, run it against production.

Then check the results with a few GROQ queries in Vision:

{
  "posts": count(*[_type == "post"]),
  "withoutImage": count(*[_type == "post" && !defined(mainImage)]),
  "brokenAuthors": *[_type == "post" && !defined(author->_id)]{ _id, title }
}

Compare the counts with WordPress. Missing references usually mean a taxonomy or author wasn't imported, for example because it had no published posts.

Step 9: Redirects and Cut-Over

Your new front end probably uses different URLs, such as /blog/my-post instead of /2024/05/my-post/. Every old URL needs a permanent redirect, or you'll lose search rankings and break inbound links.

Store redirects in Sanity so editors can maintain them later, and read them in your Next.js config:

// next.config.ts
import type { NextConfig } from 'next';
import { createClient } from '@sanity/client';

const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: '2025-02-19',
  useCdn: true,
});

const nextConfig: NextConfig = {
  async redirects() {
    return client.fetch(
      `*[_type == "redirect" && defined(source) && defined(destination)]{
        source, destination, "permanent": true
      }`,
    );
  },
};

export default nextConfig;

You can generate the redirect documents in the migration itself, since you know each post's old permalink (wpPost.link) and its new slug.

Before switching DNS, run through a short checklist:

  1. Freeze content in WordPress, then run the migration one final time to pick up recent changes.
  2. Crawl the old site and test that every URL either works or redirects.
  3. Check feeds and sitemaps, and submit the new sitemap to search engines. Generating dynamic sitemaps with Sanity covers this.
  4. Train editors on Studio, which feels quite different from the WordPress dashboard.
  5. Keep WordPress running privately for a few weeks in case you need to recover something.

FAQ: Migrating From WordPress to Sanity

There's no one-click importer, because every WordPress site is structured differently. Sanity recommends its migrations CLI for bulk imports and publishes a course on migrating from WordPress that uses the REST API, deterministic IDs and createOrReplace, which is the approach in this guide.

The REST API is usually easier, because it returns JSON and includes featured image IDs, taxonomies and plugin data such as Yoast fields. A WXR export works when the API is disabled or the site is offline, but you'll need to parse XML and handle attachments yourself.

Enable "Show in REST API" for each ACF field group so the values appear in the acf property of each post. Then map each field to a Sanity field in your migration script. Repeater and flexible content fields map naturally to arrays of objects.

Yes, and you should. Give every document a deterministic _id based on the WordPress ID, such as post-123, and write with createOrReplace. Each run then updates existing documents instead of duplicating them, so you can refine the script safely.

Sanity doesn't have a built-in public comment system. You can import comments as their own document type referencing each post, or move to a third-party comment service. Many teams use the migration as a chance to drop comments altogether.

A small blog with clean content can be migrated in a day or two. Sites with many custom post types, page builders or shortcodes take longer, mostly because of content conversion and front-end work rather than the import itself.


Conclusion

Migrating from WordPress to Sanity works best as a repeatable script rather than a one-off export. Audit the site, model your content in Studio first, then use the migrations CLI to import authors, taxonomies and posts with deterministic IDs, so references resolve and every run is safe to repeat. Upload images once, record their WordPress IDs, and convert HTML bodies with custom rules for shortcodes and embeds.

Test in a separate dataset, compare counts with WordPress, and set up redirects for every old URL before you switch DNS. Treat the first import as a draft of your new content model. With a few iterations, you'll end up with structured content that's far easier to reuse than the HTML you started with.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper