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 output

Sounds simple, but the devil is in the details. Take this nested list:

- Level one
  - Level two
    - Level three

Different 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.

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>');
// # Hello

showdown 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 → HTML

This 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.

ScenarioRecommendationWhy
General web projectsmarkdown-itFull-featured, plugin-rich, safe by default
Maximum performancemarkedFastest parsing, smallest bundle
Bidirectional conversionshowdownOnly option supporting MD↔HTML
Next.js/Astro build frameworksremark/unifiedNative support, MDX compatible
Ultra-lightweight needsmarked or smaller alternativesPick 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

  1. CommonMark Spec — Markdown syntax standardization specification
  2. markdown-it GitHub — Official markdown-it repository and documentation
  3. Marked.js Official Docs — Marked renderer spec compliance report
  4. MDN: innerHTML Security — About innerHTML and XSS safety
  5. DOMPurify — HTML sanitizer library for XSS prevention