---
title: AI & LLMs
description: Integrate AI functionality to Fumadocs.
---

## Docs for LLM

You can make your docs site more AI-friendly with dedicated docs content for large language models.

To begin, make a `getLLMText` function that converts pages into static MDX content.

In **Fumadocs MDX**, you can do:

```ts title="lib/get-llm-text.ts"
import { source } from '@/lib/source';

export async function getLLMText(page: (typeof source)['$inferPage']) {
  const processed = await page.data.getText('processed');

  return `# ${page.data.title} (${page.url})

${processed}`;
}
```

It requires `includeProcessedMarkdown` to be enabled:

```ts title="source.config.ts"
import { defineDocs } from 'fumadocs-mdx/config';

export const docs = defineDocs({
  docs: {
    // [!code ++:3]
    postprocess: {
      includeProcessedMarkdown: true,
    },
  },
});
```

### `llms.txt`

You can generate it from page tree using Loader API.

```ts tab="Next.js" title="app/llms.txt/route.ts"
import { source } from '@/lib/source';
import { llms } from 'fumadocs-core/source';

// cached forever
export const revalidate = false;

export function GET() {
  return new Response(llms(source).index());
}
```

```ts tab="React Router" title="app/routes.ts"
import { index, route, type RouteConfig } from '@react-router/dev/routes';

export default [
  // [!code ++]
  route('llms.txt', 'routes/llms.ts'),
] satisfies RouteConfig;
```

```ts tab="React Router" title="app/routes/llms.ts"
import { source } from '@/lib/source';
import { llms } from 'fumadocs-core/source';

export function loader() {
  return new Response(llms(source).index());
}
```

```ts tab="Tanstack Start" title="src/routes/llms[.]txt.ts"
import { createFileRoute } from '@tanstack/react-router';
import { source } from '@/lib/source';
import { llms } from 'fumadocs-core/source';

export const Route = createFileRoute('/llms.txt')({
  server: {
    handlers: {
      GET() {
        return new Response(llms(source).index());
      },
    },
  },
});
```

```ts tab="Waku" title="pages/_api/llms.txt.ts"
import { source } from '@/lib/source';
import { llms } from 'fumadocs-core/source';

export function GET() {
  return new Response(llms(source).index());
}

export async function getConfig() {
  return {
    render: 'static' as const,
  } as const;
}
```

### `llms-full.txt`

A version of docs for AIs to read.

```ts tab="Next.js" title="app/llms-full.txt/route.ts"
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';

// cached forever
export const revalidate = false;

export async function GET() {
  const scan = source.getPages().map(getLLMText);
  const scanned = await Promise.all(scan);

  return new Response(scanned.join('\n\n'));
}
```

```ts tab="React Router" title="app/routes.ts"
import { index, route, type RouteConfig } from '@react-router/dev/routes';

export default [
  // [!code ++]
  route('llms-full.txt', 'routes/llms-full.ts'),
] satisfies RouteConfig;
```

```ts tab="React Router" title="app/routes/llms-full.ts"
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';

export async function loader() {
  const scan = source.getPages().map(getLLMText);
  const scanned = await Promise.all(scan);

  return new Response(scanned.join('\n\n'));
}
```

```ts tab="Tanstack Start" title="src/routes/llms-full[.]txt.ts"
import { createFileRoute } from '@tanstack/react-router';
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';

export const Route = createFileRoute('/llms-full.txt')({
  server: {
    handlers: {
      GET: async () => {
        const scan = source.getPages().map(getLLMText);
        const scanned = await Promise.all(scan);
        return new Response(scanned.join('\n\n'));
      },
    },
  },
});
```

```ts tab="Waku" title="pages/_api/llms-full.txt.ts"
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';

export async function GET() {
  const scan = source.getPages().map(getLLMText);
  const scanned = await Promise.all(scan);

  return new Response(scanned.join('\n\n'));
}

export async function getConfig() {
  return {
    render: 'static' as const,
  } as const;
}
```

### `*.md` [#md-extension]

Allow AI agents to get the content of a page as Markdown/MDX, by appending `.md` to the end of path.

```ts tab="Next.js" title="app/llms.mdx/docs/[[...slug]]/route.ts"
import { getLLMText } from '@/lib/get-llm-text';
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';

export const revalidate = false;

export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
  const { slug } = await params;
  const page = source.getPage(slug);
  if (!page) notFound();

  return new Response(await getLLMText(page), {
    headers: {
      'Content-Type': 'text/markdown',
    },
  });
}

export function generateStaticParams() {
  return source.generateParams();
}
```

```ts tab="Next.js" title="next.config.ts"
import type { NextConfig } from 'next';

const config: NextConfig = {
  // [!code ++:8]
  async rewrites() {
    return [
      {
        source: '/docs/:path*.md',
        destination: '/llms.mdx/docs/:path*',
      },
    ];
  },
};
```

```ts tab="React Router" title="app/routes.ts"
import { index, route, type RouteConfig } from '@react-router/dev/routes';

export default [
  // [!code ++]
  route('llms.mdx/docs/*', 'routes/docs/llms-mdx.ts'),
] satisfies RouteConfig;
```

```ts tab="React Router" title="app/routes/docs/llms-mdx.ts"
import type { Route } from './+types/llms-mdx';
import { source } from '@/lib/source';
import { getLLMText } from '@/lib/get-llm-text';

export async function loader({ params }: Route.LoaderArgs) {
  const slugs = params['*'].split('/').filter((v) => v.length > 0);
  const page = source.getPage(slugs);
  if (!page) {
    return new Response('not found', { status: 404 });
  }

  return new Response(await getLLMText(page), {
    headers: {
      'Content-Type': 'text/markdown',
    },
  });
}
```

```ts tab="React Router" title="app/root.tsx"
import { rewritePath } from 'fumadocs-core/negotiation';
import type { Route } from './+types/root';

// [!code ++:20]
const { rewrite: rewriteLLM } = rewritePath('/docs{/*path}.md', '/llms.mdx/docs{/*path}');
const serverMiddleware: Route.MiddlewareFunction = async ({ request }, next) => {
  const url = new URL(request.url);
  const path = rewriteLLM(url.pathname);
  if (path) return Response.redirect(new URL(path, url));

  return next();
};

export const middleware = [serverMiddleware];
```

```ts tab="React Router" title="react-router.config.ts"
import type { Config } from '@react-router/dev/config';

export default {
  // enable middleware [!code ++:3]
  future: {
    v8_middleware: true,
  },
} satisfies Config;
```

```ts tab="Tanstack Start" title="lib/shared.ts"
// create the following utils:

export const docsRoute = '/docs';

export function encodeMarkdownUrl(slugs: string[], locale?: string) {
  const segments = [...slugs];
  if (segments.length === 0) {
    segments.push('index.md');
  } else {
    segments[segments.length - 1] += '.md';
  }

  return '/' + [locale, ...docsRoute.split('/'), ...segments].filter(Boolean).join('/');
}

/** @returns page slugs */
export function decodeMarkdownUrl(segments: string[]) {
  if (segments.length === 0) return [];

  const out = [...segments];
  out[out.length - 1] = out[out.length - 1].replace(/\.md$/, '');
  if (out.length === 1 && out[0] === 'index') out.pop();
  return out;
}
```

```ts tab="Tanstack Start" title="src/routes/docs/{$}[.]md.ts"
import { createFileRoute, notFound } from '@tanstack/react-router';
import { source } from '@/lib/source';
import { decodeMarkdownUrl } from '@/lib/shared';
import { getLLMText } from '@/lib/get-llm-text';

export const Route = createFileRoute('/docs/{$}.md')({
  server: {
    handlers: {
      GET: async ({ params }) => {
        const slugs = decodeMarkdownUrl(params._splat?.split('/') ?? []);
        const page = source.getPage(slugs);
        if (!page) throw notFound();

        return new Response(await getLLMText(page), {
          headers: {
            'Content-Type': 'text/markdown',
          },
        });
      },
    },
  },
});
```

```ts tab="Waku" title="pages/_api/llms.mdx/docs/[...slugs]/content.md.ts"
import { getLLMText } from '@/lib/get-llm-text';
import { source } from '@/lib/source';
import { ApiContext } from 'waku/router';
import { unstable_notFound } from 'waku/router/server';

export async function GET(
  _: Request,
  { params }: ApiContext<'/llms.mdx/docs/[...slugs]/content.md'>,
) {
  const page = source.getPage(params.slugs);
  if (!page) unstable_notFound();

  return new Response(await getLLMText(page), {
    headers: {
      'Content-Type': 'text/markdown',
    },
  });
}

export async function getConfig() {
  const pages = source.generateParams().map((item) => item.slug);

  return {
    render: 'static' as const,
    staticPaths: pages,
  } as const;
}
```

```ts tab="Waku" title="lib/source.ts"
// create a util for Markdown URLs (append /content.md):

export function getPageMarkdownUrl(page: { slugs: string[]; locale?: string }) {
  const segments = [...page.slugs, 'content.md'];

  return {
    segments,
    url: '/' + [page.locale, 'llms.mdx', 'docs', ...segments].filter(Boolean).join('/'),
  };
}
```

#### `Accept`

To serve the Markdown content instead for AI agents, you can leverage the `Accept` header.

```ts title="proxy.ts (Next.js)"
import { NextRequest, NextResponse } from 'next/server';
import { isMarkdownPreferred, rewritePath } from 'fumadocs-core/negotiation';

const { rewrite: rewriteLLM } = rewritePath('/docs{/*path}', '/llms.mdx/docs{/*path}');

export default function proxy(request: NextRequest) {
  if (isMarkdownPreferred(request)) {
    const result = rewriteLLM(request.nextUrl.pathname);

    if (result) {
      return NextResponse.rewrite(new URL(result, request.nextUrl), {
        headers: { Vary: 'Accept' }, // [!code highlight]
      });
    }
  }

  return NextResponse.next();
}
```

Because the same URL now has two representations, the response needs `Vary: Accept` so shared
caches key on the header they were selected by, rather than serving Markdown to a browser (or the
other way round).

<Callout title="Next.js">
  Next.js discards `Vary` on App Router **page** responses, so the HTML side of the branch above
  can't carry the header from inside the app — set it at your CDN if you serve documentation through
  a shared cache.
</Callout>

### Page Actions

Common page actions for AI, require [`*.md`](#md-extension) to be implemented first.

![AI Page Actions](/docs/ai-page-actions.png)

```npm
npx @fumadocs/cli add ai/page-actions
```

Use it in your docs page like:

```tsx title="app/docs/[[...slug]]/page.tsx"
// URL to fetch Markdown content, only need to append .mdx to URL if you have `*.mdx` configured.
// Use .md instead for the Tanstack Start example above.
// Use getPageMarkdownUrl(page).url for the Waku example above.
const markdownUrl = `${page.url}.mdx`

<div className="flex flex-row gap-2 items-center border-b pt-2 pb-6">
  <LLMCopyButton markdownUrl={markdownUrl} />
  <ViewOptions
    markdownUrl={markdownUrl}
    githubUrl={`https://github.com/${owner}/${repo}/blob/main/content/docs/${page.path}`}
  />
</div>
```

## Ask AI

![AI Search](/docs/ai-search.png)

You can install the AI chat dialog using Fumadocs CLI.

<Tabs items={['AI SDK', 'LLMGateway', 'Inkeep AI']}>
<Tab>

```npm
npx @fumadocs/cli add ai/openrouter
```

It's automatically configured for [OpenRouter](https://openrouter.ai) using Vercel AI SDK, with a `/search` tool for AI.

You can use other models by updating the `/api/chat` route.

> Fumadocs doesn't provide the AI model, it's up to you.
>
> Your AI model can use the `llms-full.txt` file generated above, or more diversified sources of information when combined with 3rd party solutions.

</Tab>
<Tab>

```npm
npx @fumadocs/cli add ai/llmgateway
```

It's automatically configured for [LLMGateway](https://llmgateway.io) using Vercel AI SDK, with a `/search` tool for AI.

LLMGateway is an open-source API gateway that proxies requests to 300+ language models, so the same route handler can switch between providers by changing the model ID.

Add your LLMGateway API key to environment variables:

```dotenv
LLM_GATEWAY_API_KEY="..."
```

By default the route uses `anthropic/claude-3.5-sonnet`. To switch to any other model supported by LLMGateway, set the `LLM_GATEWAY_MODEL` environment variable—no code change required:

```dotenv
# any model id from https://llmgateway.io/models
LLM_GATEWAY_MODEL="openai/gpt-4o"
```

</Tab>
<Tab>

```npm
npx @fumadocs/cli add ai/inkeep
```

It's automatically configured for [Inkeep AI](https://inkeep.com) using Vercel AI SDK.

Add your Inkeep API key to environment variables:

```dotenv
INKEEP_API_KEY="..."
```

</Tab>
</Tabs>

Add the component & trigger to docs layout:

```tsx
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
// import the installed components, e.g.
import { AISearch, AISearchPanel, AISearchTrigger } from '@/components/ai/search';
import { MessageCircleIcon } from 'lucide-react';
// or import your own button styles
import { buttonVariants } from 'fumadocs-ui/components/ui/button';

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <DocsLayout>
      {/* [!code ++:17] */}
      <AISearch>
        <AISearchPanel />
        <AISearchTrigger
          position="float"
          className={cn(
            buttonVariants({
              variant: 'secondary',
              className: 'text-fd-muted-foreground rounded-2xl',
            }),
          )}
        >
          <MessageCircleIcon className="size-4.5" />
          Ask AI
        </AISearchTrigger>
      </AISearch>

      {children}
    </DocsLayout>
  );
}
```
