Skip to content

Repository files navigation

rsc-markdown-stream

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.

Usage

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 block

Full RSC example

The 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 the react-server resolve condition (see example/rsbuild.config.ts), so the bundled react is the server-components build and the output runs with plain node — the same thing Next.js/Waku do in their server module graph. In this build the app-ui alias resolves to example/src/ui/references.ts, which turns every shadcn component into a Flight client reference via registerClientReference — 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 with react-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 to renderToMarkdownStream — exactly where react-dom/server's renderToReadableStream would normally sit.
npm --prefix example install
npm run example:flight

Markdown 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.

shadcn/ui to 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 Table components are real <table> elements underneath, so they come out as GFM tables.
  • Radix Checkbox renders a hidden <input type="checkbox"> for form interop — inside <li> that becomes a GFM task list (- [x]).
  • Radix AccordionTrigger lives inside an <h3> header, so triggers become real markdown headings; collapsed content and inactive TabsContent are unmounted by Radix and produce nothing, while the defaultValue panel 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 button to a custom serializer via options.components (example/src/lib/markdown-options.ts) to keep adjacent button labels from running together.

How this relates to rsc-html-stream

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.

API

  • 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()}`,
  },
});

What renders to what

React Markdown
h1h6 ####### 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"), ![alt](src)
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 &amp;, 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.

Out of scope, on purpose

  • 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.

Try it

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 + playground

License

MIT

About

Render React / RSC trees to a Markdown stream — a custom renderer: React to Markdown, never HTML

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages