Skip to main content

Fumadocs

A Fumadocs site is a Next.js app, so ondocs is the same three pieces as in Next.js: an index built by the CLI from content/docs, route handlers for the ask and MCP endpoints, and the <AskDialog /> component. The CLI reads content/docs as Fumadocs does, so every citation links to a page Fumadocs serves. This page follows a site made with npm create fumadocs-app and its Next.js template.

Install for Fumadocs​

npm i ondocs ai @ai-sdk/openai @radix-ui/react-dialog cmdk

Index content/docs​

Build the index before every build, and commit ask-index.json:

{
"scripts": {
"index": "ondocs index content/docs --base-url /docs -e openai:text-embedding-3-small --dimensions 512",
"prebuild": "npm run index"
}
}

--base-url /docs is the baseUrl of the loader() in lib/source.ts. The CLI finds next.config.mjs above content/docs and follows Fumadocs's rules:

  • index.mdx is its folder's page: guides/index.mdx is /docs/guides.
  • (group) folders are left out: (api)/reference.mdx is /docs/reference.
  • Every other file is a page of its own, as Fumadocs serves it, page.mdx and _-prefixed files included. If defineDocs leaves files out with files, pass the same patterns to --ignore.
  • meta.json orders the sidebar but does not change URLs, so the index does not need it.
  • A heading written ## Use Docker [#docker] is cited at #docker, as Fumadocs anchors it.

Without an API key, replace -e openai:text-embedding-3-small --dimensions 512 with -e none to build a keyword-only index, and leave embeddingModel out of the ask handler below.

Mount the route handlers​

The ask endpoint and the MCP endpoint are two route handlers that import the same index:

// app/api/ask/route.ts
import { openai } from '@ai-sdk/openai';
import { createAskHandler, memoryRateLimit } from 'ondocs/server';
import index from '@/ask-index.json';

export const POST = createAskHandler({
index,
model: openai('gpt-5.4-mini'),
embeddingModel: openai.embedding('text-embedding-3-small'),
siteName: 'Acme Docs',
rateLimit: memoryRateLimit({ limit: 10, windowMs: 60_000 }),
});
// app/api/mcp/route.ts
import { createMcpHandler } from 'ondocs/server';
import index from '@/ask-index.json';

const handler = createMcpHandler({ index, siteName: 'the Acme docs' });

export const POST = handler;
export const GET = handler;
export const DELETE = handler;
export const OPTIONS = handler;

The template's proxy.ts only rewrites paths under /docs, so it leaves /api/ask and /api/mcp alone. Set OPENAI_API_KEY in your deployment's environment. The MCP endpoint calls no model, so agents can search the docs with no key at all: see MCP server.

Render the dialog​

Render the dialog from a client component. Fumadocs's search opens with ⌘K, so shortcut="i" gives the dialog ⌘I. Fumadocs switches between light and dark with next-themes, which sets a dark class on <html> that the dialog does not read, so the component passes it the theme next-themes resolved. Install next-themes, the package Fumadocs already uses, with npm i next-themes:

// components/ask.tsx
'use client';
import { useEffect, useState } from 'react';
import { AskDialog } from 'ondocs/react';
import 'ondocs/react/styles.css';
import 'ondocs/embed/launcher.css';
import { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation';

export function Ask() {
const router = useRouter();
const { resolvedTheme } = useTheme();
// next-themes knows the theme only once the page runs; until then, follow the system's.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return (
<AskDialog
launcher
shortcut="i"
theme={mounted ? (resolvedTheme === 'dark' ? 'dark' : 'light') : undefined}
suggestions={['How do I install it?']}
onNavigate={(url, event) => {
event.preventDefault();
router.push(url);
}}
/>
);
}

Then render <Ask /> inside <RootProvider> in app/layout.tsx, after {children}, where next-themes is set up. launcher shows a floating "Ask AI" button, and onNavigate sends citations through the Next.js router instead of a full page load.

llms.txt and Markdown pages​

The template already serves /llms.txt, /llms-full.txt and a Markdown copy of each page at its URL plus .md, from its own routes and proxy.ts, in the order of your meta.json files. Keep them, and leave --llms-txt out of the index command. To point agents at the MCP endpoint, add a line to app/llms.txt/route.ts:

export async function GET() {
const mcp = 'Agents can search these docs over MCP: https://docs.acme.dev/api/mcp';
return new Response(`${await docsLlms.index()}\n\n${mcp}\n`);
}

To link each page to its copy from its <head>, add the template's own Markdown URL to the metadata of app/docs/[[...slug]]/page.tsx:

export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();

return {
title: page.data.title,
description: page.data.description,
openGraph: { images: getPageImageUrl(page).url },
alternates: { types: { 'text/markdown': getPageMarkdownUrl(page).url } },
};
}

A Fumadocs site without these routes can have the CLI write the files instead, with --llms-txt public: see llms.txt and Markdown pages.

Keep the index fresh in CI​

Add the check to CI so a pull request that changes content/docs without rebuilding the index fails:

npx ondocs index content/docs --base-url /docs --check # exit 1 if stale

The check never calls a model and needs no API key.