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:fsin 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: falseor auseEffect. There's also noMarkdownAsyncorMarkdownHooks, 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).
## Release notes Shipped **today**, rendered on the server. | Stage | Where it runs | | --- | --- | | Parse | Server | | Render | Server | | Hydrate | Nothing to hydrate |
Release notes
Shipped today, rendered on the server.
| Stage | Where it runs |
|---|---|
| Parse | Server |
| Render | Server |
| Hydrate | Nothing 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).
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 chatThe 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