markdown-it
markdown-it turns Markdown text into HTML. It passes the full CommonMark specification, adds the sugar people expect on top (tables, strikethrough, URL autolinking, smart quotes), and escapes raw HTML by default so untrusted input does not become script tags. The part that separates it from simpler parsers is the pipeline: parsing produces a flat stream of Token objects, a Renderer walks that stream, and both the parser rules and the per-token render functions are addressable by name. That means you can disable a syntax, insert a new one, or rewrite how one token type prints without forking anything, which is why almost every plugin-driven Markdown ecosystem in JavaScript is built on it.
The default Markdown parser for JavaScript when you need correctness plus extension points, and the engine under a large slice of the static-site and docs tooling people already use. Check the v15 migration notes before upgrading, and never enable raw HTML for untrusted input without a sanitizer.
Use it if
- You need real CommonMark compliance rather than a regex-based approximation that disagrees with GitHub on edge cases
- You want to change how output is generated per token, for example adding target="_blank" to external links or ids to headings, without post-processing HTML strings
- You need plugins: footnotes, containers, attributes, anchors, definition lists and dozens more already exist and all use the same md.use() interface
- You want to restrict the syntax, for example a comment box that allows only emphasis and links, which the "zero" preset plus enable() does directly
- You are on TypeScript: since v15 the types ship inside the package, so @types/markdown-it is no longer needed
- Bundle size drives the decision. Roughly 108 kB minified (47 kB gzipped) plus six runtime dependencies is heavy for a client-side app that renders one comment field. marked is a fraction of that if you only need HTML out.
- You want to transform document structure. markdown-it hands you a flat token stream with nesting encoded as open and close tokens, not a tree. Anything shaped like "move all images into a figure" is much easier in remark, which gives you a real mdast AST.
- You need to read Markdown, edit it, and write Markdown back out. This is a one-way renderer to HTML. There is no formatter and no source round trip.
- You think html: true is safe because the library says it is safe by default. It is safe only while raw HTML stays escaped. Turn that option on for user content and you own the XSS problem, so pair it with DOMPurify or do not turn it on.
- You render huge documents in a request handler. Parsing is synchronous and single-threaded, so a multi-megabyte input blocks the event loop for the whole parse.
- You are upgrading from v14 casually. Fuzzy linkification like bare example.com is off by default now, deep imports such as markdown-it/lib/token.mjs are no longer exported, three md.utils helpers were deleted, and browser dist paths moved.
Setup reality
npm install markdown-it is genuinely all of it: no peer dependencies, no native build, no config file. Three things still bite. First, if you are coming from v14 you must uninstall @types/markdown-it, because v15 bundles its own types and having both produces duplicate-declaration errors that look like a tsconfig problem. Second, the import shape catches people out: the package default-exports the class, so it is import MarkdownIt from 'markdown-it' in ESM and const MarkdownIt = require('markdown-it') in CommonJS, and plugin packages vary in whether their own default export is wrapped. Third, plugins are ordinary functions passed to md.use(), so they are unversioned against the core; a plugin written for v13 internals can silently produce wrong output on v15 rather than throwing, and the project only guarantees that plugins from the markdown-it organisation are v15-compatible.
Patterns
Render a Markdown string to HTMLrender-markdown
import MarkdownIt from 'markdown-it'
const md = new MarkdownIt()
const html = md.render('# Hello\n\nSome **bold** text.')Create the instance once and reuse it. Constructing a new MarkdownIt per request re-registers every rule and is measurably slower than reusing a module-level singleton.
Set the common optionsconfigure-options
const md = new MarkdownIt({
html: false, // escape raw HTML in the source
linkify: true, // turn URLs into links
typographer: true, // smart quotes and dashes
breaks: false, // single newline is not a <br>
langPrefix: 'language-',
})html: false is the default and is what makes the library safe for untrusted input. breaks: true is the one people usually want for chat-style input, where users expect Enter to mean a line break.
Start from a stricter or looser presetchoose-preset
const strict = new MarkdownIt('commonmark') // spec only, no extensions
const loose = new MarkdownIt('default') // spec plus tables, strikethrough
const minimal = new MarkdownIt('zero') // everything disabled
minimal.enable(['emphasis', 'link', 'linkify'])The zero preset plus an explicit enable list is the right way to build a restricted input, for example a comment box that permits bold and links but not headings, images or HTML.
Highlight fenced code blockssyntax-highlighting
import hljs from 'highlight.js'
const md = new MarkdownIt({
highlight(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre><code class="hljs">' +
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
'</code></pre>'
} catch {}
}
return '<pre><code class="hljs">' + md.utils.escapeHtml(str) + '</code></pre>'
},
})Whatever you return is inserted as raw HTML, including the pre and code wrapper, so escaping is your job in the fallback path. Return an empty string instead to let markdown-it emit its own escaped block.
Add pluginsuse-plugin
import MarkdownIt from 'markdown-it'
import footnote from 'markdown-it-footnote'
import anchor from 'markdown-it-anchor'
const md = new MarkdownIt()
.use(footnote)
.use(anchor, { permalink: anchor.permalink.headerLink() })use() returns the instance so calls chain, and order matters when two plugins touch the same rule. Only plugins from the markdown-it organisation are guaranteed compatible with v15; third-party ones that reach into internals may need checking.
Render without wrapping paragraph tagsrender-inline
md.renderInline('A **bold** label')
// 'A <strong>bold</strong> label'
md.render('A **bold** label')
// '<p>A <strong>bold</strong> label</p>\n'renderInline skips all block-level parsing, so headings, lists and code fences are treated as plain text. Use it for single-line fields such as titles or table cells.
Open external links in a new tabcustomize-link-rendering
const defaultRender = md.renderer.rules.link_open ||
((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options))
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const href = tokens[idx].attrGet('href')
if (href && /^https?:\/\//.test(href)) {
tokens[idx].attrSet('target', '_blank')
tokens[idx].attrSet('rel', 'noopener noreferrer')
}
return defaultRender(tokens, idx, options, env, self)
}Always capture the previous rule and call it, rather than rebuilding the tag yourself. Skipping that is how people accidentally drop attributes that a plugin added earlier in the chain.
Reject links you do not trustrestrict-url-schemes
const ALLOWED = /^(https?:|mailto:|#|\/)/
md.validateLink = (url) => ALLOWED.test(url.trim().toLowerCase())markdown-it already blocks javascript:, vbscript: and most data: URLs by default. Override validateLink only to tighten the rule; a permissive override that returns true for everything reopens a real injection hole.
Autolink bare domainsenable-linkify-fuzzy
const md = new MarkdownIt({ linkify: true })
md.linkify.set({ fuzzyLink: true })
md.render('visit example.com')As of v15 the underlying linkify-it no longer treats bare example.com as a link by default; only URLs with a scheme are picked up. Turning fuzzyLink back on restores the v14 behaviour.
Parse to tokens instead of HTMLinspect-tokens
const tokens = md.parse('# Title\n\n- one\n- two', {})
for (const token of tokens) {
console.log(token.type, token.tag, token.nesting, token.content)
}
const html = md.renderer.render(tokens, md.options, {})The stream is flat: nesting is expressed as paired tokens with nesting 1 and -1 rather than children arrays, except for inline tokens which carry their own children array. This is the main structural difference from remark.
Post-process tokens with a core ruleadd-core-rule
md.core.ruler.push('heading_ids', (state) => {
for (let i = 0; i < state.tokens.length; i++) {
const token = state.tokens[i]
if (token.type !== 'heading_open') continue
const text = state.tokens[i + 1].content
token.attrSet('id', text.toLowerCase().replace(/[^\w]+/g, '-'))
}
})Core rules run after block and inline parsing, which makes them the simplest place to walk the finished token list. The inline token always sits directly after its heading_open, which is what makes this lookahead safe.
Get Token and the parser classes in v15access-internal-classes
import MarkdownIt from 'markdown-it'
const { Token, StateBlock, StateInline, Ruler, Renderer } = MarkdownIt
const token = new Token('html_block', '', 0)
token.content = '<hr>'v15 stopped exporting package-internal paths such as markdown-it/lib/token.mjs and hung these classes off the main export instead. Utilities and helpers still live on the instance as md.utils and md.helpers.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| marked | npm | You want the smallest fast Markdown to HTML converter and do not need a plugin pipeline |
| remark | npm | You need to inspect or rewrite document structure as an AST, or convert between formats |
| micromark | npm | You want a tiny, strictly compliant CommonMark tokenizer to build your own layer on |