Markdown to Portable Text
Convert Markdown strings into Portable Text blocks. Use this for importing content from Markdown-based systems (static site generators, GitHub READMEs, AI-generated content), processing user input, or migrating from Markdown-first CMSes.
Looking to render Portable Text as Markdown? See the Markdown rendering guide.
Install
Section titled “Install”npm i @portabletext/markdownpnpm add @portabletext/markdownyarn add @portabletext/markdownBasic usage
Section titled “Basic usage”markdownToPortableText takes a Markdown string and returns an array of Portable Text blocks.
import {markdownToPortableText} from '@portabletext/markdown'
const blocks = markdownToPortableText('# Hello **world**')Standard Markdown elements (headings, paragraphs, bold, italic, links, lists, blockquotes, inline code) are handled automatically using the default schema.
Schema configuration
Section titled “Schema configuration”The conversion is schema-driven. The library only outputs types that exist in the schema, so the output always matches your content model.
The default schema includes:
| Type | Values |
|---|---|
styles |
normal, h1-h6, blockquote |
lists |
bullet, number, task |
decorators |
strong, em, code, strike-through |
annotations |
link (fields: href, title) |
blockObjects |
code, image, horizontal-rule, html, table, callout |
inlineObjects |
image |
To use a custom schema, build one with compileSchema and defineSchema from @portabletext/schema and pass it as the schema option; a Sanity schema converts first through @portabletext/sanity-bridge. The package README has the configuration recipes for both.
Matchers
Section titled “Matchers”Matchers control how Markdown elements map to schema types. The library includes defaults for all standard elements. You can override individual matchers when your schema uses different type names.
| Group | Matcher | Markdown | Maps to |
|---|---|---|---|
block |
normal |
Paragraphs | 'normal' |
h1-h6 |
#-###### headings |
'h1'-'h6' |
|
blockquote |
> blockquotes |
'blockquote' |
|
listItem |
bullet |
- or * lists |
'bullet' |
number |
1. ordered lists |
'number' |
|
marks |
strong |
**bold** |
'strong' |
em |
*italic* |
'em' |
|
code |
`inline code` |
'code' |
|
strikeThrough |
~~strikethrough~~ |
'strike-through' |
|
link |
[text](url "title") |
'link' |
|
types |
code |
Fenced code blocks | 'code' |
horizontalRule |
--- |
'horizontal-rule' |
|
image |
 |
'image' |
|
html |
HTML blocks | 'html' |
|
callout |
> [!NOTE], etc. |
'callout' |
Override a matcher when your schema uses a different name for a type (say, 'heading 1' instead of 'h1'): pass your own function under the matcher’s group and name, resolve the type against context.schema, and return its name, or undefined to skip the element gracefully. The package README has worked matcher recipes, including the structural table, list, and blockquote shapes.
Supported features
Section titled “Supported features”| Feature | Markdown to PT |
|---|---|
| Headings (h1-h6) | ✅ |
| Paragraphs | ✅ |
| Bold | ✅ |
| Italic | ✅ |
| Inline code | ✅ |
| Strikethrough | ✅ |
| Links | ✅ |
| Blockquotes | ✅ |
| Ordered lists | ✅ |
| Unordered lists | ✅ |
| Task lists | ✅ |
| Nested lists | ✅ |
| Code blocks | ✅ |
| Horizontal rules | ✅ |
| Images | ✅ |
| Tables | ✅ |
| HTML blocks | ✅ |
| Callouts | ✅ |
Round-trip behavior
Section titled “Round-trip behavior”Converting Markdown to Portable Text and back isn’t a lossless mirror. Five things to expect:
- Translation normalizes rather than preserves: a first Markdown → Portable Text → Markdown pass rewrites Markdown to one canonical spelling. Autolinks and reference links become inline links, indented code becomes fenced code, and
<https://portabletext.org>comes back as[https://portabletext.org](https://portabletext.org). - The normalized form is a fixpoint for the constructs in the table above: parsing it and serializing again reproduces it byte-for-byte. The exception is plain text containing literal Markdown punctuation: serialization doesn’t yet escape it, so
\*bar\*comes back as*bar*, which a second parse reads as emphasis. - Unrecognized constructs degrade, they don’t fail. A mark, list, or task checkbox whose type isn’t in the schema keeps its text and drops the formatting: an undeclared
strongdecorator turns**bar**into a plain span readingbar. - Portable Text structures with no Markdown form degrade predictably going back out. GFM has one header row, so extra header rows flatten into the body; deep or level-skipping lists collapse to relative nesting; unknown object types render as a fenced JSON block.
- Keys and span boundaries aren’t identity: every parse regenerates block and span keys, and adjacent spans with identical marks merge.
Other conversion paths
Section titled “Other conversion paths”| Source format | Tool |
|---|---|
| HTML → PT | @portabletext/html |
| Gutenberg → PT | @emdash-cms/gutenberg-to-portable-text (30+ block types) |
| Contentful → PT | @portabletext/contentful-rich-text-to-portable-text |
Further reading
Section titled “Further reading”- Markdown rendering guide for converting PT blocks to Markdown strings
@portabletext/markdownon GitHub for full API documentation and changelog