
How to Convert HTML to Portable Text
To convert HTML to Portable Text, use htmlToBlocks() from @portabletext/block-tools. You compile a schema that describes your Portable Text field with Schema.compile() from @sanity/schema, pass the HTML string and that field's type to htmlToBlocks(), and get back an array of blocks ready to save in Sanity. In Node.js you also pass a parseHtml function that uses JSDOM, because there's no browser DOM. Headings, paragraphs, lists, bold, italics and links convert automatically, and you add custom rules for anything else, such as images, embeds or callouts, before importing the result with the Sanity client or the CLI.
Most Sanity migrations start with HTML: posts from WordPress, pages from an old CMS, or articles exported from a static site generator. Rewriting that content by hand isn't realistic, and storing raw HTML in a string field throws away the benefits of structured content. This guide covers the tools available, a complete Node.js conversion script, custom rules for non-standard markup, image handling, writing documents to Sanity and checking the results.
Choosing the Right Tool
There are a few ways to get from HTML to Portable Text:
| Tool | Best for | Notes |
|---|---|---|
@portabletext/block-tools | Sanity migrations where you have a Studio schema | Wraps @portabletext/html and reads your Sanity block configuration |
@portabletext/html | Non-Sanity projects or custom Portable Text schemas | Same conversion engine, with a schema defined via @portabletext/schema |
| Pasting into Studio | A handful of documents | The Portable Text Editor converts pasted HTML, including from Google Docs and Word |
For a real migration, @portabletext/block-tools is the natural choice. Because it uses your Sanity schema, it only produces styles, lists, decorators and annotations your field allows, so converted content behaves exactly like content editors create. If you're coming from WordPress and still deciding on the move, Sanity vs WordPress compares the two.
How the Conversion Works
The converter walks the HTML tree and turns each element into Portable Text:
- Block elements:
pbecomes anormalblock,h1toh6become heading styles, andblockquotebecomes ablockquotestyle, if your schema allows them. - Lists:
ulandolbecome blocks withlistItemset tobulletornumber, withlevelfor nesting. - Inline formatting:
strong,b,em,i,u,sandcodebecome decorators. - Links:
aelements becomelinkannotations inmarkDefs. - Everything else: handled by your custom rules, or flattened to text if there's no match.
Anything your schema doesn't allow is dropped or downgraded. If your field has no h4 style, an h4 won't become one. That's usually what you want, but it means you should define the target field carefully first. What is Portable Text in Sanity explains the block structure you're converting to.
Step 1: Install the Packages
In a migration folder, or a scripts folder in your Studio project:
npm install @portabletext/block-tools @sanity/schema @sanity/client jsdom
npm install --save-dev tsx @types/jsdom
jsdom supplies the DOM parser in Node, and tsx lets you run TypeScript scripts directly.
Step 2: Compile a Schema for the Target Field
htmlToBlocks() needs the compiled type of your Portable Text field. Describe the document and its body with Schema.compile(), matching your Studio schema:
// scripts/schema.ts
import { Schema } from '@sanity/schema';
const schema = Schema.compile({
name: 'migration',
types: [
{
type: 'object',
name: 'post',
fields: [
{ name: 'title', type: 'string' },
{
name: 'body',
type: 'array',
of: [
{
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'Heading 2', value: 'h2' },
{ title: 'Heading 3', value: 'h3' },
{ title: 'Quote', value: 'blockquote' },
],
lists: [
{ title: 'Bullet', value: 'bullet' },
{ title: 'Numbered', value: 'number' },
],
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
],
annotations: [
{
name: 'link',
type: 'object',
fields: [{ name: 'href', type: 'url' }],
},
],
},
},
{ type: 'image', fields: [{ name: 'alt', type: 'string' }] },
{
type: 'object',
name: 'code',
fields: [
{ name: 'code', type: 'string' },
{ name: 'language', type: 'string' },
],
},
],
},
],
},
],
});
export const blockContentType = schema
.get('post')
.fields.find((field: { name: string }) => field.name === 'body').type;
Two details are worth noting. You can't import @sanity/code-input into a Node script, so a minimal code object with code and language fields stands in for it. The stored shape matches the plugin's, so Studio will open these blocks in its code editor once imported. And if your Studio schema lives in plain object files without Studio-only imports like React components, you can import those definitions here instead of repeating them.
Step 3: Convert HTML in Node
With the schema ready, conversion is one call:
// scripts/convert.ts
import { htmlToBlocks } from '@portabletext/block-tools';
import { JSDOM } from 'jsdom';
import { blockContentType } from './schema';
const html = `
<h2>Getting started</h2>
<p>Sanity is a <strong>headless</strong> CMS. Read the
<a href="https://www.sanity.io/docs">docs</a>.</p>
<ul>
<li>Structured content</li>
<li>Real-time editing</li>
</ul>
`;
const blocks = htmlToBlocks(html, blockContentType, {
parseHtml: (input) => new JSDOM(input).window.document,
});
console.log(JSON.stringify(blocks, null, 2));
npx tsx scripts/convert.ts
The output is standard Portable Text, abbreviated here:
[
{
"_type": "block",
"_key": "…",
"style": "h2",
"markDefs": [],
"children": [{ "_type": "span", "_key": "…", "text": "Getting started", "marks": [] }]
},
{
"_type": "block",
"_key": "…",
"style": "normal",
"markDefs": [{ "_type": "link", "_key": "…", "href": "https://www.sanity.io/docs" }],
"children": [
{ "_type": "span", "_key": "…", "text": "Sanity is a ", "marks": [] },
{ "_type": "span", "_key": "…", "text": "headless", "marks": ["strong"] },
{ "_type": "span", "_key": "…", "text": " CMS. Read the ", "marks": [] },
{ "_type": "span", "_key": "…", "text": "docs", "marks": ["…"] },
{ "_type": "span", "_key": "…", "text": ".", "marks": [] }
]
},
{
"_type": "block",
"_key": "…",
"style": "normal",
"listItem": "bullet",
"level": 1,
"markDefs": [],
"children": [{ "_type": "span", "_key": "…", "text": "Structured content", "marks": [] }]
}
]
Every block and span gets a random _key. If you want repeatable output, for example to compare runs, pass a keyGenerator option that returns predictable keys.
Step 4: Add Custom Rules
Rules let you take over conversion for specific elements. Each rule has a deserialize(el, next, createBlock) function. Return a value to handle the element, or undefined to let the next rule or the defaults handle it. Custom rules run before the built-in ones.
Images
Images need a rule, because there's no universal way to map an img to a Sanity image. The Sanity CLI's import supports a _sanityAsset property that tells it to download and upload a file during import, which is perfect for migrations:
const imageRule = {
deserialize(el: Node, _next: unknown, createBlock: (props: object) => unknown) {
const element = el as HTMLElement;
const tag = element.tagName?.toLowerCase();
if (tag !== 'img' && tag !== 'figure') return undefined;
const img = tag === 'img' ? element : element.querySelector('img');
const src = img?.getAttribute('src');
if (!src) return undefined;
return createBlock({
_type: 'image',
_sanityAsset: `image@${new URL(src, 'https://old-site.example').href}`,
alt: img?.getAttribute('alt') ?? '',
});
},
};
Resolving src against the old site's URL turns relative paths into absolute ones the importer can fetch. Keeping the alt text matters for accessibility, and how to add alt text requirements to Sanity images shows how to enforce it for new content.
Callouts, Embeds and Other Custom Markup
The same pattern maps any recognisable markup to a custom block. Here an old shortcode-style div becomes a callout:
const calloutRule = {
deserialize(el: Node, _next: unknown, createBlock: (props: object) => unknown) {
const element = el as HTMLElement;
if (element.tagName?.toLowerCase() !== 'div' || !element.classList?.contains('note')) {
return undefined;
}
return createBlock({
_type: 'callout',
tone: element.dataset?.tone ?? 'info',
text: element.textContent?.trim() ?? '',
});
},
};
For this to be valid, callout must also exist in your compiled schema and your Studio schema. Adding custom blocks to Sanity Portable Text shows how to define it.
Removing Unwanted Elements
Old content often contains scripts, tracking pixels or empty wrappers. It's simplest to clean these up before conversion, while you still have a DOM:
function cleanHtml(html: string) {
const { document } = new JSDOM(html).window;
document.querySelectorAll('script, style, iframe[src*="tracking"]').forEach((node) => node.remove());
return document.body.innerHTML;
}
Pass the rules in when converting:
const blocks = htmlToBlocks(cleanHtml(html), blockContentType, {
parseHtml: (input) => new JSDOM(input).window.document,
rules: [imageRule, calloutRule],
});
Code Blocks
pre elements are converted into a code block object when your schema has a code object with a code string field, which is why the schema in Step 2 includes one. Check the output for a few posts, as language detection depends on how the old site marked it up, often as a class like language-js on the inner code element. If the results aren't right, write a rule for pre that reads the class and returns createBlock({ _type: 'code', language, code: el.textContent }). For the Studio side, see adding code blocks to Sanity content.
Step 5: Build Documents and Import Them
With conversion working, wrap it in a script that reads your source content and writes NDJSON, one document per line. This example reads from a WordPress REST API:
// scripts/migrate.ts
import { writeFileSync } from 'node:fs';
import { htmlToBlocks } from '@portabletext/block-tools';
import { JSDOM } from 'jsdom';
import { blockContentType } from './schema';
import { imageRule, calloutRule } from './rules';
type WpPost = {
id: number;
slug: string;
date: string;
title: { rendered: string };
content: { rendered: string };
};
const res = await fetch('https://old-site.example/wp-json/wp/v2/posts?per_page=100');
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`);
const posts = (await res.json()) as WpPost[];
const lines = posts.map((post) => {
const body = htmlToBlocks(post.content.rendered, blockContentType, {
parseHtml: (input) => new JSDOM(input).window.document,
rules: [imageRule, calloutRule],
});
return JSON.stringify({
_id: `wp-post-${post.id}`,
_type: 'post',
title: new JSDOM(post.title.rendered).window.document.body.textContent,
slug: { _type: 'slug', current: post.slug },
publishedAt: new Date(post.date).toISOString(),
body,
});
});
writeFileSync('posts.ndjson', lines.join('\n'));
console.log(`Wrote ${lines.length} posts`);
Export the rules from a scripts/rules.ts file. The script uses top-level await, so set "type": "module" in the migration folder's package.json. A few deliberate choices here:
- Deterministic IDs:
wp-post-123means re-running the import updates documents instead of duplicating them. - Decoded titles: WordPress returns titles with HTML entities, so parsing them through JSDOM gives plain text.
- Pagination: the REST API returns up to 100 posts per page. For larger sites, loop through pages using the total pages response header.
Then import with the CLI:
npx tsx scripts/migrate.ts
npx sanity datasets import posts.ndjson production --replace
--replace overwrites documents with the same _id, so you can fix the script and re-run it safely. The importer downloads every _sanityAsset and uploads it as a proper Sanity asset. Add --allow-failing-assets if some old images are missing and you'd rather import the text anyway. Import into a separate test dataset first if you can. More commands are listed in installing the Sanity CLI and essential commands.
Step 6: Check the Results
Automatic conversion is rarely perfect on the first run. After importing, check:
- Unknown types: query for objects your front end doesn't render, such as
*[_type == "post" && count(body[!(_type in ["block", "image", "code", "callout"])]) > 0]{ _id }. - Empty blocks: stray
brand emptypelements can produce empty paragraphs. Filter them out in your script by removing blocks whose text is blank. - Lost formatting: tables, footnotes and inline styles have no default mapping. Search the source HTML for these elements and decide on a rule for each.
- Links: internal links still point at old URLs. Rewrite them in a rule, or convert them to internal link annotations in a later pass.
Open a sample of documents in Studio as well. If they look and behave like content an editor created, the migration is in good shape.
FAQ: Converting HTML to Portable Text
Use @portabletext/block-tools for Sanity projects. Its htmlToBlocks() function takes an HTML string and your compiled Portable Text field type. For non-Sanity projects, @portabletext/html offers the same engine with its own schema helpers.
The converter parses HTML with a DOM. Browsers provide one through DOMParser, but Node.js doesn't, so you pass a parseHtml option that returns a JSDOM document.
The converter only produces styles, decorators and annotations your schema allows. If your compiled block type doesn't include h4 or underline, those elements are downgraded to normal text. Add them to the schema if you need them.
Write a rule for img elements that returns an image object with a _sanityAsset value such as image@https://example.com/photo.jpg. When you import the NDJSON with the Sanity CLI, it downloads and uploads each image.
Yes, for small amounts. Pasting HTML from a web page, Google Docs or Word into the Portable Text Editor converts it automatically using your field's schema. For bulk migrations, use a script.
Give each document a deterministic _id based on the source, such as wp-post-123, and import with --replace. Re-running then updates the existing documents instead of creating new ones.
Conclusion
Converting HTML to Portable Text comes down to describing your target field and letting htmlToBlocks() do the heavy lifting. Compile the schema with @sanity/schema, pass a JSDOM parser in Node, and you get valid blocks for paragraphs, headings, lists, formatting and links. Custom rules cover the rest, from images using _sanityAsset to callouts and embeds.
Wrap the conversion in a script with deterministic IDs, import into a test dataset with the CLI, and query for anything unexpected before moving to production. Treat the first run as a draft, refine your rules, and re-run until the migrated content looks like it was written in Sanity from the start.


