marked
marked turns a Markdown string into an HTML string. Call marked.parse('# hi') and you get '<h1>hi</h1>'. It is a single-pass lexer and parser with no dependencies, it supports CommonMark plus GitHub Flavored Markdown, and it runs the same in Node, a browser, a worker, or from a CLI. Extension points are real rather than decorative: you can override the renderer for any token type, replace or add tokenizers for new syntax, walk every token before rendering, and hook the string before and after parsing. What it deliberately does not do is sanitize. The HTML it emits contains whatever HTML the Markdown contained, so any untrusted input has to pass through DOMPurify or an equivalent before it reaches a page.
The fastest way to get Markdown onto a page when the content is trusted and you want no dependency graph. Choose markdown-it or remark instead when the input comes from users, when you need to transform an AST, or when you cannot absorb a breaking major roughly twice a year.
Use it if
- You need Markdown to HTML with no build step and no plugin graph: one import, one function call, zero dependencies, about 12 KB gzipped in a browser bundle
- You are rendering trusted content such as your own docs, changelogs, or CMS output written by staff, where sanitizing is a single extra call rather than an architecture
- You want to customize output without forking: overriding renderer methods per token type or adding a block-level extension is a documented, supported path
- You need the same parser in Node and the browser, including a UMD build you can load from a CDN with a script tag
- You are rendering user-submitted Markdown and will not add a sanitizer: marked passes raw HTML through by design, so `<img src=x onerror=...>` in a comment becomes a live XSS unless you pipe the output through DOMPurify yourself
- You need an AST you can transform, lint, or convert to another format: remark and the unified ecosystem give you a documented mdast tree with hundreds of plugins, while marked's tokens are an internal shape that changes across majors
- You depend on a large ecosystem of syntax plugins: markdown-it has a deeper plugin catalogue and a stable plugin API, whereas marked shipped six major versions in about two years and broke renderer and extension contracts in several of them
- You are on CommonJS or Node 18: there has been no CJS build since v16 and v18 requires Node 20 or newer, so require() fails and Jest needs transformIgnorePatterns before it will even load the package
- You parse attacker-controlled documents on a request path: the docs themselves recommend running marked in a worker with a timeout because pathological input can trigger catastrophic backtracking
Setup reality
npm install marked, then `import { marked } from 'marked'`. That is genuinely it for the happy path, since there are no dependencies and types ship in the package. The friction is module format and safety. marked is ESM-only since v16, so CommonJS projects get ERR_REQUIRE_ESM and Jest needs `transformIgnorePatterns: ['/node_modules/(?!(marked)/)']`, which the v16 release notes call out explicitly. v18 requires Node 20 or newer. In the browser, load lib/marked.umd.js or the ESM build from a CDN rather than the old marked.min.js path, which was removed. Then there is state: the exported `marked` object is a global singleton, so calling marked.use() anywhere mutates it for every other module in the process, and calling marked.use() inside a component render or a loop stacks the same extension repeatedly until you get a recursion error. Use `new Marked(...)` when a library or a component owns its own configuration. Finally, several options people look for are gone rather than renamed: sanitize, mangle, headerIds, highlight, langPrefix, baseUrl, smartypants, and xhtml were all removed in v8 and live in separate marked-* packages or in a sanitizer you install yourself.
Patterns
Render Markdown to HTMLparse-markdown
import { marked } from 'marked';
const html = marked.parse('# Title\n\nSome **bold** text.');
console.log(html);
// <h1>Title</h1>\n<p>Some <strong>bold</strong> text.</p>parse returns a string unless the async option is on, in which case it returns a promise. There is no CommonJS build since v16, so require('marked') throws ERR_REQUIRE_ESM.
Make untrusted Markdown safe to insertsanitize-output
import { marked } from 'marked';
import DOMPurify from 'dompurify';
const dirty = marked.parse(userInput);
const clean = DOMPurify.sanitize(dirty);
document.getElementById('content').innerHTML = clean;This is not optional for user input. The sanitize option was removed in v8 and marked emits inline HTML verbatim, so skipping DOMPurify turns any comment box into stored XSS. On the server use isomorphic-dompurify or run DOMPurify against jsdom.
Configure parsing behaviourset-options
import { marked } from 'marked';
marked.use({
gfm: true, // default
breaks: true, // single newline becomes <br>, needs gfm
pedantic: false,
silent: false
});marked.use mutates the shared global instance, so this affects every module in the process. Call it once at module top level, never inside a function or a component body, or extensions stack up until parsing recurses.
Keep configuration local to your moduleisolated-instance
import { Marked } from 'marked';
const md = new Marked({ gfm: true, breaks: true });
export const render = (src) => md.parse(src);Use this in any library or shared component. The global `marked` export is a singleton and a dependency that calls marked.use() will silently change your output.
Override the HTML for one token typecustom-renderer
import { marked } from 'marked';
marked.use({
renderer: {
heading({ tokens, depth }) {
const text = this.parser.parseInline(tokens);
const id = text.toLowerCase().replace(/[^\w]+/g, '-');
return `<h${depth} id="${id}">${text}</h${depth}>`;
}
}
});Since v13 renderer methods take a single token object, not positional arguments, and you render children yourself via this.parser.parseInline or this.parser.parse. Returning false falls through to the previous override or the default.
Add syntax marked does not knowcustom-extension
import { marked } from 'marked';
const callout = {
name: 'callout',
level: 'block',
start(src) { return src.match(/^:::/)?.index; },
tokenizer(src) {
const rule = /^:::(\w+)\n([\s\S]*?)\n:::\n?/;
const match = rule.exec(src);
if (!match) return;
return { type: 'callout', raw: match[0], kind: match[1], tokens: this.lexer.blockTokens(match[2]) };
},
renderer(token) {
return `<aside class="callout ${token.kind}">${this.parser.parse(token.tokens)}</aside>`;
}
};
marked.use({ extensions: [callout] });start() tells the lexer where your syntax might begin so it does not swallow the block as a paragraph; leaving it out is the usual reason a custom extension never fires. Register extensions once, in module scope.
Rewrite every token of a kind before renderingwalk-tokens
import { marked } from 'marked';
marked.use({
walkTokens(token) {
if (token.type === 'link' && /^https?:/.test(token.href)) {
token.href += (token.href.includes('?') ? '&' : '?') + 'ref=docs';
}
}
});Tokens are passed by reference so mutations stick. Child tokens are visited before siblings. Multiple walkTokens functions run in reverse registration order, with the last registered one first.
Await work inside the parse pipelineasync-rendering
import { marked } from 'marked';
marked.use({
async: true,
async walkTokens(token) {
if (token.type === 'code' && token.lang === 'mermaid') {
token.text = await renderMermaid(token.text);
}
}
});
const html = await marked.parse(source);With async: true, marked.parse returns a promise and forgetting to await it gives you '[object Promise]' in the page. An extension that sets async: true will throw if you explicitly pass async: false.
Highlight fenced code blockssyntax-highlighting
import { marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
import hljs from 'highlight.js';
marked.use(markedHighlight({
emptyLangClass: 'hljs',
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
}
}));The highlight and langPrefix options were removed from core in v8; marked-highlight is the official replacement. Highlighted output contains HTML, so if you also sanitize, allow the class attribute or your colours vanish.
Render a fragment without a wrapping paragraphparse-inline
import { marked } from 'marked';
console.log(marked.parse('**strong** _em_'));
// <p><strong>strong</strong> <em>em</em></p>
console.log(marked.parseInline('**strong** _em_'));
// <strong>strong</strong> <em>em</em>parseInline is what you want for titles, table cells, button labels, and anywhere a stray <p> would break layout. It also skips block syntax entirely, so a heading marker stays literal text.
Look at the token tree instead of HTMLinspect-tokens
import { marked } from 'marked';
const tokens = marked.lexer('# Title\n\n- one\n- two');
console.dir(tokens, { depth: null });
const html = marked.parser(tokens);Handy for extracting the first heading, building a table of contents, or debugging why an extension did not match. Treat the token shape as version-specific; v13, v17, and v18 each changed how list and text tokens are structured.
Bound parse time for untrusted documentsworker-timeout
// markedWorker.js
import { marked } from 'marked';
import { parentPort } from 'node:worker_threads';
parentPort.on('message', (src) => parentPort.postMessage(marked.parse(src)));
// caller
import { Worker } from 'node:worker_threads';
const worker = new Worker('./markedWorker.js');
const timer = setTimeout(() => { worker.terminate(); throw new Error('marked took too long'); }, 2000);
worker.on('message', (html) => { clearTimeout(timer); worker.terminate(); use(html); });
worker.postMessage(userMarkdown);The docs recommend this specifically to limit the blast radius of a regular-expression denial of service. Parsing is synchronous and CPU-bound, so a pathological document on the main thread blocks the whole event loop.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| markdown-it | npm | You want a stable plugin API, a bigger plugin catalogue, and an optional built-in HTML-escaping mode rather than relying on an external sanitizer. |
| remark | npm | You need a real syntax tree to inspect, lint, or transform, or you are already in the unified ecosystem with MDX and rehype. |
| micromark | npm | You want strict CommonMark and GFM compliance in a small, streaming-friendly core and are happy to build your own layer on top. |