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
Connections
Context
Section titled “Context”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
Options considered
Section titled “Options considered”Renderer
Section titled “Renderer”1. TanStack Markdown
Section titled “1. TanStack Markdown”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, ortelare dropped, the label text stays. We ranjavascript:,vbscript:,file:,data:, andftp: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. Returningnulldrops 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.
componentsreplaces 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
TextPartalready 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
LICENSEfile; MIT is declared only inpackage.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
Markdowncomponent 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 bydefaultUrlTransform(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-sanitizegives 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-rawat about 60 KB minzipped on top - Safety depends on every plugin we add; its README says plugins and
componentsmay be insecure
3. MDX
Section titled “3. MDX”@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.
4. markdown-it
Section titled “4. markdown-it”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.
5. marked + DOMPurify
Section titled “5. marked + DOMPurify”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.
6. Streamdown (Vercel)
Section titled “6. Streamdown (Vercel)”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-hardencan 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-rawplusrehype-sanitize); its docs say to tighten this for untrusted content. Setting therehypePluginsprop replaces the whole default array, so omittingsanitizesilently removes the XSS protection - Built on
markedplus the unified stack; needs a Tailwind@sourceline, 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
Raw HTML
Section titled “Raw HTML”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
B. On, then sanitize the result
Section titled “B. On, then sanitize the result”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.
C. On, for trusted authors only
Section titled “C. On, for trusted authors only”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.
Decision
Section titled “Decision”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/markdown1.x. One package; the React binding is its@tanstack/markdown/reactentry, not a separate install. Use narrow entries (/react,/parser,/extensions/streaming), not the default entry, in browser code. Don’t addreact-markdown,remark-*,rehype-*,marked,markdown-it,streamdown, ordompurifyfor Markdown; a product that needs one writes its own ADR -
Configuration in one module, rendering in one component.
src/lib/markdown.tsis 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, andheadingIds: 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.tsxexportsMarkdown({ source, className }), which only renders: it applies those options, passes the highlighter from ADR-0022, and supplies acomponentsmap. Screens, chat messages, and comments callMarkdown; nothing else imports@tanstack/markdown. A product extends it through props (extensions,components), never by forking the module -
Raw HTML stays off.
allowHtmlis 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). Theareplacement incomponentsaddsrel="noopener noreferrer nofollow"andtarget="_blank"to absolutehttp(s)links. Deciding whether a URL is allowed stays insrc/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.pnghas 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 originexport 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;}};nullkeeps 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
parseMarkdowncall 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 withrenderHtmlhooks (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:
Markdownis synchronous and has no effects, so render it straight fromsourcein the component tree — same markup on the server and the client. NouseEffect, no client-only wrapper, nosuppressHydrationWarning -
Memoize:
Markdownis wrapped inmemoand 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.cssunder a.markdown-rendererwrapper 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
Markdowncomponent, so model replies get the same policy as user text -
Tests (ADR-0014): a
bun testattack corpus rendered throughMarkdown— raw<script>,<img onerror>,javascript:anddata:links (inline and reference),//host/x.pngimages — asserting no<script, no event-handler attributes, nojavascript:ordata:inhreforsrc, 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.
Consequences
Section titled “Consequences”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
LICENSEfile: pin to1.x, read the changelog on every minor, and have legal confirm the MIT declaration before a product ships on it @tanstack/ai-react0.31.0 depends on@tanstack/markdown^0.0.13, and a caret on0.0.xallows only that exact patch — using itsTextPartputs a second copy of the library next to ours. Chat text should go through ourMarkdowncomponent instead- No raw HTML: users cannot embed
<details>or<kbd>, and pasted HTML shows as text Markdownreparses on every render, so forgettingmemocosts 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.

