July 9, 2026


Reference: fumadocs.dev
This guide follows the same kind of setup used in this app: Next.js App Router, Tailwind CSS,
fumadocs-mdx,fumadocs-ui, a/docsroute, static docs pages, and a static search API route.
I wanted docs that felt like part of the app, not a separate website bolted onto the side.
Fumadocs is useful because it gives you three separate pieces that work together:
fumadocs-mdx processes your local MDX files.fumadocs-core turns those files into a docs source, page tree, search data, slugs, and routes.fumadocs-ui gives you ready-made docs layouts, navigation, page UI, MDX components, and search UI.The important idea is this:
Your MDX files live in
content/docs, but your routes live inapp/docs.
That means you write documentation as content, while Next.js still controls routing through the App Router.
By the end, you will have this:
app/
api/
search/
route.ts
docs/
[[...slug]]/
page.tsx
layout.tsx
layout.tsx
layout.config.tsx
globals.css
components/
shared/
root-provider.tsx
content/
docs/
introduction.mdx
meta.json
lib/
source.tsx
source.config.ts
next.config.ts
tsconfig.jsonYour docs will be available at:
/docs
/docs/introduction
/docs/any-folder/any-pageBefore installing Fumadocs, make sure your app already has:
app directoryapp/globals.cssIn this project, the current setup is already close to the latest Fumadocs manual install flow:
{
"next": "16.2.10",
"react": "19.2.4",
"fumadocs-core": "^16.11.1",
"fumadocs-mdx": "^15.1.0",
"fumadocs-ui": "^16.11.1",
"tailwindcss": "^4"
}If you are on Next 15, the structure is still the same. Just keep your Fumadocs package versions compatible with your Next and React versions.
Install the Fumadocs packages:
pnpm add fumadocs-mdx fumadocs-core fumadocs-ui @types/mdxIf you use extra docs features like Git-based last modified dates, generated install commands, or GitHub-flavored markdown tables, install these too:
pnpm add fumadocs-docgen remark-gfmIf your docs UI uses icons from Lucide, install this:
pnpm add lucide-reactYour final dependency list may look like this:
{
"dependencies": {
"fumadocs-core": "^16.11.1",
"fumadocs-mdx": "^15.1.0",
"fumadocs-ui": "^16.11.1",
"@types/mdx": "^2.0.14",
"fumadocs-docgen": "^3.1.0",
"remark-gfm": "^4.0.1",
"lucide-react": "^1.23.0"
}
}In package.json, add this:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"postinstall": "fumadocs-mdx"
}
}Why this matters:
.source folder from your MDX config..source/server.fumadocs-mdx after install keeps that generated source available.You can also generate it manually:
pnpm fumadocs-mdxsource.config.tsCreate this file at the project root:
import {
defineDocs,
defineConfig,
frontmatterSchema,
} from 'fumadocs-mdx/config';
import lastModified from 'fumadocs-mdx/plugins/last-modified';
import { remarkInstall } from 'fumadocs-docgen';
import remarkGfm from 'remark-gfm';
import { z } from 'zod';
export const { docs, meta } = defineDocs({
dir: 'content/docs',
docs: {
schema: frontmatterSchema.extend({
new: z.boolean().default(false),
pro: z.boolean().default(false),
updated: z.boolean().default(false),
}),
},
});
export default defineConfig({
plugins: [lastModified({ versionControl: 'git' })],
mdxOptions: {
remarkPlugins: [remarkInstall, remarkGfm],
},
});What this file does:
defineDocs() tells Fumadocs where your docs content lives.dir: 'content/docs' means all docs MDX files go inside content/docs.docs is your page collection.meta is your navigation metadata collection.frontmatterSchema.extend(...) lets your docs use custom fields like new, pro, and updated.lastModified() can add last modified data from Git.remarkGfm enables GitHub-flavored Markdown, like tables and task lists.Important:
In newer Fumadocs setups, use
plugins: [lastModified({ versionControl: 'git' })].Older snippets may show
lastModifiedTime: 'git', but that belongs to older versions.
Open next.config.ts and wrap your Next config with createMDX().
Simple version:
import { createMDX } from 'fumadocs-mdx/next';
import type { NextConfig } from 'next';
const config: NextConfig = {
reactStrictMode: true,
};
const withMDX = createMDX();
export default withMDX(config);If you already have other wrappers, compose them carefully.
For example, this app also uses a bundle analyzer:
import createBundleAnalyzer from '@next/bundle-analyzer';
import { createMDX } from 'fumadocs-mdx/next';
import type { NextConfig } from 'next';
const withAnalyzer = createBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
const config: NextConfig = {
reactStrictMode: true,
};
const withMDX = createMDX();
export default withAnalyzer(withMDX(config));Why this matters:
createMDX() lets Next understand the generated Fumadocs MDX source..mdx processing and .source generation will not line up correctly.Open tsconfig.json and make sure you have your normal app alias:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}Some Fumadocs examples use an alias like this:
{
"compilerOptions": {
"paths": {
"collections/*": ["./.source/*"]
}
}
}In this app, the source imports use:
import { docs, meta } from '@/.source/server';So the normal @/* alias is enough.
Create:
content/docsThen add your first page:
---
title: Introduction
description: Welcome to the docs.
icon: BookOpen
---
# Introduction
This is my first Fumadocs page.Fumadocs uses the file path to build the route:
content/docs/introduction.mdx -> /docs/introduction
content/docs/getting-started/install.mdx -> /docs/getting-started/installIf you want /docs itself to show a page, you can create:
content/docs/index.mdxor redirect /docs to another page from your route file.
meta.jsonCreate:
{
"title": "Documentation",
"pages": ["introduction", "install-fumadocs-nextjs"]
}What this does:
title names the sidebar group.pages controls the order..mdx.For folders, you can add a nested meta.json:
content/docs/
meta.json
getting-started/
meta.json
installation.mdx
configuration.mdxExample:
{
"title": "Getting Started",
"pages": ["installation", "configuration"]
}lib/source.tsxCreate:
import { docs, meta } from '@/.source/server';
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
import { loader } from 'fumadocs-core/source';
export const source = loader({
baseUrl: '/docs',
source: toFumadocsSource(docs, meta),
});This file is the bridge between your generated MDX content and the Fumadocs UI routes.
Important version note:
If you see old code using
createMDXSourcefromfumadocs-mdx, do not use it in this setup.In this app, the working pattern is
toFumadocsSourcefromfumadocs-mdx/runtime/server.
If you want Lucide icons from frontmatter, use this richer version:
import { docs, meta } from '@/.source/server';
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
import { loader } from 'fumadocs-core/source';
import { icons } from 'lucide-react';
import { createElement } from 'react';
export const source = loader({
baseUrl: '/docs',
source: toFumadocsSource(docs, meta),
icon(icon) {
if (!icon) return;
if (icon in icons) return createElement(icons[icon as keyof typeof icons]);
},
});Now frontmatter like this can render an icon:
---
title: Introduction
icon: BookOpen
---Open your global CSS file:
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';Why this matters:
neutral.css gives the Fumadocs theme color tokens.preset.css gives the docs UI components their expected styling.If your app already has many custom CSS variables, keep them below or above based on which values you want to win.
RootProviderFumadocs UI needs its provider near the root of the app.
You can use it directly:
import './globals.css';
import type { ReactNode } from 'react';
import { RootProvider } from 'fumadocs-ui/provider/next';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className="flex min-h-screen flex-col">
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}In this app, a local wrapper is used instead:
'use client';
import {
RootProvider as FumadocsRootProvider,
type RootProviderProps,
} from 'fumadocs-ui/provider/next';
export function RootProvider(props: RootProviderProps) {
return <FumadocsRootProvider {...props} />;
}Then you can use the wrapper in your root layout or docs layout:
import { RootProvider } from '@/components/shared/root-provider';Important version note:
In newer Fumadocs UI versions, import from
fumadocs-ui/provider/next.If you copied older code using
fumadocs-ui/provider, it can fail withCannot find module 'fumadocs-ui/provider'.
Create:
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: BaseLayoutProps = {
nav: {
title: 'My Docs',
},
links: [
{
text: 'Home',
url: '/',
},
{
text: 'Docs',
url: '/docs',
active: 'nested-url',
},
],
};Why this matters:
baseOptions keeps your docs layout clean.Create:
import { source } from '@/lib/source';
import type { ReactNode } from 'react';
import { baseOptions } from '../layout.config';
import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
import { RootProvider } from '@/components/shared/root-provider';
export default function Layout({ children }: { children: ReactNode }) {
return (
<RootProvider
search={{
options: {
type: 'static',
},
}}
>
<DocsLayout
{...baseOptions}
tree={source.pageTree}
sidebar={{
prefetch: false,
defaultOpenLevel: 1,
}}
>
{children}
</DocsLayout>
</RootProvider>
);
}What this does:
DocsLayout renders the docs shell.tree={source.pageTree} gives the sidebar its navigation data.type: 'static' matches the static search route we will create later.prefetch: false can reduce unnecessary route prefetching in larger docs.You can switch layouts if you want:
import { DocsLayout } from 'fumadocs-ui/layouts/docs';or:
import { DocsLayout } from 'fumadocs-ui/layouts/notebook';This app uses the notebook layout.
Create:
import { source } from '@/lib/source';
import {
DocsPage,
DocsBody,
DocsTitle,
DocsDescription,
} from 'fumadocs-ui/page';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { notFound, redirect } from 'next/navigation';
export default async function Page(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) redirect('/docs/introduction');
const MDX = page.data.body;
return (
<DocsPage
toc={page.data.toc}
full={page.data.full}
tableOfContent={{
single: false,
style: 'clerk',
}}
breadcrumb={{
enabled: false,
}}
>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX
components={{
...defaultMdxComponents,
}}
/>
</DocsBody>
</DocsPage>
);
}
export const dynamic = 'force-static';
export const dynamicParams = false;
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) return {};
return {
title: page.data.title,
description: page.data.description,
};
}Why [[...slug]]?
/docs and /docs/introduction can both be handled by the same file.source.getPage(params.slug) finds the correct MDX page.Why generateStaticParams()?
In this app, the docs page is more advanced because it adds JSON-LD, footer actions, copy buttons, GitHub links, and custom MDX components. Start simple first, then add those features.
You can make tabs, accordions, files, and callouts available to every MDX page.
Example:
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { Callout } from 'fumadocs-ui/components/callout';
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { File, Folder, Files } from 'fumadocs-ui/components/files';
import { type ComponentProps, type FC } from 'react';
// inside <MDX components={{ ... }}>
<MDX
components={{
...defaultMdxComponents,
Tabs,
Tab,
TypeTable,
Accordion,
Accordions,
File,
Folder,
Files,
blockquote: Callout as unknown as FC<ComponentProps<'blockquote'>>,
}}
/>Now your MDX can use:
<Tabs items={['pnpm', 'npm']}>
<Tab value="pnpm">
```bash
pnpm add fumadocs-ui
```
</Tab>
<Tab value="npm">
```bash
npm install fumadocs-ui
```
</Tab>
</Tabs>Important:
Only add packages you actually installed.
If you copy code using
fumadocs-typescriptorfumadocs-typescript/ui, you must install that package first. If you are not usingAutoTypeTable, remove those imports.
Create:
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const revalidate = false;
export const { staticGET: GET } = createFromSource(source);Why this matters:
createFromSource(source) builds search data from your docs source.staticGET matches the static search mode used in RootProvider.revalidate = false tells Next this route is static.Your provider should match:
<RootProvider
search={{
options: {
type: 'static',
},
}}
>
{children}
</RootProvider>If you use a different search engine later, Fumadocs also supports other search options. Start with static search first because it is the simplest.
Run:
pnpm fumadocs-mdxor just start the dev server:
pnpm devFumadocs will generate:
.source/
server.tsThat generated file is why this import works:
import { docs, meta } from '@/.source/server';If .source/server cannot be found, run the generator again.
Start the app:
pnpm devThen open:
http://localhost:3000/docsIf your route redirects to introduction, also check:
http://localhost:3000/docs/introductionAt this point, you should see:
Use this frontmatter pattern:
---
title: Example Page
description: A short explanation of this page.
icon: FileText
new: true
---
# Example Page
Write your documentation here.Custom frontmatter fields from this app:
| Field | Type | What it does |
|---|---|---|
title | string | Page title |
description | string | Page description |
icon | string | Lucide icon name |
new | boolean | Shows a New badge in the sidebar |
pro | boolean | Shows a Pro badge in the sidebar |
updated | boolean | Shows an Updated badge in the sidebar |
The badge behavior comes from the custom pageTree.transformers logic in lib/source.tsx.
source.config.tsThis is the content configuration.
It answers:
lib/source.tsxThis is the runtime source.
It answers:
app/docs/layout.tsxThis is the docs shell.
It answers:
app/docs/[[...slug]]/page.tsxThis renders each MDX page.
It answers:
app/api/search/route.tsThis powers docs search.
It answers:
Module '"fumadocs-mdx"' has no exported member 'createMDXSource'This happens when copied code is from an older Fumadocs version.
Use this:
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';Then:
source: toFumadocsSource(docs, meta)Cannot find module 'fumadocs-ui/provider'Use the Next.js provider entry:
import { RootProvider } from 'fumadocs-ui/provider/next';Cannot find module 'fumadocs-typescript'You copied a feature that needs an optional package.
If you use AutoTypeTable, install it:
pnpm add fumadocs-typescriptIf you do not use it, remove:
import { AutoTypeTable } from 'fumadocs-typescript/ui';
import { createGenerator } from 'fumadocs-typescript';Property 'file' does not exist on type 'Page'Use:
page.pathnot:
page.file.path.source/server cannot be foundRun:
pnpm fumadocs-mdxor:
pnpm devAlso make sure source.config.ts exists at the project root.
Check these two files:
<RootProvider
search={{
options: {
type: 'static',
},
}}
>export const { staticGET: GET } = createFromSource(source);The provider and route should agree.
Use this checklist when adding Fumadocs to a Next.js 15+ app:
fumadocs-mdx, fumadocs-core, fumadocs-ui, and @types/mdx"postinstall": "fumadocs-mdx"source.config.tsnext.config.ts with createMDX()tsconfig.json has your import aliasescontent/docs.mdx pagecontent/docs/meta.jsonlib/source.tsxapp/globals.cssRootProvider from fumadocs-ui/provider/nextapp/layout.config.tsxapp/docs/layout.tsxapp/docs/[[...slug]]/page.tsxapp/api/search/route.tspnpm fumadocs-mdxpnpm dev/docsThe clean mental model is:
content/docs/*.mdx
-> source.config.ts
-> .source/server
-> lib/source.tsx
-> app/docs/layout.tsx
-> app/docs/[[...slug]]/page.tsx
-> /docs/*Once you understand that pipeline, Fumadocs becomes much easier.
You write content in content/docs, Fumadocs compiles it into a typed source, lib/source.tsx exposes that source to the app, and the /docs route renders it using Fumadocs UI.
The biggest advice is simple:
Copy Fumadocs examples carefully, but always match them to your installed package versions.
That is why this app uses toFumadocsSource, fumadocs-ui/provider/next, page.path, and staticGET. Those details are small, but they are exactly what keep the setup working in a modern Next.js 15+ app.