How to Convert Markdown to HTML

Markdown is a plain-text syntax that converts to HTML — headings, bold, lists, links, and code all map to specific tags. But the details matter: which Markdown flavor you target changes the output, and rendering untrusted Markdown without sanitizing it is a real XSS hole. This guide covers the conversion in JavaScript and Python with the libraries people actually use, the difference between CommonMark and GitHub Flavored Markdown, how to sanitize safely, and the pitfalls that bite. Test any snippet against the Markdown to HTML converter tool — it renders in your browser so nothing leaves your machine.

The Basics: Markdown to Tags

Markdown is a plain-text syntax. Every construct maps to a specific HTML element — once you know the mapping, the conversion holds no surprises. Given this Markdown:

# Getting Started

Install with **npm** and read the *docs*.

- First item
- Second item

See the [guide](https://janeer.com/guides/) for more.

```js
const x = 1;
```

A standard converter produces this HTML:

<h1>Getting Started</h1>
<p>Install with <strong>npm</strong> and read the <em>docs</em>.</p>
<ul>
<li>First item</li>
<li>Second item</li>
</ul>
<p>See the <a href="https://janeer.com/guides/">guide</a> for more.</p>
<pre><code class="language-js">const x = 1;
</code></pre>

The mapping in full: # through ###### become <h1><h6>; **text** becomes <strong> and *text* becomes <em>; - or * bullet lines become <ul><li> and 1. lines become <ol><li>; [text](url) becomes <a href>; a triple-backtick fence becomes <pre><code> (with a language-* class taken from the fence info string); and inline backticks become <code>. Everything else is wrapped in a <p>.

Markdown Flavors Matter

There is no single Markdown — there are flavors, and the flavor you target changes the output. The three that matter most:

  • CommonMark — the strict specification. It defines exactly how the core syntax (headings, emphasis, lists, links, code, blockquotes) converts to HTML, resolving the ambiguities of the original 2004 Markdown. If you want predictable, portable output, target CommonMark.
  • GitHub Flavored Markdown (GFM) — a superset of CommonMark that adds tables, strikethrough (~~text~~), task lists (- [ ] and - [x]), autolinks (a bare URL becomes a link), and fenced code blocks. This is what you see on GitHub, GitLab, and most developer-facing platforms.
  • Other extensions — footnotes, definition lists, table-of-contents generation, math ($...$) and more, available as opt-in plugins in most libraries.

This is the root cause of most "why doesn't my Markdown render right" issues. A pipe-delimited table:

| Name | Role |
| ---- | ---- |
| Ada  | Dev  |

renders as an HTML <table> only if GFM (or a tables extension) is enabled. With a plain CommonMark parser, the same text comes out as a literal paragraph full of pipe characters. "My table shows as plain text" is almost always a flavor mismatch, not a syntax mistake. Pick a flavor deliberately and enable the matching extensions.

JavaScript

marked is the fast, simple default — one function call:

// npm install marked
import { marked } from 'marked';

const md = '# Hello\n\nThis is **bold** text.';
const html = marked.parse(md);
// <h1>Hello</h1>\n<p>This is <strong>bold</strong> text.</p>

// GFM (tables, strikethrough, task lists) is on by default in marked.
// Enable line breaks on single newlines if you want them:
marked.setOptions({ gfm: true, breaks: true });

markdown-it is the other mainstream choice — strictly CommonMark-compliant, with a large plugin ecosystem (footnotes, anchors, container blocks, and more):

// npm install markdown-it
import MarkdownIt from 'markdown-it';

const md = new MarkdownIt({
  html: false,        // do NOT pass raw HTML through (safer default)
  linkify: true,      // autolink bare URLs
  typographer: true,  // smart quotes and dashes
});

const html = md.render('# Hello\n\nVisit https://janeer.com');

For build pipelines that need to transform the document — rewrite links, extract a table of contents, inject components — reach for remark / unified, which parse Markdown into an AST you can manipulate before serializing to HTML with remark-rehype and rehype-stringify. It is heavier than marked but far more flexible. The Janeer Markdown to HTML converter does the conversion entirely in the browser.

Python

python-markdown is the standard library for this. Extensions opt into GFM-style features:

# pip install markdown
import markdown

text = '''# Hello

This is **bold** text.

| Name | Role |
| ---- | ---- |
| Ada  | Dev  |'''

html = markdown.markdown(
    text,
    extensions=['tables', 'fenced_code', 'toc'],
)
# Without the 'tables' extension the table renders as a plain paragraph.

Useful built-in extensions: fenced_code (triple-backtick blocks), tables, toc (heading anchors + a table of contents), codehilite (Pygments highlighting), and nl2br (treat single newlines as <br>).

mistune is a faster alternative with GFM features available out of the box:

# pip install mistune
import mistune

html = mistune.html('# Hello\n\nThis is **bold** text.')

# For more control, build a Markdown instance with plugins:
md = mistune.create_markdown(plugins=['table', 'strikethrough', 'task_lists'])
html = md('- [x] done\n- [ ] todo')

Sanitize Untrusted Markdown — This Is the Important One

Markdown is not a safe-by-default format. It lets raw HTML pass straight through to the output, and it allows javascript: URLs in links. So rendering Markdown that came from a user — comments, profiles, issue bodies, anything you didn't write yourself — and dropping the result into the page is a textbook cross-site scripting (XSS) hole. For example, this Markdown:

Click [here](javascript:alert(document.cookie))

<img src=x onerror="alert('xss')">

can produce HTML that executes script the moment it is inserted into the DOM. The rule: never set innerHTML from unsanitized Markdown output.

In the browser, run the generated HTML through DOMPurify before it ever touches the DOM:

// npm install marked dompurify
import { marked } from 'marked';
import DOMPurify from 'dompurify';

const dirty = marked.parse(untrustedMarkdown);
const clean = DOMPurify.sanitize(dirty);

// Only now is it safe to insert:
document.getElementById('out').innerHTML = clean;

Sanitize the HTML output, not the Markdown input — trying to filter Markdown source is whack-a-mole because there are too many ways to express the same dangerous output.

Server-side, use a dedicated sanitizer after rendering. In Python, bleach (allowlist-based):

# pip install markdown bleach
import markdown, bleach

dirty = markdown.markdown(untrusted_text)
clean = bleach.clean(
    dirty,
    tags=['p', 'a', 'strong', 'em', 'ul', 'ol', 'li', 'code', 'pre', 'h1', 'h2', 'h3'],
    attributes={'a': ['href', 'title']},
    protocols=['http', 'https', 'mailto'],  # blocks javascript: URLs
)

In Node, sanitize-html plays the same role. Alternatively, configure the renderer to escape or strip raw HTML entirely — markdown-it does this by default (html: false), and most parsers have an equivalent switch. Disabling raw HTML handles the injected-tag case, but you still want a sanitizer to catch dangerous URL schemes.

Syntax Highlighting for Code Blocks

A converter turns a fenced code block into <pre><code class="language-js">...</code></pre> but does not colorize the tokens — highlighting is a separate concern handled by highlight.js or Prism. There are two common ways to wire it up.

Post-processing. Render the Markdown, insert the HTML, then run the highlighter over the page:

document.getElementById('out').innerHTML = DOMPurify.sanitize(marked.parse(md));
hljs.highlightAll(); // highlight.js scans for <pre><code> and colorizes

Inline via the parser. Both marked and markdown-it accept a highlight hook so each fenced block is colorized as it is rendered:

import { marked } from 'marked';
import hljs from 'highlight.js';

marked.setOptions({
  highlight(code, lang) {
    return hljs.getLanguage(lang)
      ? hljs.highlight(code, { language: lang }).value
      : hljs.highlightAuto(code).value;
  },
});

In Python, python-markdown's codehilite extension does the same with Pygments at render time. Either way, remember to include the highlighter's CSS theme — without it, the markup is there but the colors are not.

Server-Side vs Client-Side Rendering

Where you do the conversion is a real tradeoff:

  • Build time (static site generators). Tools like Astro, Eleventy, Hugo, and Jekyll convert Markdown to HTML once, during the build. The browser receives plain HTML — best for SEO (crawlers see the content immediately), fastest to display, and the conversion library never ships to the client. This is the right default for blogs, docs, and content sites.
  • Server-side at request time. Render in your backend when content changes per request (user-generated comments, dynamic pages). You control the sanitizer centrally and can cache the rendered HTML. Slightly more server work per request.
  • Client-side in the browser. Convert with marked or markdown-it on the page — handy for live preview editors and for keeping content on the user's machine (the Janeer converter works this way). The cost: search engines may not see the rendered output, the parser bundle ships to every visitor, and you must sanitize in the browser before inserting.

If the content is authored by you and known at build time, render at build time. If it is untrusted and dynamic, render server-side with a sanitizer. Reserve client-side rendering for previews and privacy-first tools.

Common Pitfalls

Raw HTML passthrough

By spec, Markdown lets you embed raw HTML, and most parsers pass it through untouched. That is a feature for trusted authors and a vulnerability for untrusted input. Decide which mode you are in: disable HTML (html: false in markdown-it, or sanitize after) for anything user-submitted.

Single-newline line breaks

In CommonMark, a single newline inside a paragraph is collapsed to a space — you need two trailing spaces or a blank line to force a <br>. GFM and chat-style Markdown (Slack, Discord) often treat every newline as a break. Parsers expose this as a breaks option (marked, markdown-it) or the nl2br extension (python-markdown). Mismatched expectations here produce "all my lines got joined together."

Smart quotes and typography

The typographer option in markdown-it (and similar features elsewhere) rewrites straight quotes to curly ones, -- to en/em dashes, and ... to an ellipsis. Lovely for prose, but it will silently alter code-like text outside fenced blocks. Turn it off if exact characters matter.

Relative links and images

[docs](../guide) and ![logo](images/logo.png) are emitted as relative URLs. They resolve against the page the HTML is served from, not against where the Markdown lived. If you render Markdown that assumed a different base path, rewrite the URLs (a remark/rehype plugin, or a post-process pass) so the links and images don't 404.

Inconsistent table and extension support

Tables, task lists, footnotes, and strikethrough are extensions, not core Markdown. A document that renders perfectly on GitHub can come out half-broken through a bare CommonMark parser. Match the extension set to the source of your Markdown, and when in doubt, target GFM since it is what most authors assume.

Try It Live

The Markdown to HTML converter renders any Markdown to clean HTML right in your browser — paste a snippet and see both the rendered preview and the generated tags, with nothing sent to a server. When you need to escape special characters in that HTML output safely, the HTML entity encoder/decoder handles the conversion both ways.

Frequently Asked Questions

What HTML does Markdown produce?

Each Markdown construct maps to a specific HTML element. A line starting with # becomes <h1> (two hashes give <h2>, and so on through <h6>). **bold** becomes <strong> and *italic* becomes <em>. A list of - items becomes <ul> with <li> children; 1. items become <ol>. [text](url) becomes an <a href>. A fenced code block (triple backticks) becomes <pre><code>. Inline backticks become <code>. Plain paragraphs are wrapped in <p>.

What is the difference between CommonMark and GitHub Flavored Markdown?

CommonMark is the strict, unambiguous specification that defines exactly how core Markdown converts to HTML. GitHub Flavored Markdown (GFM) is a superset of CommonMark that adds tables, strikethrough (~~text~~), task lists (- [ ]), autolinks (bare URLs become links), and a few other extensions. The flavor you target matters: pipe-delimited tables only render if your parser has GFM (or a tables extension) enabled, which is why "my table shows as plain text" is almost always a flavor mismatch rather than a syntax error.

Is it safe to render user-submitted Markdown to HTML?

Not without sanitizing. Markdown allows raw HTML to pass through, and it allows javascript: URLs in links, so untrusted Markdown can inject scripts — a classic cross-site scripting (XSS) vulnerability. Never assign the converter's output to innerHTML directly. In the browser, run the generated HTML through DOMPurify first. Server-side, use a dedicated sanitizer (bleach in Python, sanitize-html in Node) or configure the renderer to escape or strip raw HTML. Sanitize the HTML output, not the Markdown input.

Which library should I use to convert Markdown to HTML?

In JavaScript, marked is the fast, simple default (marked.parse(md)); markdown-it is CommonMark-compliant with a rich plugin ecosystem; remark / unified is AST-based and best for build pipelines that transform content. In Python, python-markdown is the standard (markdown.markdown(text, extensions=[...])) and mistune is a faster alternative. All of them produce CommonMark-ish output; reach for GFM extensions or plugins when you need tables and task lists.

How do I add syntax highlighting to code blocks?

Markdown converters turn a fenced code block into <pre><code class="language-js"> but do not colorize it. Highlighting is a separate step done with highlight.js or Prism. You can run the highlighter as a post-processing pass over the rendered HTML (call hljs.highlightAll() after inserting the HTML into the page), or wire it directly into the parser — marked accepts a highlight hook and markdown-it has a highlight option, so each fenced block is colorized as it is rendered.