Render React / RSC trees to Markdown, never HTML.
This is a custom renderer in the spirit of rsc-html-stream, but for the other half of the job: where you would normally hand your tree to renderToReadableStream from react-dom/server, hand it to renderToMarkdownStream instead and get a stream of GitHub Flavored Markdown. No HTML is ever produced, no RSC payload is injected, nothing hydrates — markdown is the output.
Zero dependencies. react is the only peer (v19+). react-dom is not in the dependency graph at all.
import {renderToMarkdown} from 'rsc-markdown-stream';
let md = await renderToMarkdown(<Article />);
// "# Hello\n\nSome **bold** text...\n"Streaming, parallel to the SSR setup you already know — consume an RSC stream and render it to markdown instead of HTML:
import {renderToReadableStream} from 'react-server-dom-BUNDLER/server.edge';
import {createFromReadableStream} from 'react-server-dom-BUNDLER/client.edge';
import {renderToMarkdownStream} from 'rsc-markdown-stream';
let rscStream = renderToReadableStream(<App />);
let data;
function Content() {
data ??= createFromReadableStream(rscStream);
return React.use(data);
}
let markdownStream = renderToMarkdownStream(<Content />);
// ReadableStream<Uint8Array> of UTF-8 markdown, emitted block by blockThe wiring above is not hypothetical — example/ is a real Rsbuild project (JSX/TSX sources, path aliases, two build environments) whose app is a deploy dashboard built from async server components composing stock shadcn/ui components, run through the complete RSC pipeline with real Flight bytes crossing a process boundary:
example/src/app/App.tsx— the app. Async server components fetch data (release, deploys, checklist, FAQ) and compose shadcn components (Card, Table, Alert, Badge, Tabs, Accordion, Checkbox, Avatar) under Suspense boundaries.example/src/entries/flight-server.tsx— the RSC server. Bundled by Rsbuild with thereact-serverresolve condition (seeexample/rsbuild.config.ts), so the bundledreactis the server-components build and the output runs with plainnode— the same thing Next.js/Waku do in their server module graph. In this build theapp-uialias resolves toexample/src/ui/references.ts, which turns every shadcn component into a Flight client reference viaregisterClientReference— the substitution a real RSC bundler performs for"use client"modules. The server renders<App />to the RSC wire format and writes raw Flight bytes to stdout.example/src/entries/flight-client.tsx— the consumer. Spawns the server bundle, decodes the byte stream withreact-server-dom-webpack/client, resolving the client references back to the real shadcn implementations through a module map (example/src/lib/ui-manifest.ts), and hands the decoded tree torenderToMarkdownStream— exactly wherereact-dom/server'srenderToReadableStreamwould normally sit.
npm --prefix example install
npm run example:flightMarkdown blocks stream out progressively as each server component's data resolves (timing on stderr): the header and alert arrive first, the release card with its deploy table next, the slower checklist and FAQ last — the same progressive behavior you'd get from streaming HTML SSR, but the output is markdown.
The dashboard's shadcn components — Radix primitives, cva variants, lucide icons and all — render to markdown surprisingly well (npm run example:shadcn renders the same <App /> directly, without Flight):
- shadcn's
Tablecomponents are real<table>elements underneath, so they come out as GFM tables. - Radix
Checkboxrenders a hidden<input type="checkbox">for form interop — inside<li>that becomes a GFM task list (- [x]). - Radix
AccordionTriggerlives inside an<h3>header, so triggers become real markdown headings; collapsed content and inactiveTabsContentare unmounted by Radix and produce nothing, while thedefaultValuepanel renders. - Radix state/hooks (
useState,useId, context) run on the renderer's built-in dispatcher in their initial, uncontrolled state. Portal-based components (Dialog, Popover, Tooltip) are the ones that won't work. - Styled containers (Card, Alert, Button) flatten to plain text blocks — the entries map
buttonto a custom serializer viaoptions.components(example/src/lib/markdown-options.ts) to keep adjacent button labels from running together.
rsc-html-stream does not render anything: it is a ~130-line transport that interleaves Flight bytes into an HTML stream as <script> tags (server) and reassembles them into a ReadableStream for hydration (client). In that stack, the actual rendering is done by react-dom/server (HTML) and react-server-dom-* (Flight). This library replaces the react-dom/server leg with a renderer that emits markdown — and since markdown output never hydrates, no transport/injection layer is needed at all: the Flight stream is consumed once, on the server side of your pipeline.
renderToMarkdownStream(children, options?)→ReadableStream<Uint8Array>— markdown text, streamed a block at a time as components resolve, in document order.renderToMarkdown(children, options?)→Promise<string>.
options.components maps extra host tag names to serializers, and can also override the built-ins. A serializer gets (props, {inline, blocks}) and returns a markdown block:
await renderToMarkdown(<callout kind="WARNING">Mind the gap.</callout>, {
components: {
callout: (props, {inline}) => `> [!${props.kind}]\n> ${inline()}`,
},
});| React | Markdown |
|---|---|
h1…h6 |
#…###### headings |
p |
paragraph |
strong / b, em / i, del / s |
**bold**, *italic*, ~~strike~~ |
code |
`inline code` (nested backticks handled) |
pre (+ code className="language-js") |
fenced code block with language |
a, img |
[text](href "title"),  |
ul / ol / li |
lists, nested lists, start offsets; adjacent sibling lists alternate markers (-/*, 1./1)) so they don't merge |
blockquote |
> quoted blocks |
hr, br |
---, hard break (dropped at paragraph edges, where it would be a literal backslash) |
input type="checkbox" (in li) |
GFM task list items - [x] / - [ ] |
table / thead / tbody / tr / th / td |
GFM tables with alignment, colSpan padding; caption becomes the paragraph above |
dl / dt / dd |
terms and definitions as paragraphs |
div, section, article, … |
passthrough as blocks |
span, unknown tags |
passthrough inline — HTML tags are never emitted |
script, style, template, noscript, head, title, meta, link |
nothing |
Function and class components are invoked, async components and promise children are awaited, and React.use, useState, useContext and friends work via a minimal built-in hooks dispatcher (effects never run; state stays at its initial value, like any server render). Fragments, arrays, iterables, Suspense (content is always awaited; fallbacks never render), Activity (hidden mode renders nothing), ViewTransition, lazy, memo, forwardRef and context providers are all handled.
Markdown punctuation in text is escaped where it would change meaning (*, _ except between word characters, ~, [, <, backticks, entity-like &, list/heading markers at line start, a heading's trailing #, …) and never inside code / pre. Nesting an emphasis inside the same emphasis is a no-op rather than an upgrade to strong. Blocks are separated by exactly one blank line.
Streaming is real, not cosmetic: pass-through containers (div, article, section, …) stream their children block by block instead of buffering the subtree, so a slow component at the bottom of a layout never delays the blocks above it. Sibling subtrees resolve concurrently (like React) while output stays in document order. Only leaf blocks (p, pre, table, list, heading, blockquote — and any tag with a custom serializer) buffer their own subtree, since their markdown can't be emitted piecemeal.
- No HTML output, ever — this is not React → HTML → markdown; the tree is walked directly.
- No RSC payload injection, no
<script>tags, no hydration. If you want those, that's rsc-html-stream +react-dom/server. - No bundler integration: feed it React elements (e.g. from
createFromReadableStream) and it emits markdown.
npm install
npm test # node:test suite
npm run stress # renders realistic app trees and round-trips the output through a GFM parser
npm --prefix example install # the example is its own Rsbuild project
npm run example # JSX <Article /> → markdown
npm run example:flight # full RSC pipeline: Flight server → client → markdown
npm run example:shadcn # a shadcn/ui dashboard page → markdown
npm run demo # browser demo: live RSC → markdown streaming + playgroundMIT