DEV Community

Cover image for I Built a Zero-Backend Markdown Previewer in 40 Lines of JS
bowen Tse
bowen Tse

Posted on Originally published at toolkitloop.blogspot.com

I Built a Zero-Backend Markdown Previewer in 40 Lines of JS

Every Markdown previewer I tried wanted something from me first. An upload. A signup. A desktop app install for what is, at its core, a text transformation.

So I built one that asks for nothing: paste Markdown, get a preview, upload zero bytes. It's one of the 30 free browser tools in my ToolkitLoop collection — full disclosure, it's my own project.

The interesting part wasn't the UI. It was the three problems hiding inside "just render some Markdown."

Problem 1: GFM tables will humble you

CommonMark is the easy part — headings, emphasis, lists. The pain starts with GitHub Flavored Markdown, specifically tables and task lists.

A table looks simple until you hit the edge cases: rows with mismatched cell counts, escaped pipes inside cells (\|), alignment rows with colons. A naive regex-based parser falls apart here. I checked — writing a correct table parser by hand is a weekend of suffering I didn't need.

The pragmatic answer is marked. It's a real, maintained parser with proper GFM support:

import { marked } from 'marked';

const input = document.getElementById('source');
const output = document.getElementById('preview');

function render() {
  const html = marked.parse(input.value, {
    gfm: true,
    breaks: false
  });
  output.innerHTML = html;
}
Enter fullscreen mode Exit fullscreen mode

Forty lines was aspirational; the honest count is a bit more once you add the rest. But the parsing itself is genuinely a few lines, because marked does the heavy lifting — tokenizing, handling the table edge cases, nested lists, all of it.

The lesson: don't hand-roll a Markdown parser in 2026. The spec edge cases are a tar pit. Use a real parser and spend your energy on the problems around it.

Problem 2: Markdown becomes HTML, and HTML bites

Here's the one people skip: marked.parse() returns HTML, and you inject it with innerHTML. That means your previewer is an XSS delivery mechanism unless you sanitize.

Markdown has plenty of HTML smuggling routes. A link like [click](javascript:alert(1)). An image with an onerror attribute in inline HTML. Markdown explicitly allows raw HTML passthrough, which is great for docs and terrible for a preview pane.

The fix is DOMPurify, applied to every render with no exceptions:

import DOMPurify from 'dompurify';

function render() {
  const dirty = marked.parse(input.value, { gfm: true });
  output.innerHTML = DOMPurify.sanitize(dirty);
}
Enter fullscreen mode Exit fullscreen mode

This isn't optional hardening — it's the difference between a tool and a vulnerability. Any time you render user-supplied Markdown as HTML, sanitize the output. Every time. There is no "it's just a preview" exemption; the preview runs in the user's browser with the user's session.

One subtlety worth knowing: sanitizing is about the HTML output, not the Markdown input. Trying to blacklist "dangerous Markdown" is a losing game — the attack surface is the DOM, so that's where you defend.

Problem 3: Big documents turn keystrokes into slideshows

Re-rendering on every keystroke feels instant on a 50-line README. Paste a 10,000-line changelog and the same approach turns typing into a slideshow — parse, sanitize, and re-layout on each keypress is genuinely expensive.

The fix is a debounce, and it's the highest value-to-effort ratio in the whole project:

let timer;

input.addEventListener('input', () => {
  clearTimeout(timer);
  timer = setTimeout(render, 150);
});
Enter fullscreen mode Exit fullscreen mode

150 milliseconds is the sweet spot I landed on: short enough that the preview still feels live, long enough that fast typing coalesces into a single render. For truly huge documents you could get fancier — incremental rendering, virtualized output — but debounce plus a fast parser covers everything I've thrown at it.

Bonus: warn before you load remote images

One more real-world gotcha: Markdown documents love remote images — ![logo](https://cdn.example.com/logo.png). Rendering them means the reader's browser phones home to that server. For a preview tool, silently triggering those requests is rude at best.

I added a lightweight pre-render scan that collects remote image URLs and surfaces them as a warning instead of loading them blindly:

function findRemoteImages(markdown) {
  const urls = [];
  const re = /!\[[^\]]*\]\((https?:[^)\s]+)\)/g;
  let m;
  while ((m = re.exec(markdown))) urls.push(m[1]);
  return [...new Set(urls)];
}
Enter fullscreen mode Exit fullscreen mode

It's deliberately simple — a regex over the source, not the parsed AST — because it only needs to answer "are there external references?" before anything touches the network. The full tool pairs this with a Remote Asset Guard UI, but even the ten-line version changes the trust story: the preview never makes a network request the user didn't approve.

Small feature, outsized trust payoff. The kind of thing you only add after someone pastes a doc full of tracking pixels.

What shipped

The result is Markdown Viewer: paste or open a .md file, get an instant GFM preview, nothing leaves your browser. Along the way I added a few things the core loop needed — reading stats (words, headings, links, code blocks) as a structural sanity check, a guard that warns when your document references remote images, and a "passport" check that flags app-specific syntax like wiki links which won't survive a move to GitHub.

None of those were in the original forty-line plan. They came from actually using the thing on real documents and hitting real paper cuts.

Your turn

Here's my question for you: what's the weirdest Markdown edge case you've ever hit in the wild? Nested task lists inside blockquotes? A table that renders on GitHub but explodes in your static site generator? I'm collecting them — partly out of curiosity, partly because every one is a potential test case. Drop it in the comments.

Top comments (0)