Skip to content

Server rendering

import { readFile } from 'node:fs/promises'
import Markdown from '@react-markdown-kit/renderer'


export default async function Page() {
  const content = await readFile('content/post.md', 'utf8')
  return <article><Markdown>{content}</Markdown></article>
}

You don't need "use client", a wrapper or a dynamic import. This is a plain server component, and the Markdown it renders doesn't send any JavaScript to the browser.

Why it works#

The renderer entry doesn't have a "use client" directive. React treats that directive as a module dependency boundary, so a module without one stays on the server side of the graph. The renderer also doesn't touch any browser globals, so it's safe to use in a static build with no DOM.

So it works in all four environments:

HostStatus
Browser React applicationWorks
Server-side renderingWorks
React Server ComponentsWorks, as a server component
Static rendering with no browser globalsWorks

Rendering is synchronous. There's no MarkdownAsync or MarkdownHooks, so an async remark or rehype plugin won't run. See compatibility.

Precompile at module scope#

compileMarkdown turns source into a MarkdownDocument. Parsing is roughly two thirds of the total work, so moving it out of the request path is the biggest improvement you can make.

import Markdown, { compileMarkdown, defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'


const preset = defineMarkdownPreset({ extensions: [gfm()] })


// Parsed once per process, not once per request.
const CHANGELOG = compileMarkdown(
  `## 1.4.0


- Streaming exports
- Smaller bundle
`,
  { preset },
)


export function Changelog() {
  return <Markdown document={CHANGELOG} />
}

Re-rendering from a document is about 2.8 times faster than re-parsing the string in the examples/next-server-rendering measurement.

A MarkdownDocument is a plain JSON object with no methods. It survives structuredClone, an HTTP response, a queue and a cache, so it can come from pretty much anywhere, for example:

  • a build step that compiles every MDX-free content file;
  • a database column holding the compiled form next to the source;
  • a fetch from a content service, compiled once upstream.
const document = JSON.parse(await redis.get(`post:${slug}`))
return <Markdown document={document} />

Make sure you compile with the same extensions you render with. A dialect extension such as gfm() changes what the parser recognizes, so a document compiled without it won't have any table nodes for the renderer to draw later.

The editor is a client component#

The editor handles selection, undo history and keyboard input, so it has to run on the client. Mark your wrapper as a client component.

'use client'


import { MarkdownEditor } from '@react-markdown-kit/editor'


export function PostEditor({ value, onChange }) {
  return <MarkdownEditor value={value} onChange={onChange} />
}

This is why the editor is a separate package. Installing the renderer never pulls editor code or Lexical into a server bundle, and tests/packaging.test.ts checks the dependency graph so we aren't relying on that by convention.

InstallationWhat the bundle must not contain
Renderer onlyLexical, editor runtime
Variables plugin on a serverAnything that renders: the plugin calls no React, no Lexical, no browser global
EditorMay depend on the renderer and on Lexical

That means an app with a page for saved content and a separate editing route can use the renderer on the server and only load the editor where editing happens.

Variables on the server#

@react-markdown-kit/variables resolves variables without rendering them. variables() runs inside compileMarkdown and doesn't call React or any browser globals, so you can run it in a route handler, a worker, a cron job or a CLI.

import { compileMarkdown } from '@react-markdown-kit/renderer'
import { variables } from '@react-markdown-kit/variables'


export async function POST(request: Request) {
  const document = compileMarkdown(source, {
    extensions: [variables({ data: await request.json(), schema: ReceiptSchema })],
  })
  if (document.diagnostics.some((d) => d.severity === 'error')) {
    return Response.json({ diagnostics: document.diagnostics }, { status: 422 })
  }
  return Response.json({ document })
}

The client can render that document with <Markdown document={...} />, or the server can serialize it with documentToMarkdown for an email body. Since both sides work from the same document, the output matches.

If the resolved output is personalized, read the cache rule before putting it behind a shared cache.

Example#

examples/next-server-rendering in the repository has a runnable version of this page. app/page.tsx reads a file with node:fs and renders it without a client directive anywhere, and app/precompiled.tsx compiles at module scope and renders from the document on each request.

Last updated on