← Blog

react-markdown as a Next.js server component: 34 KB less JavaScript, and what the page pays instead

Moving react-markdown to a Next.js server component, measured on one post: 34 KB less gzipped script, a bigger RSC payload, and when to keep it.

Every post on this blog renders its markdown with react-markdown inside a component whose first line is "use client". That one line means the browser downloads react-markdown and receives the article twice, then parses the whole post again during hydration, for a page that never changes after it loads. Moving react-markdown into a Next.js server component is a one-line change. I measured it on the production build, and the result is less one-sided than I expected: the JavaScript goes down, and the payload goes up.

Why the renderer was a client component

"use client";

import ReactMarkdown from "react-markdown";

/**
 * Article body renderer for the blog. Same react-markdown approach as the chat
 * demo (embedded HTML is escaped by default, so it's XSS-safe), restyled for
 * long-form prose instead of a chat bubble.
 */
export function Markdown({ content }: { content: string }) {
  // ...ReactMarkdown with a components map for p, h2, pre, code, a
}

The chat demo really does need react-markdown in the browser, because its messages grow token by token as they stream. Rendering them is covered in streaming LLM responses in React. The blog renderer was modeled on it, "use client" included. Nothing in it needs the browser, though. In react-markdown 10.1.0 the default export "is a synchronous component", with no hooks and no effects. The package keeps MarkdownHooks for async plugins on the client and MarkdownAsync for async plugins on the server. The plain one runs fine in a server component.

Same post, same build, one line different

I measured /blog/drizzle-migrations-deploy-order-nextjs, a 1,708-word post, on next start with the production build. I summed every <script src> file listed in the page's HTML, then fetched the RSC payload that a client-side navigation requests (the same URL with an RSC: 1 header). Then I deleted the "use client" line, rebuilt and measured again.

  • JavaScript: 792 KB raw and 238 KB gzipped with the client renderer, of which one chunk, react-markdown and its parser, was 114 KB raw and 34 KB gzipped. As a server component: 676 KB and 203 KB, and that chunk is no longer loaded.
  • HTML: 50.9 KB to 66.0 KB raw, or 10.9 KB to 13.6 KB gzipped.
  • RSC payload: 22.2 KB to 34.9 KB raw, or 7.5 KB to 8.5 KB gzipped.
  • Rendered markup: identical.

Both versions carry the article twice in the HTML: once as rendered paragraphs and once inside the inline RSC data the page hydrates from. What changes is the second copy. With the client renderer it is the raw markdown string passed as a prop. With the server renderer it is the element tree, every <p>, <code> and class name spelled out. That is why the payload grows even as the script shrinks.

Over the wire, that nets out to about 31 KB less gzipped. The bigger difference is work. With the client renderer, hydration runs react-markdown again in the browser over the whole article. Rendering this post with react-markdown in Node took a median of 6.6 ms after warm-up on the build machine. A mid-range phone pays more, on the main thread, while the page is becoming interactive.

The change, and where it does not apply

- "use client";
-
  import ReactMarkdown from "react-markdown";

That is the whole diff. The components map, with its functions for p, pre, code and a, keeps working, because it now runs on the server and never has to cross into the browser. Ordinary links stay plain anchors. The kit's blog still ships the client version today, so this is a one-line improvement waiting to be made, not a description of what is live.

Keep a client boundary when the markdown itself changes in the browser, as in the chat. Keep one, too, when something inside it needs interaction, such as a copy button on code blocks. Even then, make only that button a client component and render it from the server through components. The rest of the article does not need to come along.

Things that bite

  • The payload grows with elements, not words. The server version serializes the rendered tree, so a post heavy on code blocks and inline code grows more than a prose post of the same length. Measure your longest post, not your shortest.
  • The escaping is the same on both sides. react-markdown "typically escapes HTML (or ignores it, with skipHtml)", whether it runs on the server or the client. Adding rehype-raw turns that off, and its README puts the cost of that plugin in the browser at about 60 KB minzipped. On the server it costs no bytes, but it still trusts the content.
  • The chunk does not disappear from the app. The chat page still loads react-markdown, as it should. Blog pages simply stop loading it.
  • Measure it yourself. Sum the <script src> files a page loads, and fetch the payload with curl -H "RSC: 1" <url>. Comparing those numbers before and after is enough to make the call.

Shipwright, the Next.js and Claude starter kit this blog documents, ships both renderers: the streaming chat that needs react-markdown in the browser, and this blog. The blog will not need it once that line goes.