---
title: Import HTML to Portable Text
description: Use @portabletext/block-tools with JSDOM to convert HTML content
---
## Import HTML to Portable Text
Use `@portabletext/block-tools` with `JSDOM` to convert HTML from legacy CMSs to Portable Text.
### Setup
```bash
npm install @portabletext/block-tools jsdom
```
### Basic Conversion
```typescript
import { htmlToBlocks } from '@portabletext/block-tools'
import { JSDOM } from 'jsdom'
// Get block content type from your schema
const blockContentType = schema.get('blockContent')
const blocks = htmlToBlocks(htmlString, blockContentType, {
parseHtml: html => new JSDOM(html).window.document,
})
```
### Custom Deserializers
Handle specific HTML patterns:
```javascript
const blocks = htmlToBlocks(htmlString, blockContentType, {
parseHtml: html => new JSDOM(html).window.document,
rules: [
{
deserialize(el, next, block) {
// Custom link handling — links are inline annotations, not blocks.
// Return an `__annotation` with a `markDef`, and recurse into the
// child nodes via `next()` so the link text is preserved.
if (el.tagName?.toLowerCase() === 'a') {
const href = el.getAttribute('href')
// An anchor with no `href` (named anchors, JS-driven links) isn't a
// link. Fall through so the text survives without a dangling markDef.
if (!href) return undefined
return {
_type: '__annotation',
markDef: {
_type: 'link',
href,
blank: el.getAttribute('target') === '_blank'
},
children: next(el.childNodes)
}
}
// Custom image handling — block-level types are wrapped with `block()`
if (el.tagName?.toLowerCase() === 'img') {
const src = el.getAttribute('src')
// Skip sourceless images rather than emitting `image@null`, which
// the importer reports as a failed asset with no pointer to the node.
if (!src) return undefined
return block({
_type: 'image',
// NDJSON + `sanity datasets import` only — see the note below.
_sanityAsset: `image@${src}`
})
}
return undefined // Fall through to default handling
}
}
]
})
```
> **`_sanityAsset` is only resolved by `sanity datasets import`.** The NDJSON
> importer fetches each `image@` and swaps in a real asset reference. The
> mutation API does not interpret the directive, so the same blocks written
> through `@sanity/client`, `sanity exec`, or `defineMigration` are stored
> verbatim — leaving an image field with a stray `_sanityAsset` string and no
> `asset` reference. On those paths, upload the image first and emit an asset
> reference instead, as in [Image Upload](#image-upload) below.
### Pre-Processing HTML
Clean HTML before conversion:
```javascript
function cleanHtml(html) {
const dom = new JSDOM(html)
const doc = dom.window.document
// Remove layout elements
doc.querySelectorAll('header, footer, nav, .sidebar').forEach(el => el.remove())
// Extract metadata before processing body
const title = doc.querySelector('title')?.textContent
const description = doc.querySelector('meta[name="description"]')?.content
return {
body: doc.body.innerHTML,
metadata: { title, description }
}
}
```
### Image Upload
Don't just link external images—upload them:
```javascript
async function uploadImage(client, imageUrl) {
const response = await fetch(imageUrl)
const buffer = await response.arrayBuffer()
const asset = await client.assets.upload('image', Buffer.from(buffer), {
filename: imageUrl.split('/').pop()
})
return {
_type: 'image',
asset: { _type: 'reference', _ref: asset._id }
}
}
```
### Using in a Migration
Wrap this in `defineMigration` for controlled imports. This path writes through
the mutation API, so any custom rules used here must emit uploaded asset
references rather than `_sanityAsset` directives:
```typescript
// migrations/import-wordpress-posts/index.ts
import {defineMigration, create} from 'sanity/migrate'
import {htmlToBlocks} from '@portabletext/block-tools'
export default defineMigration({
title: 'Import WordPress posts',
async *migrate(documents, context) {
const posts = await fetchWordPressPosts() // Your import source
for (const post of posts) {
const blocks = htmlToBlocks(post.content, blockContentType, {
parseHtml: html => new JSDOM(html).window.document,
})
yield create({
_type: 'post',
title: post.title,
slug: {_type: 'slug', current: post.slug},
legacyId: String(post.id),
body: blocks,
})
}
}
})
```
Let Sanity generate document IDs for ordinary imported content. Add schema fields for legacy identifiers or slugs, then use GROQ lookups against those fields when you need to rerun an import, patch existing documents, or create references between imported records. Set `_id` directly only for singleton documents.
Run with: `sanity migrations run import-wordpress-posts --no-dry-run`
Reference: [Schema and Content Migrations](https://www.sanity.io/docs/content-lake/schema-and-content-migrations)