A Markdown-to-HTML converter can turn a plain-text editor into a live preview, or help a site display user-authored content. The tricky part is not just replacing punctuation with HTML: a useful converter needs to support Markdown syntax consistently, and its output must not let untrusted content run JavaScript in a browser.
This tutorial shows how to build a small browser-based converter with the maintained markdown-it parser and DOMPurify. It also explains when a limited custom parser can make sense, how to test the result, and what the security boundaries are.
Key takeaways
- Use a maintained Markdown library for general-purpose parsing instead of trying to cover the full syntax with regular expressions.
- Decide which Markdown features and raw HTML behavior your application allows.
- Sanitize generated HTML before inserting it into the DOM when the Markdown may come from an untrusted source.
- Test both ordinary formatting and unsafe input; a successful conversion does not, by itself, prove safe rendering.
Choose a parser that matches your needs
Markdown is a family of related syntaxes rather than one universally identical feature set. Basic implementations often support headings and emphasis, while other parsers may also support tables, strikethrough, reference links, and extensions. Pick a parser and configuration that match the syntax your users expect, and document that supported subset.
For a production feature or a converter that needs familiar Markdown behavior, use a maintained parser. This tutorial uses markdown-it. Its default preset supports common Markdown features such as headings, lists, links, blockquotes, code blocks, tables, and strikethrough. Parser options and plugins can change that behavior, so verify the configuration against your requirements.
A custom parser may be reasonable for a small, fixed input format—for example, a private tool that accepts only headings and plain paragraphs. It gives you control over exactly what is supported, but you must handle escaping and edge cases yourself. It should not be presented as a complete Markdown implementation.
Install the parser and sanitizer
In a JavaScript project with a bundler, install markdown-it and DOMPurify:
npm install markdown-it dompurify
The parser converts Markdown into an HTML string. DOMPurify sanitizes that string before it is placed in the page. Keeping those jobs separate makes the security boundary clear: parsing creates markup; sanitization applies an HTML safety policy.
Build a browser converter
Add a text input and a preview area to your page. For example, place these elements in your HTML:
<label for="markdown-input">Markdown</label>
<textarea id="markdown-input" rows="12"># Hello
Write **Markdown** here.</textarea>
<section aria-labelledby="preview-heading">
<h2 id="preview-heading">Preview</h2>
<div id="preview"></div>
</section>
Create a JavaScript module that configures the parser, sanitizes its output, and updates the preview:
import MarkdownIt from "markdown-it";
import DOMPurify from "dompurify";
const markdown = new MarkdownIt({
html: false,
linkify: false,
typographer: false
});
function markdownToSafeHtml(source) {
const renderedHtml = markdown.render(source);
return DOMPurify.sanitize(renderedHtml, {
USE_PROFILES: { html: true }
});
}
const input = document.querySelector("#markdown-input");
const preview = document.querySelector("#preview");
function updatePreview() {
preview.innerHTML = markdownToSafeHtml(input.value);
}
input.addEventListener("input", updatePreview);
updatePreview();
This example is intended for a browser application bundled with a tool that supports npm packages. It uses innerHTML only for the sanitized result. Do not assign raw user input or unsanitized parser output to innerHTML.
The html: false option tells the parser not to interpret raw HTML in Markdown as active markup. DOMPurify is still useful as a second safety layer, particularly if parser settings or the allowed syntax change later. Its HTML profile is a starting policy, not a guarantee that every application has the right allowlist. Review the sanitizer configuration for the elements and attributes your product actually needs.
Understand the security risk
Markdown is not automatically safe just because it looks like plain text. A parser can produce HTML links, images, and other elements. If an application accepts Markdown from users and inserts unsafe HTML into a page, an attacker may be able to add event-handler attributes or other active markup. That can lead to cross-site scripting (XSS), where code runs in a visitor's browser in the context of the site.
A safer rendering flow is:
- Treat submitted Markdown as untrusted input.
- Parse it with the chosen Markdown library and explicit options.
- Sanitize the generated HTML with a maintained sanitizer and an appropriate policy.
- Insert only the sanitized result into the preview or page.
Sanitization is not the same as escaping. Escaping displays markup as text; sanitization removes or adjusts markup according to a policy. Which behavior you want depends on the feature. A live Markdown preview usually needs some HTML elements to remain active, so escaping the entire output would prevent formatted rendering.
If raw HTML is not a product requirement, keep it disabled in the parser. If it is required, consider the additional risk and test the sanitizer policy against the elements and attributes your application intends to permit. A Content Security Policy can provide another layer of defense, but it does not replace safe parsing and sanitization.
Supported syntax and product decisions
Before shipping, write down the syntax you intend to support. Questions to settle include:
- Are tables and strikethrough part of the supported format?
- Should bare URLs become links, or must users write Markdown link syntax?
- Is raw HTML displayed as text, removed, or allowed after sanitization?
- Are images permitted, and what URL schemes are acceptable?
- Should the same Markdown render identically in the preview and in the saved page?
The example disables automatic linkification, so a bare URL is not necessarily converted into a link. Users can still use Markdown link syntax. If you enable a parser option or plugin, confirm that it is supported by your installed version and test the resulting links. Parser configuration is part of your content format: changing it later may change how previously saved Markdown appears.
Test formatting and unsafe input
Test expected formatting as well as security-sensitive cases. The following example uses Vitest with a jsdom environment so DOMPurify can run in a browser-like test environment. Install the test dependencies with npm install --save-dev vitest jsdom, export markdownToSafeHtml from its module, and adapt the import path to your project.
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { markdownToSafeHtml } from "./markdown.js";
describe("markdownToSafeHtml", () => {
it("renders common Markdown formatting", () => {
const html = markdownToSafeHtml("# Title\n\nA **bold** word.");
expect(html).toContain("<h1>Title</h1>");
expect(html).toContain("<strong>bold</strong>");
});
it("does not render raw HTML as an active element", () => {
const html = markdownToSafeHtml(
'<img src="x" onerror="alert(1)">'
);
expect(html).not.toContain("<img");
expect(html).not.toContain("onerror=");
});
it("does not retain a JavaScript URL in a link", () => {
const html = markdownToSafeHtml(
"[open](javascript:alert(1))"
);
expect(html).not.toContain('href="javascript:');
});
});
These tests check a few important behaviors, not every possible attack or syntax case. Add tests for the Markdown features you promise to support, links and images allowed by your policy, empty input, multiline content, and any sanitizer configuration you customize. Keep the dependencies current and review their security advisories as part of normal maintenance.
A deliberately limited custom parser
If your input format is intentionally narrow, a small parser can be easier to understand than adopting a full Markdown implementation. This example handles blank lines, paragraphs, and headings from one to three # characters. It escapes HTML-sensitive characters before placing text inside generated tags.
function escapeHtml(text) {
const replacements = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
};
return text.replace(/[&<>"']/g, (character) => replacements[character]);
}
function renderLimitedMarkdown(source) {
return source
.split(/\r?\n/)
.map((line) => {
if (!line.trim()) return "";
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
const level = heading[1].length;
return `<h${level}>${escapeHtml(heading[2])}</h${level}>`;
}
return `<p>${escapeHtml(line)}</p>`;
})
.join("\n");
}
This parser does not support emphasis, links, lists, nested blocks, code fences, or most other Markdown syntax. It also treats each nonblank line as its own paragraph. Its escaping protects the text inserted by these specific templates, but it is not a general-purpose Markdown parser or a substitute for testing. If you add syntax that creates HTML, carefully encode text and attributes, and reassess the rendering boundary.
Common mistakes
- Using regular expressions as a full Markdown parser. Markdown has block and inline rules that interact. Regex substitutions can produce surprising output for nested or multiline syntax.
- Assuming a parser sanitizes its output. Parsing and sanitization are separate jobs. Configure and test both.
- Enabling raw HTML without a clear need. Raw HTML expands the input surface. Keep it disabled when users do not need it.
- Writing untrusted output into the DOM directly. Never pass raw Markdown to
innerHTML; sanitize generated HTML before insertion. - Testing only a successful heading. Include unsafe markup and URL cases, along with every syntax feature your product supports.
- Letting the preview and saved page use different rules. Different parser settings or sanitizer policies can make content look different across environments. Share or carefully align the configuration.
Conclusion
To build a Markdown-to-HTML converter with JavaScript, choose a parser that fits your supported syntax, configure it deliberately, and sanitize generated HTML before rendering content you do not trust. A maintained library is usually the better choice for general Markdown; a custom parser is appropriate only when the supported format is small and clearly defined. Tests for both normal output and unsafe input help keep those decisions visible as the converter evolves.
