Beautiful Documentation with Fumadocs
20. December, 2025 • 12 min read • Develop
Docs that live in the same app
Documentation usually ends up in a second repository, with a second build system, a second deployment target and a second set of design decisions that slowly drift from the product it describes. It works, and it is nobody's favourite part of the week. Fumadocs takes the other route: the docs are routes in your existing Next.js app, sharing its bundler, its Tailwind config and its components.
I wrote about Docusaurus a while back and ended by saying that MkDocs with Material is almost always enough. I still think that. Fumadocs is the exception to it, and only for one specific case: when the thing you are documenting is itself a Next.js or React project, and you want the documentation to be able to render the real component rather than a screenshot of it.
Everything below is against the 16.x line of fumadocs-core and fumadocs-ui, with fumadocs-mdx at 14. The API moved around quite a bit through 2025, so version numbers matter more than usual here.
What makes it different
Docusaurus is its own application. It has a build system, a routing model and a theming approach that are all its own. It does not care that you already have a Next.js project; it sits next to one. Nextra grew up in the Pages Router era and has spent a while catching up with the App Router.
Fumadocs was written for React Server Components from the start. Your pages render on the server and ship almost no client JavaScript; the only interactive parts are the search dialog and the sidebar toggles. If you already run Next.js with Tailwind, adding docs is adding a route group, not adopting a second framework.
The adoption list is the part that made me look properly. Zod, Better Auth, shadcn/ui and several of Vercel’s own SDK docs run on it, and those are projects whose documentation people actually read.
The other structural choice is that it splits into a headless core and a styled layer:
fumadocs-coredoes the work with no opinions about appearance: the Source API, search adapters, Markdown utilities.fumadocs-uiis the theme. Sidebar, table of contents, breadcrumbs, code blocks, callouts, built on Tailwind CSS v4.fumadocs-mdxturns MDX files on disk into typed data, with validated frontmatter and a generated table of contents.create-fumadocs-appscaffolds the whole thing.
You can take the core alone and build your own presentation. Almost nobody does at first, and the option matters mostly because it means the styled layer is not load-bearing. You can replace a piece of it without forking anything.
Getting it running
npm create fumadocs-appThat scaffolds a working Next.js project. In an existing one:
npm install fumadocs-core fumadocs-ui fumadocs-mdxThe MDX pipeline is a Next.js plugin, so next.config.ts has to know about it:
// next.config.ts
import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX();
export default withMDX({});Declaring the content
source.config.ts at the project root describes what exists and what shape it has:
// source.config.ts
import { defineDocs, frontmatterSchema } from 'fumadocs-mdx/config';
import { z } from 'zod';
export const docs = defineDocs({
dir: 'content/docs',
docs: {
schema: frontmatterSchema.extend({
category: z.enum(['guides', 'api', 'tutorials']).optional(),
audience: z.enum(['user', 'developer']).default('developer'),
}),
},
});Two things to get right here. schema takes an actual schema object, not a bag of field definitions. Plenty of tutorials show a plain object literal there, and it will not validate. And you extend frontmatterSchema rather than replacing it, because that is what already defines title and description for the rest of the pipeline.
The payoff is that a typo in a frontmatter key fails at build time with a message naming the file, instead of rendering an empty sidebar entry that nobody notices for two months. Zod is the obvious choice but any Standard Schema library works.
The loader
// lib/source.ts
import { loader } from 'fumadocs-core/source';
import { docs } from '@/.source';
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});.toFumadocsSource() is the bit people leave off. The generated docs export is a collection of entries; the loader wants a Source, and that method converts one into the other. Skip it and TypeScript will tell you, at some length.
What comes back is the Source API: getPage(slugs), getPages(), getPageTree(), generateParams() for static generation. That object is the only interface between your content and your pages.
Layout and page
// app/docs/layout.tsx
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { source } from '@/lib/source';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout
tree={source.getPageTree()}
nav={{ title: 'My Docs' }}
sidebar={{ defaultOpenLevel: 1 }}
>
{children}
</DocsLayout>
);
}// app/docs/[[...slug]]/page.tsx
import { source } from '@/lib/source';
import { DocsPage, DocsBody } from 'fumadocs-ui/layouts/docs/page';
import { notFound } from 'next/navigation';
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
const page = source.getPage(slug);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc}>
<DocsBody>
<MDX />
</DocsBody>
</DocsPage>
);
}
export function generateStaticParams() {
return source.generateParams();
}That is the whole integration. Drop MDX files into content/docs/ and they appear, with navigation, breadcrumbs and a table of contents built from the headings.
Writing in it
Code blocks go through Shiki at build time, so there is no highlighting library in the client bundle. Line ranges, titles and copy buttons come from the fence:
```typescript title="api/users.ts" {3-5}
import { db } from '@/lib/db';
export async function getUsers() {
return db.user.findMany();
}
```Callouts, tabs, steps and cards are components you can use directly in MDX:
<Callout type="warn">
This deletes data and does not ask twice.
</Callout>
<Tabs groupId="package-manager" items={['npm', 'pnpm', 'yarn']}>
<Tab value="npm">npm install fumadocs-ui</Tab>
<Tab value="pnpm">pnpm add fumadocs-ui</Tab>
<Tab value="yarn">yarn add fumadocs-ui</Tab>
</Tabs>The groupId on tabs is the detail I did not expect to care about and now would not give up. Every tab group sharing that id switches together, and the choice persists as the reader moves between pages. A pnpm user picks pnpm once at the top of the install guide and never sees an npm install again. It is a tiny thing that removes a constant low-grade annoyance from every documentation site that has ever existed.
For library documentation there is TypeTable, which renders a props or options table without you hand-maintaining a Markdown grid:
import { TypeTable } from 'fumadocs-ui/components/type-table';
<TypeTable
type={{
name: {
type: 'string',
description: 'The user display name',
required: true,
},
role: {
type: "'admin' | 'user'",
description: 'User role',
default: "'user'",
},
}}
/>Search
Orama is the default and it is self-hosted, meaning it runs in your own route handler with no external service:
// app/api/search/route.ts
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source);Four lines, and Cmd+K works. Fumadocs indexes your content structurally (headings, sections, paragraphs) rather than dumping page text into a blob, so results land you on the right section rather than the top of a long page.
If you outgrow it, there is an Algolia adapter, though it works differently from what you might expect. It is not a drop-in replacement for the route handler: you write a sync script that runs after a build and pushes records up.
// scripts/sync-algolia.ts
import { algoliasearch } from 'algoliasearch';
import { sync } from 'fumadocs-core/search/algolia';
import { source } from '@/lib/source';
const client = algoliasearch(
process.env.ALGOLIA_APP_ID!,
process.env.ALGOLIA_ADMIN_KEY!
);
void sync(client, {
indexName: 'docs',
documents: source.getPages().map((page) => ({
_id: page.url,
title: page.data.title,
description: page.data.description,
url: page.url,
structured: page.data.structuredData,
})),
});There is an Orama Cloud adapter too if you want hosted search without Algolia’s pricing conversation. For anything under a few hundred pages, plain Orama is the right answer and adding a search vendor is work you have invented for yourself.
API reference from OpenAPI
fumadocs-openapi is a separate package (npm i fumadocs-openapi shiki, plus one more stylesheet import), and worth mentioning because most documentation frameworks make you find a third-party plugin for this and then maintain it. You point createOpenAPI from fumadocs-openapi/server at an OpenAPI 3.0 or 3.1 document and it generates reference pages: endpoints, parameter tables, request and response schemas, example payloads, styled like the rest of the site rather than like an embedded Swagger UI.
The value is not the rendering. It is that the reference is generated from the same spec your server validates against, so it cannot drift. Hand-written endpoint tables always drift.
Theming
fumadocs-ui is built on Tailwind CSS v4, which means the CSS-first configuration I wrote about in the Tailwind v4 post applies directly. Eight colour themes ship with it: neutral, black, vitepress, dusk, catppuccin, ocean, purple and solar. You pick one by importing its stylesheet.
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@theme {
--color-fd-primary: oklch(0.55 0.2 250);
--color-fd-background: oklch(0.98 0 0);
}Every token is prefixed --color-fd-*, which keeps them out of the way of your application’s own variables. That is a small piece of foresight, and it saves a genuinely annoying afternoon when the docs and the app share a stylesheet.
There is also a shadcn.css that maps the Fumadocs tokens onto shadcn/ui’s colour system. If your app already uses shadcn/ui, that one import makes the documentation look like it belongs to the product rather than next to it.
Beyond colour, the layout handles RTL, collapsible sidebar folders with configurable open depth, root-level tabs for splitting “Guides” from “API Reference”, banners for version notices, and a mobile navigation that you do not have to think about.
Multiple languages
i18n is configured once and threaded through the loader:
// lib/i18n.ts
import { defineI18n } from 'fumadocs-core/i18n';
export const i18n = defineI18n({
languages: ['en', 'de', 'fr'],
defaultLanguage: 'en',
hideLocale: 'default-locale',
});// lib/source.ts
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
i18n,
});A page tree is built per language, missing translations fall back to the default, and the search index is built per locale so people search in the language they are reading. hideLocale: 'default-locale' keeps /docs/getting-started rather than /docs/en/getting-started, which matters if you have existing URLs and an SEO person.
Living in Switzerland, I have opinions about translation workflows that are not really about tooling. Fumadocs does the mechanical part properly. Keeping four languages actually in sync is a staffing problem, not a framework problem.
Navigation and structure
The sidebar comes from the directory structure. Nested folders become nested sections. Where you want to override the ordering or the label, drop a meta.json into the folder:
{
"title": "Getting Started",
"pages": ["installation", "quickstart", "configuration"]
}This is one of the few places I would have designed it differently. Ordering lives in meta.json, page metadata lives in frontmatter, and when a page is in the wrong place you have to remember which file to open. It is a small tax and it never quite stops being one.
Performance
MDX compiles to Server Components, so a documentation page ships the HTML and essentially nothing else. generateStaticParams() means every page can be prerendered at build time, and Orama’s index can be prerendered too, which leaves you with a directory of static files and no server runtime at all.
The Source API works from in-memory structures built during the build. There is no database, no external service, nothing to provision. For a documentation site that is exactly the right amount of infrastructure.
Where it doesn’t fit
| Fumadocs | Docusaurus | Nextra | |
|---|---|---|---|
| Runs inside | Your Next.js app | Its own build | Next.js |
| Rendering | RSC, minimal JS | Client-side | Pages or App Router |
| Styling | Tailwind v4 | CSS Modules | Tailwind or CSS |
| Content | MDX with a typed schema | MDX | MDX |
| Search | Orama, Orama Cloud, Algolia | Algolia | Flexsearch |
| API reference | fumadocs-openapi |
Plugin | Roll your own |
| Customising | Headless core plus UI layer | Swizzling | Theme options |
Docusaurus has a much larger community, more plugins and more Stack Overflow answers, and that is worth real money when something breaks at an awkward hour. Fumadocs is younger and moves faster: the API churn through 2025 was not trivial, and reading a tutorial written six months ago will hand you imports that no longer exist.
And the complaint I had about Docusaurus applies here twice over. It is React all the way down. A broken MDX import fails your production build, and now it fails your product’s production build, because they are the same build. That is arguably correct and it is not the trade I would make for a Python service’s documentation, where Sphinx or MkDocs will serve you better and nobody has to learn anything.
Where I’ve landed
For a Next.js project whose docs need to render live components: Fumadocs, without much hesitation. The docs inherit the design system, the components are the real ones, and there is no second deployment to forget about.
For everything else, my answer from the Docusaurus post stands. MkDocs with Material is almost always enough, and “almost always enough” remains an underrated property in a documentation tool.
What I want to try next is pointing fumadocs-openapi at a spec generated from a running server, so the reference pages fail the build when an endpoint changes shape. That feels like the version of this that would actually keep documentation honest
‘Till next time!