Inspiration

I read three READMEs in one morning and not one of them was rendered. Just walls of asterisks, pipes, and backticks on a grey screen.

That's the strange thing about Markdown in 2026. We write more of it than ever (READMEs, docs, notes, specs, AI output), but the tooling for reading it never caught up with how much we produce. The viewers that do exist either look like they were designed in 2013, or they quietly upload your files to a server in order to render them.

I wanted a viewer where the reading experience was the entire point.

The insight that shaped everything

The thing I found while building is the part I most want to share:

Rendering Markdown well is not a parsing problem. It's a typography problem.

Every parser is a commodity. You can install one in about a minute and it will turn your asterisks into <strong> tags perfectly well. Almost nobody's viewer looks bad because the parser failed. It looks bad because nobody thought about the line-height, the type scale, the vertical rhythm, or what a code block should actually feel like to read.

Once I accepted that, the whole build reoriented. The parser became the boring, solved part, and the interesting work moved to spacing, hierarchy, and craft.

How I built it

The app is a client-only single-page application: Vite, React 18, and TypeScript in strict mode. There is no backend at all.

The rendering core is react-markdown with a remark and rehype plugin pipeline. Conceptually the whole render is a composition:

$$\text{render} = \text{sanitize} \circ \text{highlight} \circ \text{katex} \circ \text{gfm} \circ \text{parse}$$

In practice that means remark-gfm for tables, task lists, strikethrough and autolinks; remark-math and rehype-katex for math, so inline expressions like $e^{i\pi} + 1 = 0$ and display blocks both render properly; rehype-highlight for syntax highlighting; and rehype-raw paired with rehype-sanitize for controlled HTML.

A few decisions I'd defend:

One pipeline, one place. The entire plugin chain and component override map live in a single MarkdownRenderer. Custom renderers (code block, anchor, table wrapper) are separate modules it consumes. Nothing else in the app knows how Markdown is rendered.

Design tokens over duplicated styles. Light and dark themes are a CSS variable swap, not two sets of classes. Typography is exposed as --font-display, --font-body, and --font-mono, so no component hard-codes a family name.

Code blocks got the most attention of anything. Each one has a header bar with the detected language, a copy button that confirms it worked, and a color accent unique to each language, driven by a single reusable map. You can tell Python from TypeScript before reading a line.

Typography chosen, not defaulted. Lastik for headings and reading, Poppins for the UI, both self-hosted so the page makes no third-party font requests.

Challenges

The highlight feature broke my own security rule.

I wanted <mark> support so readers could emphasize important passages. But <mark> is raw HTML, and raw HTML is an XSS vector, so I had deliberately disabled HTML rendering from the start.

The lazy fix is to switch it back on. What I did instead was enable HTML parsing behind a strict sanitize allowlist that permits exactly one tag and strips everything else, including scripts, styles, and every event handler attribute. The schema is its own module, so the security boundary is a single reviewable file rather than a setting buried in a config object.

That tradeoff (wanting a feature, refusing to buy it with a security hole) taught me more than the rest of the build combined.

Matching a typographic target is harder than matching a feature list.

Checking off "renders tables" is easy. Getting the vertical rhythm to feel calm across headings, body copy, lists, code, and math (all on the same page, at different sizes) took far more iteration than any parsing work.

Going backendless started as a speed decision.

No server meant nothing to upload and nothing to wait for. Then I realised what else it meant: files are read in the browser, held in memory, and never leave the device. There is nothing to leak because there is nowhere for it to go. The privacy guarantee is enforced by the architecture rather than promised in a policy. It ships as a static bundle at zero infrastructure cost.

What I learned

That the unglamorous layer is usually where the quality lives. I could have shipped a viewer with every feature on this list and it would still have felt cheap, because the thing people actually experience is the spacing between two paragraphs.

Also: a security boundary you can point at in one file is worth far more than one you can only describe.

Built With

  • client-side
  • eslint
  • file-api
  • highlight.js
  • javascript
  • katex
  • lucide-react
  • prettier
  • react
  • react-markdown
  • rehype
  • rehype-highlight
  • rehype-katex
  • rehype-raw
  • rehype-sanitize
  • remark
  • remark-gfm
  • remark-math
  • tailwindcss
  • typescript
  • vercel
  • vite
Share this project:

Updates