Setup
Install the package and wire it into a React Router or Next.js site, with the code highlighting, the search route and the stylesheets in order.
A site on @contour-ui/fumadocs is a Fumadocs site without fumadocs-ui: fumadocs-core and
fumadocs-mdx read and compile the content as their docs describe,
and the components of this package draw it. This page sets up a site on React Router, as this one
is, and on the Next.js App Router.
Requirements
- React 19, React DOM 19 and
fumadocs-core16.17.8 or a later 16: peer dependencies. - A content source for the
loader()offumadocs-core:fumadocs-mdxin the examples here. - With TypeScript,
@types/mdx, an optional peer dependency: the types ofgetMdxComponents.
Install
npm install @contour-ui/fumadocs @contour-ui/react @contour-ui/tokens @contour-ui/icons
npm install fumadocs-core fumadocs-mdx
npm install -D @types/mdx shikipnpm add @contour-ui/fumadocs @contour-ui/react @contour-ui/tokens @contour-ui/icons
pnpm add fumadocs-core fumadocs-mdx
pnpm add -D @types/mdx shikiyarn add @contour-ui/fumadocs @contour-ui/react @contour-ui/tokens @contour-ui/icons
yarn add fumadocs-core fumadocs-mdx
yarn add --dev @types/mdx shikibun add @contour-ui/fumadocs @contour-ui/react @contour-ui/tokens @contour-ui/icons
bun add fumadocs-core fumadocs-mdx
bun add --dev @types/mdx shiki@contour-ui/reactand@contour-ui/tokensare dependencies of your own: your app imports their stylesheets itself (Installation says why).- Icons gives the icons of the site's links, such as GitHub's. Shiki gives the theme of
the code blocks, below: take the major version
fumadocs-coredepends on. - Coming from
fumadocs-ui, uninstall it, and drop its stylesheet, itsRootProvider, its layouts and its MDX components: those of this package take their place.
Highlight the code
CodeBlock colors code from Shiki's
css-variables theme, so one theme serves the dark and the light theme. rehypeCode, the
highlighter of Fumadocs, takes it in the MDX options of fumadocs-mdx, with two more settings for
the pre that CodeBlock renders:
import { createCssVariablesTheme } from 'shiki';
const rehypeCodeOptions = {
// One theme, as a one-entry map whose key is the default color: rehypeCode merges its light
// and dark pair into the options, and Shiki takes `themes` over `theme`.
themes: { tokens: createCssVariablesTheme() },
defaultColor: 'tokens',
// CodeBlock's scroll area takes the focus: the `pre` stays out of the Tab order.
tabindex: false,
// No language icon: the header of a code block shows its title.
icon: false,
};The options go into the Vite plugin of fumadocs-mdx or into source.config.ts, as the two
setups below show.
React Router
React Router in framework mode, prerendered to static files, as this site is.
The content
import { reactRouter } from '@react-router/dev/vite';
import { fumadocsMdx } from 'fumadocs-mdx/vite';
import { createCssVariablesTheme } from 'shiki';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
fumadocsMdx({
globalOptions: {
mdxOptions: {
rehypeCodeOptions: {
themes: { tokens: createCssVariablesTheme() },
defaultColor: 'tokens',
tabindex: false,
icon: false,
},
},
},
}),
reactRouter(),
],
});import { loader } from 'fumadocs-core/source';
import { defineDocs } from 'fumadocs-mdx/macro';
export const docs = defineDocs({
dir: 'content/docs',
// The MDX of a page loads when the page opens, not with the app.
docs: { async: true },
});
export const source = loader({
source: docs.toFumadocsSource(),
baseUrl: '/docs',
});The routes
import { index, route } from '@react-router/dev/routes';
import type { RouteConfig } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('docs/*', 'routes/docs.tsx'),
route('api/search', 'routes/search.ts'),
] satisfies RouteConfig;The root
import { DocsProvider, docsScript } from '@contour-ui/fumadocs';
import type { DocsSite } from '@contour-ui/fumadocs';
import { githubIcon } from '@contour-ui/icons';
import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
import type { ReactNode } from 'react';
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
import '@contour-ui/tokens/tokens.css';
import '@contour-ui/tokens/fonts.css';
import '@contour-ui/fumadocs/styles.css';
import '@contour-ui/react/styles.css';
const site: DocsSite = {
name: 'My docs',
nav: [{ label: 'Docs', href: '/docs' }],
links: [{ label: 'GitHub', href: 'https://github.com/me/my-docs', icon: githubIcon }],
};
export function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<script dangerouslySetInnerHTML={{ __html: docsScript() }} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<ReactRouterProvider>
<DocsProvider site={site}>{children}</DocsProvider>
</ReactRouterProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}ReactRouterProvidergives the layouts the links, the path and the router of React Router.DocsProviderholds the site, the theme and the search: its props are in Layouts.docsScript()returns an inline script that sets the theme and a collapsed sidebar on<html>before the first paint.suppressHydrationWarninglets React accept the attributes it adds. Give the script the options you giveDocsProvider(The script).
A docs page
The loader finds the page and sends the page tree; the component renders the page in
DocsLayout, once its MDX has loaded:
import { DocsLayout, DocsPage } from '@contour-ui/fumadocs';
import { getMdxComponents, Prose } from '@contour-ui/fumadocs/mdx';
import { useFumadocsLoader } from 'fumadocs-core/source/client';
import { use } from 'react';
import { useLoaderData } from 'react-router';
import type { LoaderFunctionArgs } from 'react-router';
import { docs, source } from '../lib/source';
export async function loader({ params }: LoaderFunctionArgs) {
const page = source.getPage((params['*'] ?? '').split('/').filter(Boolean));
if (page === undefined) {
throw new Response('Not found', { status: 404 });
}
return {
path: page.path,
title: page.data.title,
description: page.data.description,
pageTree: await source.serializePageTree(source.getPageTree()),
};
}
interface ContentProps {
path: string;
title: string;
description: string | undefined;
}
function Content({ path, title, description }: ContentProps) {
const page = docs.getPage(path);
if (page === undefined) {
throw new Error(`Unknown page: ${path}`);
}
const { toc } = use(page.load());
const Mdx = page.body;
return (
<DocsPage title={title} description={description} toc={toc}>
<Prose>
<Mdx components={getMdxComponents()} />
</Prose>
</DocsPage>
);
}
export default function Page() {
const { path, title, description, pageTree } = useFumadocsLoader(useLoaderData<typeof loader>());
return (
<>
<title>{title}</title>
<DocsLayout tree={pageTree}>
<Content path={path} title={title} description={description} />
</DocsLayout>
</>
);
}The layout and the page find the current page in the tree by the path of the router. A page of
your own site, such as the home page, goes in HomeLayout:
import { HomeLayout } from '@contour-ui/fumadocs';
export default function Home() {
return (
<HomeLayout>
<h1>My docs</h1>
</HomeLayout>
);
}The search route
import { createFromSource } from 'fumadocs-core/search/server';
import { source } from '../lib/source';
const server = createFromSource(source, { language: 'english' });
export function loader() {
return server.staticGET();
}staticGET() serves the whole index as one file, which the search downloads as it first opens
(Search).
Prerender
import { glob } from 'node:fs/promises';
import type { Config } from '@react-router/dev/config';
import { createGetUrl, getSlugs } from 'fumadocs-core/source';
const getUrl = createGetUrl('/docs');
export default {
ssr: false,
async prerender({ getStaticPaths }) {
// The routes without parameters, the search index among them, then a path per page.
const paths = [...getStaticPaths()];
for await (const entry of glob('**/*.mdx', { cwd: 'content/docs' })) {
paths.push(getUrl(getSlugs(entry)));
}
return paths;
},
} satisfies Config;A static host answers a path that was not prerendered, such as a page that does not exist, with
the app and no data for its route: give the docs route a clientLoader that returns
serverLoader(), so React Router asks the host for the data of that page, gets a 404, and renders
your error boundary. With a server instead (ssr: true), leave prerender out; the rest stays the
same.
Next.js
The App Router renders pages as React Server Components. The parts of this package that need the
browser are client modules ('use client'), and the ones a server calls, docsScript(),
HomeLayout, getMdxComponents(), Prose and Preview, are server modules: the files below
stay server components.
The content
import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {};
export default withMDX(config);import { defineConfig } from 'fumadocs-mdx/config';
import { createCssVariablesTheme } from 'shiki';
export default defineConfig({
mdxOptions: {
rehypeCodeOptions: {
themes: { tokens: createCssVariablesTheme() },
defaultColor: 'tokens',
tabindex: false,
icon: false,
},
},
});import { loader } from 'fumadocs-core/source';
import { defineDocs } from 'fumadocs-mdx/macro';
const docs = defineDocs({ dir: 'content/docs' });
export const source = loader({
source: docs.toFumadocsSource(),
baseUrl: '/docs',
});The root layout
import { DocsProvider, docsScript } from '@contour-ui/fumadocs';
import type { DocsSite } from '@contour-ui/fumadocs';
import { githubIcon } from '@contour-ui/icons';
import { NextProvider } from 'fumadocs-core/framework/next';
import type { ReactNode } from 'react';
import '@contour-ui/tokens/tokens.css';
import '@contour-ui/tokens/fonts.css';
import '@contour-ui/fumadocs/styles.css';
import '@contour-ui/react/styles.css';
const site: DocsSite = {
name: 'My docs',
nav: [{ label: 'Docs', href: '/docs' }],
links: [{ label: 'GitHub', href: 'https://github.com/me/my-docs', icon: githubIcon }],
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: docsScript() }} />
</head>
<body>
<NextProvider>
<DocsProvider site={site}>{children}</DocsProvider>
</NextProvider>
</body>
</html>
);
}The docs
import { DocsLayout } from '@contour-ui/fumadocs';
import type { ReactNode } from 'react';
import { source } from '@/lib/source';
export default function Layout({ children }: { children: ReactNode }) {
return <DocsLayout tree={source.getPageTree()}>{children}</DocsLayout>;
}import { DocsPage } from '@contour-ui/fumadocs';
import { getMdxComponents, Prose } from '@contour-ui/fumadocs/mdx';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { source } from '@/lib/source';
interface Props {
params: Promise<{ slug?: string[] }>;
}
export default async function Page({ params }: Props) {
const page = source.getPage((await params).slug);
if (page === undefined) notFound();
const Mdx = page.data.body;
return (
<DocsPage title={page.data.title} description={page.data.description} toc={page.data.toc}>
<Prose>
<Mdx components={getMdxComponents()} />
</Prose>
</DocsPage>
);
}
export function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const page = source.getPage((await params).slug);
if (page === undefined) notFound();
return { title: page.data.title, description: page.data.description };
}The home page and the other pages of the site go in HomeLayout, as with React Router.
The search route
import { createFromSource } from 'fumadocs-core/search/server';
import { source } from '@/lib/source';
// Built once: the index is a static file, also with `output: 'export'`.
export const revalidate = false;
export const { staticGET: GET } = createFromSource(source, { language: 'english' });contour-ui in server components
Props that go from a server component to a client one must be serializable: data, React nodes
and components of client modules, never functions. DocsSite is: its name is a string or an
element, and its icons are plain data.
@contour-ui/react and @contour-ui/headless-react are built one file per module, and the
modules that need the browser start with 'use client'. A server component, an MDX page included,
imports @contour-ui/react directly: the parts that only render markup (Button, Kbd, Card,
Table…) render on the server, the others are client components it renders by reference, and a
namespace such as Card is a server module, so Card.Root works:
import { Button, Card } from '@contour-ui/react';
<Card.Root>
<Card.Title>Rendered on the server</Card.Title>
<Button asChild>
<a href="/docs">Read the docs</a>
</Button>
</Card.Root>;Code with handlers or state, such as a dialog that opens on onClick, goes in a module of your own
that starts with 'use client'. Such a module hands a server only references: export its
components by name, since a server component cannot read inside an object it exports.
themeScript() also comes without React from @contour-ui/react/theme-script; docsScript()
holds it already.
Other frameworks
fumadocs-core has an adapter for each framework it supports: wrap DocsProvider in the one of
yours, as above, such as TanstackProvider (fumadocs-core/framework/tanstack) for TanStack
Router and Start, or WakuProvider (fumadocs-core/framework/waku) for Waku. For another router,
FrameworkProvider from fumadocs-core/framework takes its usePathname, useParams,
useRouter and Link.