Markdown Renderer Complete Guide - Top Parsers Compared with Practical Tutorials
What does a Markdown renderer do? Simply put, it turns .md files into HTML that browsers can understand. Every GitHub README, every blog post on dev platforms, every Notion export — there's a markdown parser working behind the scenes.
This guide covers three things: how renderers work, what makes each popular library different, and how to pick and use them in real projects.
How Markdown Renderers Work
Whether you're using markdown-it, marked, or something else, the core process is similar — three steps:
Step one: Tokenization. The renderer reads raw Markdown text and breaks it into tokens according to the CommonMark spec. For example, ## Heading gets identified as a heading token, and **bold** becomes ** + text + **.
Step two: Building an intermediate structure. Different libraries handle this differently: markdown-it builds a token stream then outputs HTML directly; remark/unified builds a full AST (Abstract Syntax Tree) first, letting you transform it before output.
Step three: Generating HTML. The intermediate structure maps to HTML tags.
Markdown text → Token stream → HTML outputSounds simple, but the devil is in the details. Take this nested list:
- Level one
- Level two
- Level threeDifferent renderers may parse this differently. Some strictly require spaces for indentation (not tabs), others are more lenient. This is why spec compliance matters — we'll get into that later.
Popular Markdown Renderers Compared
There are quite a few markdown to html converter libraries in the JavaScript ecosystem, but the ones with real adoption are these.
markdown-it: The Most Full-Featured Choice
markdown-it is the most mature option available. It strictly follows the CommonMark spec, is safe by default (it won't render <script> tags from user input), and has a rich plugin ecosystem.
const MarkdownIt = require('markdown-it');
const md = new MarkdownIt();
const result = md.render('# Hello Markdown');
// <h1>Hello Markdown</h1>markdown-it supports many configuration options. Here are the ones you'll use most:
const md = new MarkdownIt({
html: false, // Disable raw HTML in Markdown
linkify: true, // Auto-convert URLs to links
typographer: true, // Enable typography replacements (e.g., (c) → ©)
highlight: function (str, lang) {
// Custom code highlighting, can integrate highlight.js
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(str, { language: lang }).value;
}
return '';
}
});Honestly, markdown-it's biggest selling point isn't performance (though it's decent) — it's the plugin system. Footnotes, math formulas, definition lists, emoji, container blocks — there's a plugin for almost everything:
const md = new MarkdownIt()
.use(require('markdown-it-footnote')) // Footnote support
.use(require('markdown-it-mark')) // ==marked text==
.use(require('markdown-it-emoji')); // :emoji: support
md.render('Here is a footnote[^1]\n\n[^1]: Footnote content');With 21k+ GitHub stars and over 880k dependents, it powers VS Code, Dillinger, and many other well-known projects. If you want a safe bet, this is it.
marked: The Speed Champion
marked has a clear positioning: fast. It's a low-level compiler that skips the AST complexity and compiles Markdown directly to HTML.
const { marked } = require('marked');
const html = marked.parse('# Hello **Markdown**');
// <h1>Hello <strong>Markdown</strong></h1>marked has a small codebase, compact bundle size (~19KB minified), and typically tops the benchmarks among mainstream libraries. It achieves 98% CommonMark 0.31 compliance and 97% GFM 0.29 compliance — no issues there.
But there's one thing to watch out for: marked doesn't sanitize HTML by default. If a user inputs <script>alert('xss')</script>, marked outputs it as-is. The official docs recommend pairing it with DOMPurify.
I once used marked to render user-submitted comments in a blog system and missed this detail. Someone submitted a comment containing a <script> tag, which triggered an alert on the page. Adding DOMPurify fixed it:
const { marked } = require('marked');
const DOMPurify = require('isomorphic-dompurify');
const dirty = marked.parse(userInput);
const clean = DOMPurify.sanitize(dirty);With 36k+ stars and over 1.5 million dependents, marked is widely used across the community.
showdown: The Classic Bidirectional Converter
showdown is a veteran library. Its standout feature is bidirectional conversion — it can turn Markdown into HTML and HTML back into Markdown.
const showdown = require('showdown');
const converter = new showdown.Converter();
const html = converter.makeHtml('# Hello');
// <h1>Hello</h1>
const md = converter.makeMd('<h1>Hello</h1>');
// # Helloshowdown boasts 30+ configuration options, supports GitHub Flavored Markdown features (tables, task lists, strikethrough), and has wrapper libraries for Angular and Vue (ng-showdown, vue-showdown).
However, like marked, showdown doesn't sanitize HTML by default, posing XSS risks. And its update frequency is lower than markdown-it and marked.
remark/unified: The Plugin-Powered Ecosystem
remark is part of the unified ecosystem. Its approach differs from the others: it parses Markdown into an AST first, then lets you transform the AST through plugins before outputting HTML.
Markdown → remark AST → Plugin transforms → HTMLThis architecture offers extreme flexibility. Want to auto-add IDs to headings, lint Markdown syntax, convert relative links to absolute, or support MDX? There's a plugin for that.
const { unified } = require('unified');
const remarkParse = require('remark-parse');
const remarkRehype = require('remark-rehype');
const rehypeStringify = require('rehype-stringify');
const result = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeStringify)
.process('# Hello Markdown');Next.js, Astro, Remix — modern frameworks that process Markdown all use remark under the hood. If your project already uses one of these, remark is the natural choice.
The trade-off is a steeper learning curve and potentially larger bundle size (depending on which plugins you use).
Choosing the Right Markdown Renderer
So which one should you pick? My advice: let your use case decide.
| Scenario | Recommendation | Why |
|---|---|---|
| General web projects | markdown-it | Full-featured, plugin-rich, safe by default |
| Maximum performance | marked | Fastest parsing, smallest bundle |
| Bidirectional conversion | showdown | Only option supporting MD↔HTML |
| Next.js/Astro build frameworks | remark/unified | Native support, MDX compatible |
| Ultra-lightweight needs | marked or smaller alternatives | Pick the smallest package |
By the way, if you're already using a framework like React or Vue, you often don't need to interact with the underlying renderer directly — framework-specific components handle it. Let's look at how.
Rendering Markdown in React
In React projects, the most straightforward approach is using the react-markdown component. It's built on remark/unified and is safer than manually calling markdown-it and injecting into dangerouslySetInnerHTML:
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
function MarkdownViewer({ content }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
);
}If you prefer markdown-it, that works too, but pay attention to security. I used this approach in early projects and later realized dangerouslySetInnerHTML is literally named to warn you — there's risk here:
import MarkdownIt from 'markdown-it';
import DOMPurify from 'isomorphic-dompurify';
const md = new MarkdownIt({ html: false });
function MarkdownViewer({ content }) {
const html = DOMPurify.sanitize(md.render(content));
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}For code highlighting, you can pair react-markdown with rehype-highlight or configure highlight.js manually with markdown-it.
Rendering Markdown in Vue
Vue projects have several options too.
Option 1: markdown-it + v-html
<template>
<div class="markdown-body" v-html="renderedHtml"></div>
</template>
<script setup>
import { ref, watch } from 'vue';
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt({ html: false, linkify: true });
const props = defineProps({ source: String });
const renderedHtml = ref('');
watch(() => props.source, (val) => {
renderedHtml.value = md.render(val || '');
}, { immediate: true });
</script>Option 2: vue-showdown
If you like showdown's bidirectional conversion, use the vue-showdown component directly:
<template>
<VueShowdown :markdown="source" flavor="github" />
</template>Both have trade-offs. markdown-it is more flexible with more plugins; vue-showdown is more plug-and-play.
Security: What You Must Know When Rendering Markdown
This topic deserves its own section because many tutorials gloss over it.
When rendering user-submitted Markdown as HTML, you must consider XSS (Cross-Site Scripting). Three scenarios:
Scenario 1: Content from trusted sources (like your own documentation). Security risks are low here, but it's still good practice to set html to false to prevent accidental HTML injection.
Scenario 2: Content from user submissions. This is the most dangerous case. Users can easily embed <script> tags, javascript: protocol links, or onerror event handlers in Markdown. markdown-it filters these by default, but marked and showdown don't.
Scenario 3: You need HTML in Markdown. Sometimes you genuinely need to write HTML (like embedding videos). In this case, use an HTML sanitizer.
Regardless of the scenario, adding a DOMPurify pass is always a good habit when rendering Markdown to HTML:
import DOMPurify from 'isomorphic-dompurify';
// No matter which parser you use, sanitize the output
const safeHtml = DOMPurify.sanitize(parser.render(userInput));Why CommonMark Compliance Matters
You've probably seen the term CommonMark. It's a project that tries to standardize Markdown syntax — because Markdown's creator John Gruber never published a precise spec, different markdown render engines could parse the same text differently.
For example:
**bold *italic***
**bold *italic** text*Different renderers may produce different HTML from this. CommonMark eliminates these ambiguities.
markdown-it is a faithful CommonMark implementation, which is why many people choose it for production projects — predictable behavior, no "different output on different renderers" surprises. marked also achieves 98% CommonMark compliance, which is fine for everyday use, but edge cases might differ from markdown-it's output.
Code Highlighting Integration
Code highlighting is the most common companion feature for a Markdown parser. Regardless of which library you pick, the integration approach is similar.
markdown-it + highlight.js
const hljs = require('highlight.js');
const md = require('markdown-it')({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
'</code></pre>';
} catch (__) {}
}
return '<pre class="hljs"><code>' + md.utils.escapeHtml(str) + '</code></pre>';
}
});marked + highlight.js
marked's configuration is similar, using marked.setOptions:
const { marked } = require('marked');
const hljs = require('highlight.js');
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return code;
}
});Don't forget to include highlight.js's CSS theme file in your page, or you'll get markup without colors.
Customizing Render Behavior
Sometimes you need to change the renderer's default output — adding target="_blank" to all links, lazy loading to images, or custom heading ID generation.
markdown-it Custom Render Rules
const md = require('markdown-it')();
// Add target="_blank" to all links
const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrSet('target', '_blank');
tokens[idx].attrSet('rel', 'noopener noreferrer');
return defaultRender(tokens, idx, options, env, self);
};marked Custom Renderer
const { marked } = require('marked');
const renderer = {
heading(text, depth) {
const slug = text.toLowerCase().replace(/[^\w]+/g, '-');
return `<h${depth} id="${slug}">${text}</h${depth}>`;
},
image(href, title, text) {
return `<img src="${href}" alt="${text}" loading="lazy" />`;
}
};
marked.use({ renderer });Neither approach is particularly complex, but markdown-it's token manipulation is more flexible for scenarios requiring fine-grained control.
References
- CommonMark Spec — Markdown syntax standardization specification
- markdown-it GitHub — Official markdown-it repository and documentation
- Marked.js Official Docs — Marked renderer spec compliance report
- MDN: innerHTML Security — About innerHTML and XSS safety
- DOMPurify — HTML sanitizer library for XSS prevention