Migrating from Ghost: Problem Solving
Great question! Converting markdown to Portable Text directly is generally the better path rather than going markdown → HTML → Portable Text. Here's why and how to approach it:
Direct Markdown → Portable Text is Recommended
Going directly from markdown to Portable Text preserves the semantic structure better and avoids introducing HTML-specific quirks. The markdown → HTML → Portable Text route adds an unnecessary transformation step that can introduce edge cases.
The Best Tools for the Job
@sanity/block-tools (now also published as @portabletext/block-tools) is your primary library here. It includes htmlToBlocks which works with the Ghost guide you linked, but more importantly for your case, you can use it with remark/rehype to process markdown directly.
The typical workflow looks like:
- Markdown → MDAST (using remark-parse)
- MDAST → HAST (using remark-rehype)
- HAST → HTML (using rehype-stringify)
- HTML → Portable Text (using
htmlToBlocksfrom @sanity/block-tools)
Here's a basic implementation:
import {unified} from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeStringify from 'rehype-stringify'
import {htmlToBlocks} from '@sanity/block-tools'
import {Schema} from '@sanity/schema'
import {JSDOM} from 'jsdom'
// Load your Sanity schema
const defaultSchema = Schema.compile({
name: 'default',
types: [/* your schema types */]
})
const blockContentType = defaultSchema
.get('blockContent') // or whatever your PT field is called
.jsonType
async function markdownToPortableText(markdown) {
// Convert markdown to HTML via remark
const file = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeStringify)
.process(markdown)
const html = String(file)
// Convert HTML to Portable Text blocks
const blocks = htmlToBlocks(html, blockContentType, {
parseHtml: (htmlString) => new JSDOM(htmlString).window.document
})
return blocks
}Why Direct Conversion is Better
As mentioned in the content migration principles, migrations should be incremental and idempotent. Converting directly from markdown:
- Preserves semantic meaning better (headings, lists, emphasis)
- Avoids HTML-specific quirks like inline styles or presentational markup
- Gives you cleaner Portable Text that's easier to query with GROQ
- Reduces transformation complexity and potential bugs
The @sanity/block-tools package handles basic rich text formatting like headings, paragraphs, and lists automatically. You can also add custom deserialization rules to handle specific markdown patterns that need special treatment (like code blocks, images, or custom syntax).
Alternative: Keep Markdown Native
If you want to keep markdown as-is in Sanity and render it later, consider sanity-plugin-markdown. This stores markdown natively and provides a markdown editor in the Studio. However, you lose the powerful querying benefits of Portable Text.
The guide you linked is solid for HTML sources (like Ghost exports), but since you're starting with markdown, the remark/rehype ecosystem gives you a mature, well-maintained foundation for your migration scripts. The intermediate HTML step is quick and leverages the robust htmlToBlocks function that already handles edge cases well.
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.