Skip to content

Markdown in Next.js

The renderer works as a server component. It can read files and run during a static build, and it doesn't ship any JavaScript to the browser for the Markdown itself.

A server component in the App Router#

There's no use client in this file or in the renderer entry, so React keeps it on the server.

// app/posts/[slug]/page.tsx
import { readdir, readFile } from 'node:fs/promises'
import Markdown, { defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'


// The dialect, decided once for the whole app.
const preset = defineMarkdownPreset({ extensions: [gfm()] })


export async function generateStaticParams() {
  const files = await readdir('content')
  return files
    .filter((file) => file.endsWith('.md'))
    .map((file) => ({ slug: file.replace(/\.md$/, '') }))
}


export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const content = await readFile(`content/${slug}.md`, 'utf8')


  return (
    <article className="rmk-document">
      <Markdown preset={preset}>{content}</Markdown>
    </article>
  )
}

A few things make that work.

  • No use client. React treats the directive as a module boundary, so a module without one stays on the server side of the graph.
  • No browser globals. It's safe to read from node:fs in the same component, and the same code runs in a static export where there's no DOM.
  • Synchronous rendering. You don't need dynamic(), ssr: false or a useEffect. There's also no MarkdownAsync or MarkdownHooks, which means async remark or rehype plugins won't run (compatibility).

The rmk-document class is optional. It scopes the shipped stylesheet, which you'd import once in the root layout if you want the typography (see styling).

What that page renders (running live here)
Markdown
## Release notes

Shipped **today**, rendered on the server.

| Stage | Where it runs |
| --- | --- |
| Parse | Server |
| Render | Server |
| Hydrate | Nothing to hydrate |
Rendered

Release notes

Shipped today, rendered on the server.

StageWhere it runs
ParseServer
RenderServer
HydrateNothing to hydrate

Precompile what does not change#

Parsing is roughly two thirds of the work. If you move it out of the request path and re-render from the compiled document, it comes out about 2.8 times faster in the benchmark (results, benchmarks/run.mjs).

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


// Parsed once per process, not once per request.
const TERMS = compileMarkdown(await readFile('content/terms.md', 'utf8'))


export default function Page() {
  return <Markdown document={TERMS} />
}

A compiled document is plain JSON. It can go through a cache, a queue or an HTTP boundary, so it could also come from a build step, from unstable_cache, or from a CMS webhook that compiles on publish. The content policy runs again on the way to HTML, so compiling early doesn't let anything skip it (test).

Compiling documents

Route handlers and metadata#

The same import works outside a page too. A route handler can compile Markdown and return the document as JSON. For generateMetadata, read the front matter your own loader parsed (the renderer only takes the body as a string).

// app/api/doc/[slug]/route.ts
import { NextResponse } from 'next/server'
import { compileMarkdown } from '@react-markdown-kit/renderer'


export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  return NextResponse.json(compileMarkdown(await load(slug)))
}

Markdown from users#

Raw HTML shows up as visible escaped text instead of being executed, and URL schemes outside http, https, irc, ircs, mailto and xmpp get emptied. Both defaults are asserted in packages/renderer/tests/render.dom.test.tsx, and they apply the same way in a server component as in the browser. More in the security model.

Streaming a model's answer#

A Next.js chat route streams tokens to a client component. Pass the renderer the longer string on each token. Every prefix renders, and blocks that are already finished stay byte-identical while the next one is being written (streaming tests).

Streaming Markdown in React for AI chat

The editor is a client component#

The editor is interactive, so it has to be a client component. That's the main reason it's a separate package. Installing the renderer doesn't pull editor code into a server bundle, and a packaging test checks this against the renderer's own modules (tests/packaging.test.ts). scripts/pack-check.mjs packs the actual npm tarballs and installs them into clean consumer fixtures in CI.

'use client'


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

FAQ#

Can I render Markdown in a Next.js server component?
Yes. @react-markdown-kit/renderer doesn't have a use client directive and doesn't touch any browser globals, so the Markdown component renders inside an async server component that reads the file with node:fs. No JavaScript ships for the Markdown itself.
Do I need dynamic import or a client wrapper for Markdown in Next.js?
No. Rendering is synchronous and doesn't need a DOM, so you don't need a dynamic import, an ssr:false wrapper or a useEffect. Only the editor package is a client component, since it's interactive.
How do I make repeat requests skip the Markdown parse?
Call compileMarkdown once at module scope and pass the resulting document to the renderer. Parsing is roughly two thirds of the work, so re-rendering from a compiled document comes out about 2.8 times faster in benchmarks/run.mjs.

Next#

Renderer demo · @react-markdown-kit/renderer on npm · Source on GitHub

Server rendering in depth · React Markdown renderer · How to render Markdown in React · Streaming Markdown in React · Migrate from react-markdown

Last updated on