Skip to content

ADR-0021 Markdown

Company standard Markdown rendering — TanStack Markdown behind one shared component per app, raw HTML off, and one link and image policy for model and user text.

Decision
TanStack MarkdownBuild the app
Status
Accepted
Date
Scope
Company-wide default for all new apps

Apps in our stack render Markdown from three places:

  • Model replies — streamed token by token (ADR-0025) and re-rendered many times a second while still incomplete
  • User-written text — comments, notes, descriptions: anything a customer types
  • Text we author — help pages, release notes, empty-state copy

The first two are untrusted input. A model that has read a web page, a tool result, or a stored record can emit whatever an attacker put there.

Surrounding decisions:

  • SSR: TanStack Start (ADR-0002) renders on the server and hydrates; the Markdown output must be the same on both sides
  • Styling: Tailwind v4 and shadcn/ui tokens (ADR-0011), no runtime style injection on the render path — element styles are our CSS
  • Validation: a Markdown field is a string at a boundary like any other (ADR-0005)
  • Code blocks are colored by the highlighter in ADR-0022

What is open: which renderer, the policy for raw HTML, links and images, how partial Markdown renders while it streams, and where the configuration lives.

Constraints:

  • Raw HTML is an XSS vector. A renderer that passes it through makes us responsible for sanitizing it
  • Links and images are outbound requests. A javascript: link runs code on click. A remote image loads with no click at all, so a prompt-injected model can put conversation data in an image URL and the browser sends it — Streamdown’s own security docs describe exactly this attack
  • Partial input. While streaming, the text ends mid-fence, mid-bold, mid-link. The output must stay stable and never show a half-built element
  • Same output on the server and the client, or hydration mismatches
  • Browser bundle. The renderer ships in every app that has chat or comments

A parser that produces a serializable AST, plus HTML and React renderers. @tanstack/markdown 1.0.0 (2026-10-01), MIT per package.json. There is no separate React package: the adapter is the @tanstack/markdown/react entry, with React 18+ as an optional peer.

Pros

  • Safe by default. Raw HTML is escaped as text. Link and image URLs with a scheme other than http, https, mailto, or tel are dropped, the label text stays. We ran javascript:, vbscript:, file:, data:, and ftp: in inline and reference links against 1.0.0; all were dropped
  • urlTransform(url, kind, defaultUrl) runs while parsing, for every link, image, and reference destination. Returning null drops the element and keeps its label — that is our hook for an image policy
  • Streaming profile. streamingMarkdownExtension() reparses the whole accumulated text on each update, hides empty trailing headings, quotes, and list items, and renders an unclosed code fence as code. Unclosed **, links, and code spans stay literal text until they close. HTML and React output are tested for structural parity at every character boundary
  • Synchronous, no runtime dependencies, no effects. The React component builds elements directly, so it renders on the server and the client with the same result; SSR output is tested against the HTML renderer
  • Serializable AST: parse once, cache, render to HTML or React later. components replaces any emitted tag (a, img, pre) with our own React component
  • Small: 5.2 KB gzip parser, 7.0 KB React adapter, 7.2 KB with the streaming extension (maintainers’ measurement; framework runtime and highlighter excluded). Their numbers for others: Marked 12.5 KB, unified + remark + rehype 36.8 KB, markdown-it 52.7 KB
  • Highlighting is a callback with a first-party TanStack Highlight adapter, so Markdown never bundles a grammar (ADR-0022). Fence metadata (title, file, {2,4-6}) is parsed and handed over
  • Same organization as Router, Query, and Start. TanStack AI’s own TextPart already renders with it

Cons

  • Not CommonMark or GFM, by design. The README says so. The maintainers’ own accounting matches 403 of 652 CommonMark 0.31.2 examples (61.8%) and calls it accounting, not a goal. Verified on 1.0.0: setext headings and indented code blocks render as plain paragraphs, and bare URLs and <https://…> autolinks stay text. Tables, task lists, strikethrough, fenced code, and footnotes work
  • No math, no Mermaid, no MDX. Extensions are small synchronous hooks; unified plugins cannot be reused
  • Young. First npm release 2026-06-21, 1.0.0 twenty days before this ADR. The GitHub repository has no LICENSE file; MIT is declared only in package.json
  • It is not a sanitizer. Renderers trust ASTs handed to them, URLs returned by urlTransform, extension HTML, and highlighter output; the URL screen runs at parse time, not at render time
  • Remote images pass the default screen (only the scheme is checked), and so does //host/x.png
  • The Markdown component has no memoization: it reparses on every render. Limits cover nesting and inline scans, not total input size

2. react-markdown (unified: remark + rehype)

Section titled “2. react-markdown (unified: remark + rehype)”

react-markdown 10.1.0 (latest npm release 2025-03-07), MIT, 11 dependencies.

Pros

  • The reference React renderer; safe by default — no dangerouslySetInnerHTML, raw HTML escaped or skipped (skipHtml), URLs screened by defaultUrlTransform (relative, http, https, irc, ircs, mailto, xmpp)
  • 100% CommonMark, and 100% GFM with remark-gfm; the broadest plugin ecosystem (math, directives, anything remark or rehype has)
  • rehype-sanitize gives a schema-based allowlist as a second layer

Cons

  • No streaming profile: partial Markdown renders as the parser sees it, and each update reparses the whole string through the unified pipeline
  • Heavier: 36.8 KB gzip for the unified stack in the maintainers’ measurement above; its docs put rehype-raw at about 60 KB minzipped on top
  • Safety depends on every plugin we add; its README says plugins and components may be insecure

@mdx-js/mdx 3.1.1, MIT, 25 dependencies.

Pros: components inside content; the standard for authored docs. Cons: MDX compiles to JavaScript. Its own docs: “If you trust your authors, that’s fine. If you don’t, it’s unsafe. Do not let random people from the internet write MDX.” That rules it out for model and user text. It stays available for text we author, as a separate decision.

markdown-it 15.0.3, MIT, 6 dependencies.

Pros: mature; html: false by default; large plugin ecosystem; a token stream you can walk. Cons: no React renderer — the default output is an HTML string, so we would write the token-to-React mapper or use dangerouslySetInnerHTML. Its validateLink is a denylist (vbscript:, javascript:, file:, data: except common image types), so every other scheme passes. 52.7 KB gzip in the maintainers’ measurement.

marked 18.1.1 (MIT, no dependencies) and dompurify 3.4.17 (MPL-2.0 OR Apache-2.0).

Pros: marked is small (12.5 KB gzip in the maintainers’ measurement), fast, and familiar. Cons: marked’s README: it “does not sanitize the output HTML”, so DOMPurify is mandatory. DOMPurify needs a DOM, so on the server that means jsdom — and its docs say happy-dom is not considered safe. Output is an HTML string: no component mapping, dangerouslySetInnerHTML everywhere, and raw HTML passes through to be sanitized rather than never being parsed.

streamdown 2.7.1, Apache-2.0, 15 dependencies, built for AI streams as a drop-in react-markdown replacement.

Pros

  • Repairs unterminated Markdown while streaming (remend: bold, italic, links, inline code, KaTeX), memoizes blocks, and ships GFM, KaTeX math, Mermaid, and CJK plugins
  • Shiki code highlighting with lazy-loaded languages and light/dark themes; rehype-harden can restrict link protocols and image origins
  • Actively released

Cons

  • Permissive defaults. Out of the box it allows all links, all protocols, base64 images, and raw HTML (through rehype-raw plus rehype-sanitize); its docs say to tighten this for untrusted content. Setting the rehypePlugins prop replaces the whole default array, so omitting sanitize silently removes the XSS protection
  • Built on marked plus the unified stack; needs a Tailwind @source line, and ships its own UI (code block controls such as copy buttons) to restyle
  • The code plugin pulls Shiki, which ADR-0022 declines for the default
  • TanStack’s own browser benchmark (Chrome 152, Streamdown 2.6.0, code as plain text) puts TanStack Markdown’s per-update latency below Streamdown’s on unfinished fences. It is the competitor’s benchmark and we did not reproduce it, so we do not weigh it

A. Off — HTML in Markdown is shown as text

Section titled “A. Off — HTML in Markdown is shown as text”

Pros

  • Nothing to sanitize, so no sanitizer, no allowlist to maintain, and no DOM on the server
  • The smallest attack surface; the same rule for model, user, and authored text

Cons

  • No inline HTML (<details>, <kbd>, <sub>); pasted READMEs show their tags as text

allowHtml: true plus DOMPurify, or rehype-raw plus rehype-sanitize.

Pros: rich content survives. Cons: a second dependency in the pipeline, an allowlist that someone has to review, a DOM on the server for DOMPurify, and TanStack Markdown inserts raw HTML with dangerouslySetInnerHTML per node, so sanitizing means working on the final string.

Pros: HTML for text we write ourselves. Cons: one prop away from being switched on for user text; the library’s own docs tell you not to enable it for untrusted content.

TanStack Markdown renders all Markdown in our apps — model replies, user text, and authored text — with raw HTML off and one link and image policy. This is the company standard for all new apps.

Concretely:

  • Package: @tanstack/markdown 1.x. One package; the React binding is its @tanstack/markdown/react entry, not a separate install. Use narrow entries (/react, /parser, /extensions/streaming), not the default entry, in browser code. Don’t add react-markdown, remark-*, rehype-*, marked, markdown-it, streamdown, or dompurify for Markdown; a product that needs one writes its own ADR

  • Configuration in one module, rendering in one component. src/lib/markdown.ts is pure TypeScript with no React and no server-only imports, so server and client both import it. It exports the shared options: the URL policy, the streaming extension, frontmatter: false, and headingIds: false (frontmatter must not reinterpret the start of a model reply, and a growing heading must not change its id on every token). src/components/markdown/markdown.tsx exports Markdown({ source, className }), which only renders: it applies those options, passes the highlighter from ADR-0022, and supplies a components map. Screens, chat messages, and comments call Markdown; nothing else imports @tanstack/markdown. A product extends it through props (extensions, components), never by forking the module

  • Raw HTML stays off. allowHtml is never set. Any product that needs raw HTML writes its own ADR with a sanitizer, and it cannot apply to model or user text

  • Links: keep the built-in screen (relative, http, https, mailto, tel). The a replacement in components adds rel="noopener noreferrer nofollow" and target="_blank" to absolute http(s) links. Deciding whether a URL is allowed stays in src/lib/markdown.ts, never in a component

  • Remote images are off. Only the app origin and our own storage or CDN are allowed. Compare origins after resolving, not prefixes — //host/x.png has no scheme and passes the library’s screen:

    src/lib/markdown.ts
    import type { UrlTransform } from "@tanstack/markdown";
    const IMAGE_ORIGINS = new Set([APP_ORIGIN]); // plus our own storage or CDN origin
    export const urlTransform: UrlTransform = (url, kind, defaultUrl) => {
    if (kind === "link") return defaultUrl;
    try {
    return IMAGE_ORIGINS.has(new URL(url, APP_ORIGIN).origin) ? defaultUrl : null;
    } catch {
    return null;
    }
    };

    null keeps the alt text and drops the image, so a prompt-injected image URL is never requested. data: images are already dropped by the default screen

  • The trust boundary is the source string. Render only ASTs that our own parseMarkdown call built from a string we hold; never accept an AST or JSON document from a client — renderers do not re-screen URLs. Don’t write extensions with renderHtml hooks (their output is trusted HTML). Fence metadata is data: pick known fields, never evaluate it

  • Validate Markdown at the boundary: z.string().max(…) in the shared schema (ADR-0005), with a limit per field. The library bounds nesting and scans, not total size

  • SSR: Markdown is synchronous and has no effects, so render it straight from source in the component tree — same markup on the server and the client. No useEffect, no client-only wrapper, no suppressHydrationWarning

  • Memoize: Markdown is wrapped in memo and takes a plain string, because the library reparses on every render. A streaming message then does not reparse the finished ones above it

  • Streaming: the streaming extension stays on for all text, also after the reply completes. Keep the accumulated string in state and pass the whole string; there is no incremental parser state. Each update reparses everything, so update at most once per frame or throttle with ADR-0023. Unclosed **, links, and code spans show as literal text until they close, and a table can reflow the last block — accept it, don’t add a repair layer

  • Styling: element styles live in src/styles/app.css under a .markdown-renderer wrapper class, using the shadcn tokens (ADR-0011); no inline styles. The same wrapper class is what ADR-0022 targets for code

  • Chat text from the AI SDK (ADR-0025) goes through this Markdown component, so model replies get the same policy as user text

  • Tests (ADR-0014): a bun test attack corpus rendered through Markdown — raw <script>, <img onerror>, javascript: and data: links (inline and reference), //host/x.png images — asserting no <script, no event-handler attributes, no javascript: or data: in href or src, and no <img> from a foreign origin. A second test renders every prefix of a sample reply without throwing. Run both on every upgrade; parser corrections can change output

The deciding factors: safe defaults with raw HTML never parsed, so there is no sanitizer to run or maintain; a streaming profile and synchronous SSR in one small package; a serializable AST; and first-party highlighting. We accept narrower syntax coverage than CommonMark and the risk of a 1.0 that is twenty days old.

Easier:

  • One renderer and one policy for chat, comments, and notes; the XSS and link rules are reviewed in two files
  • No sanitizer in the pipeline, and no DOM needed on the server
  • Small browser bundle; streaming works with a string in state and no parser lifecycle
  • Server and client render the same markup; the AST can be cached when content is stored
  • Highlighting is an explicit callback, so grammars never enter the Markdown bundle

Harder:

  • The syntax is a documented subset: bare URLs are not linked, setext headings and indented code are plain text, and only 403 of 652 CommonMark examples match. Run a sample of real model replies through it before rollout, and warn authors who paste Markdown from elsewhere
  • There is no remark, rehype, or MDX ecosystem: math, Mermaid, and anything else is ours to build as an extension, or to skip
  • The URL and image policy is ours. The library trusts what we give it at render time, so the attack-corpus test is not optional
  • The package is a few weeks past 1.0 and the repository has no LICENSE file: pin to 1.x, read the changelog on every minor, and have legal confirm the MIT declaration before a product ships on it
  • @tanstack/ai-react 0.31.0 depends on @tanstack/markdown ^0.0.13, and a caret on 0.0.x allows only that exact patch — using its TextPart puts a second copy of the library next to ours. Chat text should go through our Markdown component instead
  • No raw HTML: users cannot embed <details> or <kbd>, and pasted HTML shows as text
  • Markdown reparses on every render, so forgetting memo costs CPU on long chats

Given up: CommonMark and GFM completeness and the unified plugin ecosystem, Streamdown’s out-of-the-box repair of partial Markdown, math, and Mermaid, and MDX for anything untrusted. Accepted — safe defaults, streaming, and SSR in one small package matter more for model and user text than exotic syntax; a product that needs full CommonMark or math writes an ADR.