# Fumadocs (Framework Mode): Asyntai AI Chatbot
URL: /docs/community/asyntai
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/community/asyntai.mdx
Let readers ask questions and get answers from your docs.
> This is a community-maintained integration.
Readers often cannot find the page they need, or they have a question that the search box does not answer. They leave, or they open a support ticket.
[Asyntai](https://asyntai.com/documentation/integrations/nextjs/) adds an AI chat assistant to your Fumadocs site. It reads your published pages and answers readers from them, in more than 80 languages. The [`nextjs-ai-chatbot`](https://www.npmjs.com/package/nextjs-ai-chatbot) package adds the chat widget with one component. There is a free plan.
## Setup [#setup]
You need an Asyntai widget ID. It starts with `asyntai_`, and you find it on the **Install** page of your Asyntai dashboard.
### 1. Install the package [#install-the-package]
```package-install
nextjs-ai-chatbot
```
### 2. Add the component [#add-the-component]
Put `` at the end of the `
` in your root layout. It works in Server Components. Keep the rest of your layout as it is.
```tsx title="app/layout.tsx"
import { RootProvider } from 'fumadocs-ui/provider/next';
import { AsyntaiChatbot } from 'nextjs-ai-chatbot';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
{children}
);
}
```
The widget loads after the page's `load` event, so your docs always render first. Fumadocs changes pages without a full reload, and the chat stays on screen while the reader moves through your docs.
### 3. Add your docs in the dashboard [#add-your-docs-in-the-dashboard]
Add the URL of your docs site in the Asyntai dashboard. The assistant then answers from the same pages the widget sits on.
## Widget ID from an environment variable [#widget-id-from-an-environment-variable]
You can leave `widgetId` out and set it in `.env` instead:
```bash title=".env"
NEXT_PUBLIC_ASYNTAI_WIDGET_ID=asyntai_YOURWIDGETID
```
Set it where you run `next build`, because Next.js reads `NEXT_PUBLIC_` values at build time.
## Without the package [#without-the-package]
You can add the widget by hand with `next/script`:
```tsx title="app/layout.tsx"
import Script from 'next/script';
;
```
See the [package README](https://github.com/asyntai/nextjs-ai-chatbot) for every option, and the [Asyntai docs](https://asyntai.com/documentation/integrations/nextjs/) for the dashboard.
# Fumadocs (Framework Mode): Page History
URL: /docs/community/fumadocs-page-history
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/community/fumadocs-page-history.mdx
Display contributors and Git history on documentation pages.
> This is a community-maintained integration.
[fumadocs-page-history](https://github.com/shenxianovo/fumadocs-page-history) displays contributors and a collapsible history of changes on documentation pages. It reads Git history at build time and supports Fumadocs MDX and Sätteri.
[View the live demo](https://fumadocs-page-history.vercel.app).
## Setup [#setup]
Requires Node.js 24.14 or later and Fumadocs 16.17 or later within the 16.x release line.
### 1. Install the package [#install-the-package]
```package-install
fumadocs-page-history
```
### 2. Import the styles [#import-the-styles]
Add the following import after your Fumadocs styles:
```css title="app/global.css"
@import 'fumadocs-page-history/css/preset.css';
```
### 3. Register the plugin [#register-the-plugin]
Add `remarkGitChangelog` to your docs collection and set `repoURL` to your repository URL. Keep any existing schema and compiler options.
```ts title="source.config.ts"
import { applyMdxPreset, defineDocs } from 'fumadocs-mdx/config';
import { remarkGitChangelog } from 'fumadocs-page-history/remark';
export const docs = defineDocs({
dir: 'content/docs',
docs: {
mdxOptions: applyMdxPreset({
remarkPlugins: [[remarkGitChangelog, { repoURL: 'https://github.com/owner/repository' }]],
}),
},
});
```
Contributors and page history now appear at the end of each document in this collection. No page-template changes are needed.
## Git history [#git-history]
The build environment needs Git and the repository's full history. In GitHub Actions, use `fetch-depth: 0`:
```yaml
- uses: actions/checkout@v6
with:
fetch-depth: 0
```
- Commit new files before building to include their history.
- Rebuild after new commits. During development, restart the server to pick up Git-only changes.
## Configuration [#configuration]
You can toggle contributors and history independently, customize translations, and optionally resolve GitHub avatars and profile links.
# Fumadocs (Framework Mode): Privatus Analytics
URL: /docs/community/privatus-analytics
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/community/privatus-analytics.mdx
See how many people read your docs, without cookies.
> This is a community-maintained integration.
You want to know which docs pages people read and where they come from. Many analytics tools use cookies, so in many countries you must show a cookie pop-up first. Many visitors click no, and they never show up in your numbers.
[Privatus Analytics](https://privatusanalytics.com) counts visits without cookies and keeps no IP addresses. Known bots are left out. The [`@privatus-analytics/next`](https://www.npmjs.com/package/@privatus-analytics/next) package adds its script to your Fumadocs site with one tag. There is a free plan with 10,000 events a month on up to 3 sites, and it needs no card.
## Setup [#setup]
You need a Privatus Analytics site id. It starts with `pa_`, and you find it under **Site settings**, **Tracking**.
### 1. Install the package [#install-the-package]
```package-install
@privatus-analytics/next
```
### 2. Set your site id [#set-your-site-id]
```bash title=".env"
PRIVATUS_SITE_ID=pa_YOURSITEID
```
Set it where you run `next build`, not only where the site runs. Pages that Next.js renders at build time read it then, so a value that only exists at run time leaves those pages without the tracker. You can also pass it to the tag as `siteId`.
### 3. Add the tag [#add-the-tag]
Put `` in the `` of your root layout. It works in Server Components. Keep the rest of your layout as it is.
```tsx title="app/layout.tsx"
import { RootProvider } from 'fumadocs-ui/provider/next';
import { PrivatusAnalyticsTag } from '@privatus-analytics/next/server';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
{children}
);
}
```
The tag only prints in production (`next build` and `next start`). It prints nothing in `next dev`, so your own visits while you write are not counted. Fumadocs changes pages without a full reload, and the script notices each page change by itself.
## Without the package [#without-the-package]
You can add the script tag by hand instead:
```tsx title="app/layout.tsx"
```
## Track events [#track-events]
To count an action, like a download, call `usePrivatus()` in a Client Component. This needs the package:
```tsx
'use client';
import { usePrivatus } from '@privatus-analytics/next';
export function DownloadButton() {
const privatus = usePrivatus();
return (
);
}
```
If you added the script tag by hand, you can add an attribute instead. The script reads it in the browser:
```tsx
Download
```
See the [package README](https://github.com/Privatus-Analytics/js/tree/main/next) for every setting, and the [Privatus Analytics docs](https://privatusanalytics.com/docs) for the dashboard.
# Fumadocs (Framework Mode): Comparisons
URL: /docs/comparisons
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/comparisons.mdx
How is Fumadocs different from other existing frameworks?
## Nextra [#nextra]
Fumadocs is highly inspired by Nextra. For example, the Routing Conventions. That is why
`meta.json` also exists in Fumadocs.
Nextra is more opinionated than Fumadocs, as a side effect, you have to
configure things manually compared to simply editing a configuration file.
Fumadocs works great if you want more control over everything, such as
adding it to an existing codebase or implementing advanced routing.
### Feature Table [#feature-table]
| Feature | Fumadocs | Nextra |
| ------------------- | ------------ | ------------------------- |
| Static Generation | Yes | Yes |
| Cached | Yes | Yes |
| Light/Dark Mode | Yes | Yes |
| Syntax Highlighting | Yes | Yes |
| Table of Contents | Yes | Yes |
| Full-text Search | Yes | Yes |
| i18n | Yes | Yes |
| Last Git Edit Time | Yes | Yes |
| Page Icons | Yes | Yes, via `_meta.js` files |
| RSC | Yes | Yes |
| Remote Source | Yes | Yes |
| SEO | Via Metadata | Yes |
| Built-in Components | Yes | Yes |
| RTL Layout | Yes | Yes |
### Additional Features [#additional-features]
Features supported via 3rd party libraries like [TypeDoc](https://typedoc.org) will not be listed here.
| Feature | Fumadocs | Nextra |
| -------------------------- | -------- | ------ |
| OpenAPI Integration | Yes | No |
| TypeScript Docs Generation | Yes | No |
| TypeScript Twoslash | Yes | Yes |
## Mintlify [#mintlify]
Mintlify is a documentation service, as compared to Fumadocs, it offers a free tier but isn't completely free and open source.
Fumadocs is not as powerful as Mintlify, for example, the OpenAPI integration of Mintlify.
As the creator of Fumadocs, I wouldn't recommend switching to Fumadocs from Mintlify if you're satisfied with the current way you build docs.
However, I believe Fumadocs is a suitable tool for all React.js developers who want to have elegant docs.
## Docusaurus [#docusaurus]
Docusaurus is a powerful framework based on React.js. It offers many cool
features with plugins and custom themes.
### Lower Complexity [#lower-complexity]
As Fumadocs is designed to integrate with React frameworks, you may need more knowledge of React.js to get started.
In return, Fumadocs have better customizability.
For a simple docs, Docusaurus might be a better choice if you don't need any framework-specific functionality.
### Plugins [#plugins]
You can easily achieve many things with plugins, their ecosystem is indeed larger and maintained by many contributors.
In comparison, the flexibility of Fumadocs allows you to implement them on your own, it may take longer to tune it to your satisfaction.
# Fumadocs (Framework Mode): Deploying
URL: /docs/deploying
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/deploying/index.mdx
Deploy your Fumadocs app
## Overview [#overview]
Your Fumadocs app is powered by the underlying React framework (e.g. Next.js), you will need to check the relevant docs of your React framework for deployment guides.
- [Next.js](https://nextjs.org/docs/app/getting-started/deploying)
- [React Router](https://reactrouter.com/start/framework/deploying)
- [Tanstack Start](https://tanstack.com/start/latest/docs/framework/react/guide/hosting)
- [Waku](https://waku.gg/#deployment)
If you are looking to host the app as a SPA app (fully static, hostable on CDN), see [Static Build](/docs/deploying/static).
There's some extra notes on specific combinations:
### Next.js + Cloudflare [#nextjs--cloudflare]
Use https://opennext.js.org/cloudflare, Fumadocs doesn't work on Edge runtime.
### Next.js + Docker [#nextjs--docker]
If you want to deploy your Fumadocs app using Docker with **Fumadocs MDX configured**, make sure to add the `source.config.ts` file and your `next.config.js` file to the `WORKDIR` in the Dockerfile.
The following snippet is taken from the official [Next.js Dockerfile Example](https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile):
```zsh title="Dockerfile"
# syntax=docker.io/docker/dockerfile:1
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager [!code highlight:2]
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* source.config.ts next.config.* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/config/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
```
This ensures Fumadocs MDX can access your configuration file during builds.
# Fumadocs (Framework Mode): Static Build
URL: /docs/deploying/static
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/deploying/static.mdx
Output static website with Fumadocs.
## Setup [#setup]
By default, Fumadocs use a server-first approach which always requires a running server to serve.
You can output a static build by configuring your framework.
### Search [step] [#search]
#### Built-in Search [#built-in-search]
1. [Configure Search Server](/docs/headless/search/orama#static-export).
2. [Configure Search UI/Client](/docs/search/orama#static).
After the configurations, your app will statically store the search indexes, and search will be computed on browser instead.
#### Cloud Solutions [#cloud-solutions]
Since the search functionality is powered by remote servers, static export works without configuration.
### Deployment [step] [#deployment]
You can enable Next.js static export, it allows you to export the app as a static HTML site without a Node.js server.
```js title="next.config.mjs"
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
output: 'export',
// Optional: Change links `/me` -> `/me/` and emit `/me.html` -> `/me/index.html`
// trailingSlash: true,
// Optional: Prevent automatic `/me` -> `/me/`, instead preserve `href`
// skipTrailingSlashRedirect: true,
};
```
See [Next.js docs](https://nextjs.org/docs/app/guides/static-exports) for limitations and details.
By configuring SPA mode, you can serve the `build/client` directory as a static website.
On SPA mode, all your server-side loaders must be pre-rendered.
See [React Router Docs](https://reactrouter.com/how-to/spa) for details.
```ts title="react-router.config.ts"
import type { Config } from '@react-router/dev/config';
import { glob } from 'node:fs/promises';
import { createGetUrl, getSlugs } from 'fumadocs-core/source';
const getUrl = createGetUrl('/docs');
export default {
// disable SSR [!code highlight:2]
ssr: false,
// [!code ++:9]
async prerender({ getStaticPaths }) {
const paths: string[] = [...getStaticPaths()];
for await (const entry of glob('**/*.mdx', { cwd: 'content/docs' })) {
paths.push(getUrl(getSlugs(entry)));
}
return paths;
},
} satisfies Config;
```
Configure SPA mode and pre-rendering, refer to [SPA Mode](https://tanstack.com/start/latest/docs/framework/react/guide/spa-mode) for deploying SPA apps with Tanstack Start.
```ts title="vite.config.ts"
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
// ...other plugins
tanstackStart({
spa: {
enabled: true,
// Tanstack Router will automatically crawl your pages [!code ++:4]
prerender: {
enabled: true,
},
// if you have any hidden paths that's not visible on UI, you can add them explicitly. [!code highlight:6]
pages: [
{
path: '/docs/test',
},
],
},
}),
],
});
```
Waku can serve your site statically when all pages are configured with `static` render mode.
See [Deployment](https://waku.gg/#deployment) for details.
# Fumadocs (Framework Mode): Visual Editor
URL: /docs/editor
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/editor.mdx
Write Markdown and MDX visually, with your files as the source of truth.
## Introduction [#introduction]
[Fumadocs Editor](https://editor.fumadocs.dev) is a visual editor for Fumadocs MDX.
### Start with Studio [#start-with-studio]
Studio opens your local docs in a browser without adding an editor to your app.
From your Fumadocs project, run:
```bash
npx @fumadocs-editor/studio
```
Studio requires **Node.js 24 or later**. It looks for `content/docs`, or the parent directories when missing.
To select a different content folder:
```bash
npx @fumadocs-editor/studio --root ./docs
```
Edits autosave to disk, and changes from your code editor merge into the open document.
The file list follows your `meta.json` ordering.
Review the changes in Git and publish them through your site's usual deployment workflow.
> Studio edits existing files. Create, rename, and delete files in your code editor or file manager instead.
### Use as a Library [#use-as-a-library]
Use `@fumadocs-editor/ui` to build an editing interface inside your own app. See the docs for details:
Install the editor, import its styles, and render your first editable document.
Configure the content folder, custom components, and styles.
Connect an embedded editor to files through your development server.
### Write with Components [#write-with-components]
Built-in editing support includes Fumadocs components such as Callout, Cards, Steps, Accordions, and Files.
Custom components can define their own editable regions with [component specs](https://editor.fumadocs.dev/components).
The editor works on your Markdown and MDX files. Continue using [Fumadocs MDX](/docs/mdx) to load
them and your React framework to render the published site.
# Fumadocs (Framework Mode): Access Control
URL: /docs/guides/access-control
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/guides/access-control.mdx
Limit the access of content.
## Overview [#overview]
Thanks to the flexible design, it is simple to implement access control with Fumadocs.
## Loader API [#loader-api]
If you are using [`loader()`](/docs/headless/source-api) API, you can create the loaders on-demand, and filter files from content source to restrict accessible content.
For example, this setup with Fumadocs MDX allows only MDX files with frontmatter `permission: public` to be shown.
```ts title="lib/source.ts"
import { docs } from 'collections/server';
import { loader, update } from 'fumadocs-core/source';
const filteredSource = update(docs.toFumadocsSource())
.files((files) =>
files.filter((file) => {
// keep meta files (e.g. `meta.json`)
if (file.type === 'meta') return true;
// access file data (type-safe)
return file.data.permission === 'public';
}),
)
.build();
export const source = loader(filteredSource, {
baseUrl: '/docs',
});
```
Or with more complicated setup, you may turn `source` into a factory function.
```ts tab="lib/source.ts"
import { docs } from 'collections/server';
import { loader, update } from 'fumadocs-core/source';
// uncached, it's better to cache it in-memory for real use case.
export function createSource(permission: 'public' | 'admin') {
const filteredSource = update(docs.toFumadocsSource())
.files((files) =>
files.filter((file) => {
if (file.type === 'meta') return true;
return file.data.permission === permission;
}),
)
.build();
return loader(filteredSource, {
baseUrl: '/docs',
});
}
```
```ts tab="References"
// [!code --]
import { source } from '@/lib/source';
// [!code ++:4]
import { createSource } from '@/lib/source';
const user = await getUser();
const source = createSource(user.permission);
const page = source.getPage(params.slugs);
```
### Advantages [#advantages]
What is special with this approach is that **the access is limited at input level**, rather than at rendering phase or build-time.
This guarantees the protected content is filtered entirely, and without compromise on flexibility.
## Custom Implementation [#custom-implementation]
Without using Loader API, you can also implement your access control at framework-level (e.g. via Next.js routing & proxy).
For example, to limit access to certain pages:
```tsx title="page.tsx"
import { notFound } from 'next/navigation';
import { source } from '@/lib/source';
export default async function Page({ params }) {
const { slugs } = await params;
const user = await getUser();
const page = source.getPage(slugs);
if (!page) notFound();
if (page.data.permission !== user.permission) notFound();
// render page...
}
```
Note that it is up to you to manage the page tree (sidebar items), search and other details.
This approach offers the best flexibility, but it doesn't rely on Fumadocs itself for access control, the setup may take longer and be more complicated.
It is better to consult the docs of your React.js framework when implementing.
# Fumadocs (Framework Mode): Customize UI
URL: /docs/guides/customize-ui
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/guides/customize-ui.mdx
A complete guide on how to customize Fumadocs UI.
## Introduction [#introduction]
Fumadocs UI provides pre-built components for simplicity, serving as a middleground between "installing all UI code to your codebase" and an opinionated package.
While Fumadocs UI itself has many designs around flexibility & backward compatibility, if used incorrectly, the measures may take no effect.
> It is also recommended to feed this guide to your AI agents, if they may edit your UI.
### Pick the Right Component [step] [#pick-the-right-component]
For layout UIs, there's different built-in layout components you can try:
The layout of documentation
A compact version of Docs Layout
Shared layout for other pages
An aggressively minimal layout for docs
A docs layout with a floating, translucent sidebar and header
A less compact version of Docs Layout
### Props [step] [#props]
You can first check if the options of components can suffice your needs, for example:
```tsx
{/* content */}
```
Or styling the container via props:
```tsx
{/* content */}
```
Detailed documentation for each component is in the [Fumadocs UI](/docs/ui) section, you can take it as a reference.
Most options support passing JSX elements to allow your own components.
This approach should suffice most use cases, such as adding sections below table of contents or in sidebar.
### CSS [step] [#css]
When the styling is more complicated, some components of Fumadocs UI attaches its own `id` & `data-` attributes for selecting.
You can target them in your CSS selectors:
```css
#nd-docs-layout #nd-subnav {
background-color: purple;
}
[data-toc-popover] {
background-color: purple;
}
```
**Avoid invasive styles, or anything that presume a very specific DOM structure**, as it is difficult to guarantee backward compatibility, especially with newer UI features.
```css title="Bad Example"
/* avoid targeting elements like this! */
[data-toc-popover] > div {
background-color: purple;
}
```
CSS approach can be risky as only the `id` & `data-` attributes can be versioned under its own life cycles, prefer other approaches if possible.
### Install via CLI [step] [#install-via-cli]
For advanced needs beyond the exposed options, or if your custom CSS styles became overly invasive, Fumadocs CLI is a powerful tool.
You can find similar prompts under the relevant component docs, indicating the component is installable, such as:
For **layout components** (which often composes multiple components), Fumadocs CLI has a special `customize` command with granular choices.
```npm
npx @fumadocs/cli@latest customize
```
Despite installing the entire layout component, you can choose to install a **slot** (a replaceable part of the layout component).
Run the command above and you can view the full options.
When installed, make sure to use the installed types over the types exported from Fumadocs UI. For example, if you have installed `layout/docs`, update your imports:
```tsx
// [!code --]
import type { DocsLayoutProps } from 'fumadocs-ui/layouts/docs';
// [!code ++]
import type { DocsLayoutProps } from '@/components/layout/docs';
```
This ensure your local components will be decoupled from Fumadocs UI.
While Fumadocs CLI offers full control for the UI code, it is worth mentioning that:
- You won't be able to receive UI updates for the installed the components.
- You can re-install the component with Fumadocs CLI, but prior customisations will be erased. (You can choose which file to overwrite)
- Fumadocs CLI will assume the latest Fumadocs Core + UI to be used.
## Other Questions [#other-questions]
Feel free to reach out in [GitHub Discussions](https://github.com/fuma-nama/fumadocs/discussions) for questions, or if you have encountered unexpected breakages.
# Fumadocs (Framework Mode): Export EPUB
URL: /docs/guides/export-epub
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/guides/export-epub.mdx
Export your documentation to EPUB format for e-readers
## Introduction [#introduction]
The EPUB integration lets you export your Fumadocs documentation as an EPUB file, making it easy for readers to download and read your docs on e-readers, tablets, or any EPUB-compatible app.
It converts your MDX content to HTML, resolves images, and produces a standards-compliant EPUB with a table of contents.
## Setup [#setup]
Fumadocs CLI can set it up for you, it creates the export route protected by an `EXPORT_SECRET` and enables `includeProcessedMarkdown`:
```package-install
npx @fumadocs/cli feature epub
```
Or manually:
### Installation [#installation]
```npm
npm install fumadocs-epub
```
### Enable Processed Markdown [#enable-processed-markdown]
EPUB export requires `includeProcessedMarkdown` in your docs collection config:
```ts tab="Fumadocs MDX" title="source.config.ts"
import { defineDocs } from 'fumadocs-mdx/config';
export const docs = defineDocs({
docs: {
postprocess: {
// [!code ++]
includeProcessedMarkdown: true,
},
},
});
```
### Create Export Route [#create-export-route]
`createEpubExportAPI` builds the route handler. It is guarded by `secret`: without a matching `Authorization: Bearer ` header the request is rejected, and the route answers `503` when the secret itself is unset.
```ts tab="Next.js" title="app/export/epub/route.ts"
import { source } from '@/lib/source';
import { createEpubExportAPI } from 'fumadocs-epub';
export const revalidate = false;
export const { GET } = createEpubExportAPI({
source,
title: 'My Documentation',
author: 'My Team',
description: 'Documentation for my project',
cover: '/cover.png',
secret: process.env.EXPORT_SECRET,
});
```
```ts tab="React Router" title="app/routes/export.epub.ts"
import type { Route } from './+types/export.epub';
import { source } from '@/lib/source';
import { createEpubExportAPI } from 'fumadocs-epub';
const api = createEpubExportAPI({
source,
title: 'My Documentation',
secret: process.env.EXPORT_SECRET,
});
export function loader({ request }: Route.LoaderArgs) {
return api.GET(request);
}
```
```ts tab="React Router" title="app/routes.ts"
import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
// [!code ++]
route('export/epub', 'routes/export.epub.ts'),
] satisfies RouteConfig;
```
```ts tab="Tanstack Start" title="src/routes/export/epub.ts"
import { createFileRoute } from '@tanstack/react-router';
import { source } from '@/lib/source';
import { createEpubExportAPI } from 'fumadocs-epub';
const api = createEpubExportAPI({
source,
title: 'My Documentation',
secret: process.env.EXPORT_SECRET,
});
export const Route = createFileRoute('/export/epub')({
server: {
handlers: {
GET: ({ request }) => api.GET(request),
},
},
});
```
```ts tab="Waku" title="pages/_api/export/epub.ts"
import { source } from '@/lib/source';
import { createEpubExportAPI } from 'fumadocs-epub';
export const { GET } = createEpubExportAPI({
source,
title: 'My Documentation',
secret: process.env.EXPORT_SECRET,
});
```
For a public endpoint, or to build the file yourself (e.g. in a script), call `exportEpub()` directly and return the buffer.
### Using the CLI [#using-the-cli]
You can also export EPUB via the Fumadocs CLI:
```bash
fumadocs export epub --framework next
# or: tanstack-start | react-router | waku
```
This fetches from your running server (Next.js) or copies from the build output (other frameworks). Run a production build first for the other frameworks.
## Options [#options]
Both `exportEpub` and `createEpubExportAPI` accept these options:
| Option | Type | Description |
| -------------- | -------------------- | ------------------------------------------------------------------------------- |
| `title` | `string` | **Required.** Book title |
| `author` | `string \| string[]` | Author name(s). Default: `'anonymous'` |
| `description` | `string` | Book description |
| `language` | `string` | Language code (e.g. `'en'`). Default: `'en'` |
| `publisher` | `string` | Publisher name. Default: `'anonymous'` |
| `isbn` | `string` | ISBN |
| `cover` | `string` | Cover image. Supports `file://`, `http(s)://`, `/public/...`, or relative paths |
| `outputPath` | `string` | If set, writes the EPUB to file in addition to returning the buffer |
| `includePages` | `(page) => boolean` | Filter: include only pages where this returns `true` |
| `excludePages` | `(page) => boolean` | Filter: exclude pages where this returns `true` |
| `css` | `string` | Custom CSS for the EPUB. Uses `defaultEpubStyles` if omitted |
| `publicDir` | `string` | Public directory for resolving `/public/...` image paths. Default: `./public` |
`createEpubExportAPI` adds two more: `secret` (required, guards the route) and `filename` (the downloaded file name, default `docs.epub`).
### Custom CSS [#custom-css]
You can override the default EPUB styles:
```ts
import { exportEpub, defaultEpubStyles } from 'fumadocs-epub';
const buffer = await exportEpub({
source,
title: 'My Docs',
author: 'Me',
css: `${defaultEpubStyles}
/* Custom overrides */
body { font-size: 1.1em; }
`,
});
```
### Filtering Pages [#filtering-pages]
Use `includePages` and `excludePages` to control which docs are exported:
```ts
const buffer = await exportEpub({
source,
title: 'My Docs',
author: 'Me',
// Only include docs under /docs/getting-started
includePages: (page) => page.path.startsWith('getting-started'),
// Exclude specific paths
excludePages: (page) => page.path === 'changelog',
});
```
### Image Resolution [#image-resolution]
Images in your MDX are resolved automatically:
- **Relative paths** (e.g. `./image.png`) — resolved relative to the page file
- **Public paths** (e.g. `/og.png`) — resolved from the `public` directory
- **Remote URLs** (e.g. `https://...`) — embedded as-is
# Fumadocs (Framework Mode): Export PDF
URL: /docs/guides/export-pdf
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/guides/export-pdf.mdx
Export docs pages as PDF documents
## Overview [#overview]
In general, we strongly recommend you to download the entire website (HTML files, etc) for offline browsing.
In case if you want to export the page as a PDF, you can follow this guide.
## Setup [#setup]
Use Puppeteer to export PDF files.
```ts title="scripts/export-pdf.ts"
import puppeteer from 'puppeteer';
import fs from 'node:fs/promises';
import path from 'node:path';
const browser = await puppeteer.launch();
const outDir = 'pdfs';
// update this [!code highlight:2]
const urls = ['/docs/ui', '/docs/ui/customizations'];
async function exportPdf(pathname: string) {
const page = await browser.newPage();
await page.goto('http://localhost:3000' + pathname, {
waitUntil: 'networkidle2',
});
await page.pdf({
path: path.join(outDir, pathname.slice(1).replaceAll('/', '-') + '.pdf'),
width: 950,
printBackground: true,
});
console.log(`PDF generated successfully for ${pathname}`);
await page.close();
}
await fs.mkdir(outDir, { recursive: true });
await Promise.all(urls.map(exportPdf));
await browser.close();
```
Add the following to your Tailwind CSS file to disable navigation elements when printing:
```css
@media print {
#nd-docs-layout {
--fd-sidebar-width: 0px !important;
}
#nd-sidebar {
display: none;
}
}
```
You can now run the script:
```bash
bun ./scripts/export-pdf.ts
```
### Invisible Contents [#invisible-contents]
For invisible contents in accordions/tabs, you may need to temporarily override the MDX components. For example:
```tsx title="components/mdx.tsx"
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
// you may use environment variable here [!code highlight:2]
const isPrinting = true;
function PrintingAccordion(props: React.ComponentProps) {
return (
{props.title}
{props.children}
);
}
function PrintingAccordions(props: React.ComponentProps) {
return
{props.children}
;
}
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
// updated accordions:
Accordion: isPrinting ? PrintingAccordion : Accordion,
Accordions: isPrinting ? PrintingAccordions : Accordions,
...components,
} satisfies MDXComponents;
}
```
# Fumadocs (Framework Mode): Guides
URL: /docs/guides
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/guides/index.mdx
Useful guides for extending Fumadocs.
# Fumadocs (Framework Mode): RSS Feed
URL: /docs/guides/rss
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/guides/rss.mdx
Generate a RSS feed for your docs/blog.
## Setup [#setup]
You can create the feed object:
```ts title="lib/rss.ts"
import { Feed } from 'feed';
import { source } from '@/lib/source';
const baseUrl = 'https://fumadocs.dev';
export function getRSS() {
const feed = new Feed({
title: 'Fumadocs Blog',
id: `${baseUrl}/blog`,
link: `${baseUrl}/blog`,
language: 'en',
image: `${baseUrl}/banner.png`,
favicon: `${baseUrl}/icon.png`,
copyright: 'All rights reserved 2025, Fuma Nama',
});
for (const page of source.getPages()) {
feed.addItem({
id: page.url,
title: page.data.title,
description: page.data.description,
link: `${baseUrl}${page.url}`,
date: new Date(page.data.lastModified),
author: [
{
name: 'Fuma',
},
],
});
}
return feed.rss2();
}
```
And expose it using a server loader/route handler like:
```ts tab="Next.js" title="app/rss.xml/route.ts"
import { getRSS } from '@/lib/rss';
export const revalidate = false;
export function GET() {
return new Response(getRSS());
}
```
```ts tab="React Router" title="app/routes/rss.ts"
import { getRSS } from '@/lib/rss';
export async function loader() {
return new Response(getRSS());
}
```
```ts tab="React Router" title="app/routes.ts"
import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
// [!code ++]
route('rss.xml', 'routes/rss.ts'),
] satisfies RouteConfig;
```
```ts tab="Tanstack Start" title="src/routes/rss[.]xml.ts"
import { createFileRoute } from '@tanstack/react-router';
import { getRSS } from '@/lib/rss';
export const Route = createFileRoute('/rss.xml')({
server: {
handlers: {
GET: async () => new Response(getRSS()),
},
},
});
```
```ts tab="Waku" title="pages/_api/rss.xml.ts"
import { getRSS } from '@/lib/rss';
export function GET() {
return new Response(getRSS());
}
export async function getConfig() {
return {
render: 'static' as const,
} as const;
}
```
### Link the Feed [#link-the-feed]
Link it from your pages, so feed readers can discover it:
```ts tab="Next.js" title="app/layout.tsx"
import type { Metadata } from 'next';
export const metadata: Metadata = {
alternates: {
types: {
'application/rss+xml': [
{
title: 'Fumadocs Blog',
url: 'https://fumadocs.dev/rss.xml',
},
],
},
},
};
```
```ts tab="React Router" title="app/root.tsx"
import type { Route } from './+types/root';
export const links: Route.LinksFunction = () => [
{ rel: 'alternate', type: 'application/rss+xml', title: 'Fumadocs Blog', href: '/rss.xml' },
];
```
```tsx tab="Tanstack Start" title="src/routes/__root.tsx"
export const Route = createRootRoute({
head: () => ({
links: [
{ rel: 'alternate', type: 'application/rss+xml', title: 'Fumadocs Blog', href: '/rss.xml' },
],
}),
// ...
});
```
```tsx tab="Waku" title="pages/_layout.tsx"
// React hoists it into ``
```
# Fumadocs (Framework Mode): Quick Start
URL: /docs
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/index.mdx
Getting Started with Fumadocs
## Introduction [#introduction]
Fumadocs (Foo-ma docs) is a **documentation framework**, designed to be fast, flexible,
and composes seamlessly into your React framework. It consists of multiple layers:
} title='Fumadocs Core' href='/docs/headless'>
Handles most of the logic, including document search, content source adapters, and Markdown extensions.
} title='Fumadocs UI' href='/docs/ui'>
The default theme of Fumadocs offers a beautiful look for documentation sites and interactive components.
} title='Content Source'>
The source of your content, can be a CMS or local data layers like [Fumadocs MDX](/docs/mdx) (the official content source).
} title='Fumadocs CLI' href='/docs/cli'>
A command line tool to install UI components and automate things, useful for customizing layouts.
Read our in-depth [What is Fumadocs](/docs/what-is-fumadocs) introduction.
### Terminology [#terminology]
**Markdown/MDX:** Markdown is a markup language for creating formatted text. Fumadocs natively supports Markdown and MDX (superset of Markdown).
**[Bun](https://bun.sh):** A JavaScript runtime, it will be used in our guides for running scripts. If you prefer Node.js, try [unrun](https://gugustinette.github.io/unrun/guide/getting-started.html#cli) instead.
Some basic knowledge of React.js would be useful for further customizations.
[Fumapress](https://press.fumadocs.dev) is a site generator that manages more features on top of Fumadocs.
- Automatic routing, `llms.txt`, MCP and more basic features.
- Based on Vite ([Waku](https://waku.gg)).
It enforces a React framework of its own, and offers a better DX in return.
## Automatic Installation [#automatic-installation]
A minimum version of Node.js 22 required.
```npm
npm create fumadocs-app
```
It will ask you the built-in template to use:
- **Framework**: Next.js, Waku, React Router, Tanstack Start.
- **Content source**: Fumadocs MDX, or content collections.
A new fumadocs app should be initialized, with following features configured by default:
- [LLM integration](/docs/integrations/llms).
- [Dynamic metadata image](/docs/integrations/og).
You can follow the [Manual Installation](/docs/manual-installation) guide to get started.
### Enjoy! [#enjoy]
Create your first MDX file in the docs folder.
```mdx title="content/docs/index.mdx"
---
title: Hello World
---
## Yo what's up
```
Run the app in development mode and see http://localhost:3000/docs.
```npm
npm run dev
```
## FAQ [#faq]
Some common questions you may encounter.
Make sure to upgrade Fumadocs when you've encountered any problems or trying out new features:
```bash title="pnpm"
pnpm update -i -r --latest
```
Routing is handled by your React framework, you need to change the routing structure first.
For example, in Next.js, rename the route (`/docs/*` -> `/info/*`):
Or rename from `/docs/*` to `/*` using a route group:
Finally, update the base URL of pages in `source.ts`:
```ts title="lib/source.ts"
import { loader } from 'fumadocs-core/source';
export const source = loader({
baseUrl: '/info', // to the new value [!code highlight]
});
```
We recommend using [Layout Tabs](/docs/ui/layouts/docs#layout-tabs).
### For Vite [#for-vite]
There's some weird pre-bundling problems with Vite: [#3910](https://github.com/vitejs/vite/issues/3910).
Make sure to exclude Fumadocs from pre-bundling and add it to `noExternal`:
```ts title="vite.config.ts"
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
// add other Fumadocs deps as needed
noExternal: ['fumadocs-core', 'fumadocs-ui', 'fumadocs-openapi', '@fumadocs/base-ui'],
},
});
```
### For Next.js [#for-nextjs]
If you have similar Turbopack errors like:
```
JSON file not found: FileSystemPath { ... path: "lib/locales/sources/file.json.json" }
```
It is usually because `require(esm)` is not supported ([#3312](https://github.com/fuma-nama/fumadocs/issues/3312)), please upgrade to Node.js 22+.
On Node.js 23.1, if you encountered this error in production build:
```
Could not find a production build in the '.next' directory.
```
It is a known problem, see [#1021](https://github.com/fuma-nama/fumadocs/issues/1021), you can upgrade your Node.js version to 23.6.0+.
Next.js turns dynamic route into static routes when `generateStaticParams` is configured.
Hence, it is as fast as static pages.
You can [enable Static Exports](/docs/deploying/static) on Next.js to get a static build output.
Same as managing layouts in Next.js App Router, remove the original MDX file from content directory (`/content/docs`).
This ensures duplicated pages will not cause errors.
Now, You can add the page to another route group, which isn't a descendant of docs layout.
For example, to replace `/docs/test`:
For `/docs`, you need to change the catch-all route to be non-optional:
## Learn More [#learn-more]
New to here? Don't worry, we are welcome for your questions.
If you find anything confusing, please give your feedback on [Github Discussion](https://github.com/fuma-nama/fumadocs/discussions)!
### Writing Content [#writing-content]
For authoring docs, make sure to read:
Fumadocs has some additional features for authoring content.
Learn how to customize navigation structure.
Learn how to organise content.
### Special Needs [#special-needs]
# Fumadocs (Framework Mode): createAsyncAPIPage()
URL: /docs/integrations/asyncapi/api-page
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/integrations/asyncapi/api-page.mdx
The component for rendering AsyncAPI docs content
## Overview [#overview]
Fumadocs AsyncAPI uses a `` component to render page contents, it should be a client component.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage({
// config
});
```
### Custom Layout [#custom-layout]
You can customize how the sections of operations are laid out.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage({
content: {
renderOperationLayout(slots) {
return (
);
},
},
});
```
| Slot | Renders |
| ------------- | ------------------------------------------------------------------------- |
| `header` | the title, on pages with several operations |
| `connection` | which way messages go, the server and channel address, and the playground |
| `description` | the descriptions of operation and its channel, and external documentation |
| `usage` | code samples for the selected server, beside the operation on wide pages |
| `authSchemes` | security schemes of the server and operation |
| `parameters` | address parameters, and the query and headers of WebSocket connections |
| `messages` | the messages, with their payload, headers, examples and correlation ID |
| `reply` | the reply of a request |
| `bindings` | protocol details of the selected server |
### Direction of Messages [#direction-of-messages]
AsyncAPI describes an application with `send` and `receive` actions. Pages flip them for readers, who talk to the application: `send` is shown as **Receive**, and `receive` as **Send**. The sidebar shows them as an arrow into or out of a phone.
### Playground [#playground]
Operations can be tried in a playground: readers connect with the address parameters, query and credentials, send messages from their examples as text, hex or Base64, and see the messages they receive.
A transport connects to the servers of each protocol, WebSocket (`ws` and `wss`) is built in.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
import { sseTransport } from '@fumadocs/asyncapi/playground';
export const AsyncAPIPage = createAsyncAPIPage({
playground: {
// by the protocol of servers
transports: { http: sseTransport, https: sseTransport },
// set to `false` to disable the playground
enabled: true,
},
});
```
Browsers can't send headers or cookies of other sites with WebSocket connections, only credentials
in the query are supported. Pages served over HTTPS can only connect to `wss` servers.
#### Server-Sent Events [#server-sent-events]
`sseTransport` receives the events of `http` and `https` servers with `fetch`, readers can't send messages on it.
- API keys are sent in headers or the query.
- HTTP bearer and basic credentials are sent in the `Authorization` header.
It is opt-in, since webhooks are on `http` servers too.
#### Custom Transports [#custom-transports]
A transport connects to a server and reports what happens through `events`. For example, MQTT over WebSocket with [MQTT.js](https://github.com/mqttjs/MQTT.js):
```ts title="lib/mqtt-transport.ts"
import mqtt from 'mqtt';
import type { PlaygroundTransport } from '@fumadocs/asyncapi/playground';
export const mqttTransport: PlaygroundTransport = {
// the security schemes it applies, they become inputs of the playground
auth: (scheme) => scheme.type === 'userPassword',
connect({ url, address, auth }, events) {
const credentials = auth[0]?.value;
// the server is the WebSocket listener of broker, like `localhost:9001`
const client = mqtt.connect(`ws://${new URL(url).host}`, {
...(typeof credentials === 'object' && credentials),
reconnectPeriod: 0,
});
client.on('connect', () =>
client.subscribe(address, (error) => (error ? events.error(error.message) : events.open())),
);
client.on('message', (_topic, payload) => events.message(payload.toString()));
client.on('error', (error) => events.error(error.message));
client.on('close', () => events.close());
return {
send: (data) =>
client.publish(address, typeof data === 'string' ? data : (new Uint8Array(data) as Buffer)),
close: () => client.end(),
};
},
};
```
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
import { mqttTransport } from '@/lib/mqtt-transport';
export const AsyncAPIPage = createAsyncAPIPage({
playground: {
transports: { mqtt: mqttTransport },
},
});
```
See [`PlaygroundTransport`](/docs/integrations/asyncapi/headless#transports) for the details.
### Code Samples [#code-samples]
Operations show how to receive or send their messages on the selected server, in JavaScript and Python.
| Protocol | JavaScript | Python |
| ------------------------------ | ------------------------- | ----------------- |
| `ws`, `wss` | `WebSocket` | `websockets` |
| `mqtt`, `mqtts`, `secure-mqtt` | `mqtt` | `paho-mqtt` |
| `kafka`, `kafka-secure` | `kafkajs` | `confluent-kafka` |
| `amqp`, `amqps` | `amqplib` | `pika` |
| `nats` | `@nats-io/transport-node` | `nats-py` |
Add generators with `codeUsages`, or replace the default ones:
```tsx title="components/api-page.tsx"
'use client';
import {
createAsyncAPIPage,
createCodeUsageGeneratorRegistry,
registerDefaultCodeUsages,
} from '@fumadocs/asyncapi/ui';
// leave out the defaults to start from scratch
const codeUsages = registerDefaultCodeUsages(createCodeUsageGeneratorRegistry());
codeUsages.add('websocat', {
label: 'websocat',
lang: 'bash',
// `url` is the URL to connect to WebSocket channels
generate({ protocol }, { url }) {
// hidden on other protocols
if (protocol !== 'ws' && protocol !== 'wss') return '';
return `websocat "${url}"`;
},
});
export const AsyncAPIPage = createAsyncAPIPage({ codeUsages });
```
### CodeUsageData
| Prop | Type | Description |
| ---------- | -------------------------------- | ------------------------------------------------------------------------------- |
| `protocol` | `string` | the protocol of the selected server, like `ws`, `mqtt` or `kafka` |
| `server` | `string` | the URL of the selected server, with its variables |
| `address` | `string` | the channel address with its parameters, like a topic or a WebSocket path |
| `received` | `union` | readers receive the messages, otherwise they send them |
| `message?` | `object` | the selected message, with its example |
| `bindings` | `Partial>` | bindings of the server's protocol on the server, channel, operation and message |
Operations can have their own samples in `x-codeSamples`, they replace the generated ones of the same `lang`, and `source: false` removes one.
```yaml
operations:
receiveLightMeasurement:
action: receive
x-codeSamples:
- lang: js
label: SDK
source: |
lights.onMeasured((event) => console.log(event));
- lang: python
source: false
```
### Schema UI [#schema-ui]
Customize how JSON schemas are rendered, or disable schema examples.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage({
schemaUI: {
showExample: true,
render(options, ctx) {
// fully custom schema renderer
return ctx.SchemaUI(options);
},
},
});
```
### TypeScript Definitions [#typescript-definitions]
Payloads and headers have a button to copy their TypeScript definitions, generated with [`@fumari/json-schema-ts`](https://npmjs.com/package/@fumari/json-schema-ts).
Like the schemas on pages, they leave out `readOnly` properties of messages readers send, and `writeOnly` properties of messages they receive.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage({
// or `(schema, ctx) => string | undefined` to generate your own
generateTypeScriptDefinitions: false,
});
```
### Internationalization [#internationalization]
Assuming you have configured [Internationalization](/docs/internationalization) at UI level:
```tsx title="layout.shared.tsx"
import { defineI18n } from 'fumadocs-core/i18n';
import { uiTranslations } from 'fumadocs-ui/i18n';
import { asyncapiTranslations } from '@fumadocs/asyncapi/i18n';
const i18n = defineI18n({
languages: ['en', 'cn'],
defaultLanguage: 'en',
});
export const translations = i18n
.translations()
.extend(uiTranslations())
// [!code ++]
.extend(asyncapiTranslations())
.add({
cn: {
displayName: 'Chinese',
// [!code ++]
'Messages(operation page)': '消息',
},
});
```
See [Translations](/docs/ui/translations) for more details.
### Syntax Highlighting [#syntax-highlighting]
AsyncAPI pages highlight code blocks with Shiki, pass a [highlighter](/docs/headless/utils/shiki) to customize it.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
import { highlighter } from '@/lib/highlighter';
export const AsyncAPIPage = createAsyncAPIPage({ highlighter });
```
### Custom Components [#custom-components]
Override the default heading and code block components.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage({
components: {
Heading({ id, depth, ...props }) {
const Tag = `h${depth}` as const;
return ;
},
CodeBlock({ lang, code }) {
return
{code}
;
},
},
});
```
## Customise UI [#customise-ui]
For customisations beyond the available options, you can install the UI into your codebase with [Fumadocs CLI](/docs/cli).
### Full UI [#full-ui]
The entire UI of API pages, built on [the headless layer](/docs/integrations/asyncapi/headless).
```npm
npx @fumadocs/cli add asyncapi/page
```
It installs `` itself, your `components/api-page.tsx` is no longer needed.
```tsx title="mdx-components.tsx"
import { AsyncAPIPage } from '@/components/api-page'; // [!code --]
import { AsyncAPIPage } from '@/components/asyncapi/page'; // [!code ++]
```
### Operation [#operation]
The UI of a single operation, including its channel, messages and bindings.
```npm
npx @fumadocs/cli add asyncapi/operation
```
### Playground [#playground-1]
The playground, built on [`usePlayground()`](/docs/integrations/asyncapi/headless#playground).
```npm
npx @fumadocs/cli add asyncapi/playground
```
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
import { Operation } from '@/components/asyncapi/operation';
export const AsyncAPIPage = createAsyncAPIPage({
components: {
Operation,
},
});
```
### Schema UI [#schema-ui-1]
The UI of JSON schemas, shared with the other API integrations. Its state comes from [``](/docs/integrations/openapi/headless#schema-ui).
```npm
npx @fumadocs/cli add api-docs/schema
```
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
import { Schema } from '@/components/api/schema';
export const AsyncAPIPage = createAsyncAPIPage({
components: {
SchemaUI: Schema,
},
});
```
# Fumadocs (Framework Mode): Generating Pages
URL: /docs/integrations/asyncapi/generate-files
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/integrations/asyncapi/generate-files.mdx
Generate pages from AsyncAPI schema.
`staticSource()` and `dynamicSource()` generate pages for your [`loader()`](/docs/integrations/asyncapi#generate-pages), `generateFiles()` writes them into your content directory as MDX files instead. They accept the same options.
Note that the page content is still rendered by the `` component, the docs generator only arranges the operations to render for each page.
## Options [#options]
### `per` [#per]
Customize the content of pages, default to `operation`.
Operation refers to an AsyncAPI operation with a specific action like
`receiveLightMeasurement:receive`.
| mode | content | file path |
| --------- | ------------------------------ | ------------------- |
| tag | operations with same tag | `{tag_name}.mdx` |
| file | operations in same schema file | `{file_name}.mdx` |
| operation | each operation | `{operationId}.mdx` |
| custom | see below | N/A |
```ts
import { asyncapi } from '@/lib/asyncapi';
await asyncapi.staticSource({
per: 'tag',
});
```
When set to `custom`, you can pass a function to fully customize the generation process:
```ts title="Example"
import { asyncapi } from '@/lib/asyncapi';
await asyncapi.staticSource({
per: 'custom',
toPages(builder) {
for (const [id, item] of Object.entries(builder.document.operations ?? {})) {
const operation = builder.dereferenceShallow(item);
builder.create({
type: 'operation',
schemaId: builder.id,
item: { id, action: operation.action },
path: `${id}.mdx`,
info: {
title: operation.title ?? id,
description: operation.description,
},
});
}
},
});
```
When set to `tag`, tags are matched by name. Define them in `info.tags` to give their pages a description, and `x-displayName` to control the title. Operations without tags are under `unknown`.
```yaml title="asyncapi.yaml"
info:
tags:
- name: lighting
description: Operations related to streetlights.
x-displayName: Lighting
```
### `groupBy` [#groupby]
In `operation` mode, you can group output files with folders.
| value | output |
| -------------- | --------------------------------- |
| tag | `{tag}/{operationId}.mdx` |
| channel | `{channel key}/{operationId}.mdx` |
| none (default) | `{operationId}.mdx` |
```ts
import { asyncapi } from '@/lib/asyncapi';
await asyncapi.staticSource({
per: 'operation',
groupBy: 'tag',
});
```
### `name` [#name]
A function that controls the file name of generated pages.
```ts
import { asyncapi } from '@/lib/asyncapi';
await asyncapi.staticSource({
name(output) {
if (output.type === 'operation') {
const operation = this.dereferenceShallow(this.document.operations?.[output.item.id]);
console.log(operation);
return `my-dir/${output.item.id}`;
}
return 'my-dir/filename';
},
});
```
You can also use the `algorithm` option:
```ts
import { asyncapi } from '@/lib/asyncapi';
await asyncapi.staticSource({
// v2 (default): use operation id
// v1: use `{operationId}/{action}`
name: { algorithm: 'v1' },
});
```
### `meta` [#meta]
Generate [`meta.json`](/docs/page-conventions#meta) files automatically.
You can always define the `meta.json` files manually for maximal flexibility.
```ts
import { asyncapi } from '@/lib/asyncapi';
await asyncapi.staticSource({
meta: true,
});
```
Or customize how folders are represented:
```ts
import { asyncapi } from '@/lib/asyncapi';
await asyncapi.staticSource({
meta: {
folderStyle: 'separator',
},
});
```
## MDX Files [#mdx-files]
To write pages as MDX files, create a script:
```ts title="scripts/generate-docs.ts"
import { generateFiles } from '@fumadocs/asyncapi';
import { asyncapi } from '@/lib/asyncapi';
void generateFiles({
input: asyncapi,
output: './content/docs',
// we recommend to enable it
// make sure your operation description doesn't break MDX syntax.
includeDescription: true,
});
```
Generate docs with the script:
```bash
bun ./scripts/generate-docs.ts
```
Add the `AsyncAPIPage` component to your MDX components. Generated files use the `` tag.
```tsx tab="Next.js" title="app/docs/[[...slug]]/page.tsx"
// [!code ++:2]
import { asyncapi } from '@/lib/asyncapi';
import { AsyncAPIPage } from '@/components/api-page';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
// ...
(
),
})}
/>;
}
```
```tsx tab="React Router" title="app/routes/docs.tsx"
// [!code ++:3]
import type { MDXComponents } from 'mdx/types';
import { asyncapi } from '@/lib/asyncapi';
import { AsyncAPIPage } from '@/components/api-page';
export async function loader({ params }: Route.LoaderArgs) {
// ...
return {
path: page.path,
pageTree: await source.serializePageTree(source.getPageTree()),
...(await asyncapi.preloadAsyncAPIPage(page)), // [!code ++]
};
}
// [!code highlight]
function Content({ path, components }: { path: string; components?: MDXComponents }) {
// ...
; // [!code highlight]
}
export default function Page({ loaderData }: Route.ComponentProps) {
const { path, pageTree, preloaded } = useFumadocsLoader(loaderData); // [!code highlight]
return (
,
}}
/>
);
}
```
```tsx tab="Tanstack Start" title="src/routes/docs/$.tsx"
// [!code ++:4]
import type { MDXComponents } from 'mdx/types';
import { asyncapi } from '@/lib/asyncapi';
import { AsyncAPIPage } from '@/components/api-page';
import type { AsyncAPI } from '@fumadocs/asyncapi';
// [!code ++:7]
// the type-level check of server function results can't verify AsyncAPI documents,
// register them as serializable (install `@tanstack/router-core` if it isn't resolvable)
declare module '@tanstack/router-core' {
interface SerializableExtensions {
asyncapiDocument: AsyncAPI.AsyncAPIObject;
}
}
const serverLoader = createServerFn({
method: 'GET',
})
.validator((slugs: string[]) => slugs)
.handler(async ({ data: slugs }) => {
// ...
return {
path: page.path,
pageTree: await source.serializePageTree(source.getPageTree()),
...(await asyncapi.preloadAsyncAPIPage(page)), // [!code ++]
};
});
// [!code highlight]
function Content({ path, components }: { path: string; components?: MDXComponents }) {
// ...
; // [!code highlight]
}
function Page() {
const { path, pageTree, preloaded } = useFumadocsLoader(Route.useLoaderData()); // [!code highlight]
return (
,
}}
/>
);
}
```
```tsx tab="Waku" title="src/pages/docs/[...slugs].tsx"
// [!code ++:2]
import { asyncapi } from '@/lib/asyncapi';
import { AsyncAPIPage } from '@/components/api-page';
export default function Page({ slugs }: PageProps<'/docs/[...slugs]'>) {
// ...
(
),
})}
/>;
}
```
Add the plugin to your `loader()`:
```ts title="lib/source.ts"
import { asyncapiPlugin } from '@fumadocs/asyncapi/server';
import { loader } from 'fumadocs-core/source';
export const source = loader({
// [!code ++:2] optional: adds the direction of messages to each page item in page tree
plugins: [asyncapiPlugin()],
});
```
`generateFiles()` also accepts the options below.
### `index` [#index]
Generate index files with cards linking to generated pages.
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
index: {
// for generating `href`
url: {
baseUrl: '/docs',
contentDir: './content/docs',
},
items: [
{
// output path
path: 'index.mdx',
// optional: restrict the input files (identical to the `input` field in server)
only: ['streetlights.yaml'],
// optional: set frontmatter
description: 'All available pages',
},
],
},
});
```
### `imports` [#imports]
Add custom imports to generated MDX files. This is useful for including components, utilities, or other dependencies in your generated documentation.
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
imports: [
{
names: ['API_BASE_URL'],
from: '@/constants',
},
],
});
```
This will add the following imports to all generated MDX files:
```ts
import { API_BASE_URL } from '@/constants';
```
### `frontmatter` [#frontmatter]
Customize the frontmatter of MDX files.
By default, it includes:
| property | description |
| ------------- | ---------------------------------- |
| `title` | Page title |
| `description` | Page description |
| `full` | Always true, added for Fumadocs UI |
| `_asyncapi` | Internal Properties |
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
frontmatter: (title, description) => ({
myProperty: 'hello',
}),
});
```
### `addGeneratedComment` [#addgeneratedcomment]
Add a comment to the top of generated files indicating they are auto-generated.
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
// Add default comment
addGeneratedComment: true,
// Or provide a custom comment
addGeneratedComment: 'Custom auto-generated comment',
// Or disable comments
addGeneratedComment: false,
});
```
# Fumadocs (Framework Mode): Headless
URL: /docs/integrations/asyncapi/headless
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/integrations/asyncapi/headless.mdx
The state and logic of AsyncAPI pages, without UI.
## Overview [#overview]
Everything outside `@fumadocs/asyncapi/ui` is headless.
| Path | Module |
| ------------------------------- | --------------------------------------------------------- |
| `@fumadocs/asyncapi` | the page: the document, its servers and your components |
| `@fumadocs/asyncapi/operation` | an operation: its channel, parameters, messages and reply |
| `@fumadocs/asyncapi/playground` | a session on the channel of an operation |
## Page [#page]
`createAsyncAPIRenderer()` takes your components and returns ``, which accepts the props of generated pages.
```tsx title="components/api-page.tsx"
'use client';
import { type CodeBlockProps, createAsyncAPIRenderer } from '@fumadocs/asyncapi';
import { Operation } from '@/components/my-operation';
import { SchemaUI } from '@/components/my-schema';
export const AsyncAPIPage = createAsyncAPIRenderer({
components: {
Operation,
SchemaUI,
CodeBlock({ lang, code }: CodeBlockProps) {
return (
{code}
);
},
},
});
```
| Component | Renders |
| ----------- | ------------------------------------------------------------ |
| `Operation` | each operation of the page, see [Operation](#operation) |
| `SchemaUI` | the JSON schemas of parameters, headers and message payloads |
| `Layout` | optional, wraps the rendered operations |
### Hooks [#hooks]
Components under the page read its state.
| Hook | Returns |
| -------------------- | -------------------------------------------------- |
| `useAsyncAPI()` | the dereferenced document (`doc`) and page options |
| `useComponents()` | the components passed to the page |
| `useServer()` | the servers, the selected one and its variables |
| `useRenderContext()` | the render options of the page, like `content` |
To render operations yourself, mount `` in place of `createAsyncAPIRenderer()`.
## Operation [#operation]
`` derives the details of an operation once: traits are applied, and its channel, parameters, messages and reply are resolved.
```tsx title="components/my-operation.tsx"
'use client';
import {
OperationProvider,
type PageOperationProps,
useOperation,
useOperationSecurity,
} from '@fumadocs/asyncapi/operation';
export function Operation(props: PageOperationProps) {
return (
);
}
function Content() {
const { title, channel, parameters, messages } = useOperation();
const security = useOperationSecurity();
return (
<>
>
);
}
```
| Hook | Returns |
| ------------------------ | ---------------------------------------------------------------------------- |
| `useOperation()` | the operation with its details resolved |
| `useOperationSecurity()` | security schemes of the selected server and the operation, both apply |
| `useChannelMessages()` | messages readers receive and send on the channel, from all of its operations |
### OperationInfo
| Prop | Type | Description |
| -------------- | -------- | -------------------------------------------------------------------------------- |
| `id` | `string` | |
| `action` | `union` | the action of the application described by the document, readers do the opposite |
| `operation` | `object` | resolved, with traits applied |
| `channel` | `object` | |
| `title` | `string` | |
| `description?` | `string` | |
| `parameters` | `array` | parameters of the channel address |
| `messages` | `array` | |
| `reply?` | `object` | |
Examples of a message come from the document, or are generated from its schemas.
### OperationMessage
| Prop | Type | Description |
| -------------- | -------- | ------------------------------------------------------------- |
| `id` | `string` | the key of message in its channel, unique in the operation |
| `name` | `string` | display name |
| `message` | `object` | resolved, with traits applied |
| `contentType?` | `string` | falls back to the `defaultContentType` of document |
| `received` | `union` | sent by the application to readers, otherwise readers send it |
| `headers?` | `union` | |
| `payload?` | `union` | |
| `examples` | `array` | example messages, generated from its schemas when it has none |
## Playground [#playground]
`usePlayground()` connects to the channel of an operation with a transport, under ``.
```tsx
'use client';
import { usePlayground } from '@fumadocs/asyncapi/playground';
import { mqttTransport } from '@/lib/mqtt-transport';
export function Playground() {
const { url, status, connect, disconnect, send, log } = usePlayground({
// on top of the page's `playground.transports`
transports: { mqtt: mqttTransport },
});
if (!url) return null; // no transport for the server
return (
<>
{send && }
{log.map((entry) => (
{entry.type} {entry.message?.name} {entry.data}
))}
>
);
}
```
- The address parameters, query and auth inputs are the form `stf`, `url` follows its values.
- `send` is available while connected, unless the connection can only receive.
- Received messages are matched against the payloads of messages on the channel, `entry.message` is the documented message. Binary messages are `entry.binary`.
### Playground
| Prop | Type | Description |
| ------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `stf` | `object` | the form of PlaygroundValues |
| `transport?` | `object` | the transport connecting to the selected server, `undefined` when none does |
| `url?` | `string` | the URL to connect with the values of form, `undefined` without a transport |
| `status` | `ConnectionStatus` | |
| `connect` | `function` | |
| `disconnect` | `function` | |
| `send?` | `function` | send a message, with the documented message it is. Absent until connected, and when the connection can only receive |
| `log` | `array` | |
| `clearLog` | `function` | |
### Transports [#transports]
A transport connects to the servers of a protocol, see [the MQTT example](/docs/integrations/asyncapi/api-page#custom-transports). They are picked from the `transports` of `usePlayground()`, then the page's `playground.transports`, then the built-in WebSocket one.
`usePlaygroundTransport()` takes the same `transports` and returns the transport of the selected server, `undefined` when none connects to it.
### PlaygroundTransport
| Prop | Type | Description |
| --------- | ---------- | ------------------------------------------------------------------------------------------------------- |
| `auth?` | `function` | security schemes it can apply besides API keys in the query, they become inputs of the connection panel |
| `connect` | `function` | |
`connect()` reports to `events`: `open()`, `message(data)` with text or an `ArrayBuffer`, and `close()` or `error()`, which end the connection.
### ConnectTarget
| Prop | Type | Description |
| --------- | ------------------------- | ------------------------------------------------------------- |
| `url` | `string` | server URL, address and query, for URL-based protocols |
| `server` | `object` | |
| `address` | `string` | the channel address with its parameters |
| `query` | `Record` | |
| `auth` | `array` | values of the auth inputs, for the schemes accepted by `auth` |
# Fumadocs (Framework Mode): AsyncAPI
URL: /docs/integrations/asyncapi
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/integrations/asyncapi/index.mdx
Generating docs for AsyncAPI schema.
## Setup [#setup]
Install the required packages.
```npm
npm i @fumadocs/asyncapi shiki
```
### Generate Styles [#generate-styles]
Add the following line:
```css title="Tailwind CSS"
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
/* [!code ++] */
@import '@fumadocs/asyncapi/css/preset.css';
```
### Configure Plugin [#configure-plugin]
Create the AsyncAPI server instance & `` component.
```ts tab="lib/asyncapi.ts"
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
// note: this is a server-side API
export const asyncapi = createAsyncAPI({
// the AsyncAPI schema, you can also give it an external URL.
input: ['./asyncapi.yaml'],
});
```
```tsx tab="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage();
```
See [`createAsyncAPI()`](/docs/integrations/asyncapi/server) & [`createAsyncAPIPage()`](/docs/integrations/asyncapi/api-page) for available options.
### Generate Pages [#generate-pages]
Integrate into [Loader API](/docs/headless/source-api/source) to generate pages, without generating real files.
```ts tab="Next.js" title="lib/source.ts"
import { loader } from 'fumadocs-core/source';
import { defineDocs } from 'fumadocs-mdx/macro';
import { asyncapi } from '@/lib/asyncapi'; // [!code ++]
const docs = defineDocs({
dir: 'content/docs',
});
export const source = loader(
// [!code ++:6]
{
docs: docs.toFumadocsSource(),
asyncapi: await asyncapi.staticSource({
baseDir: 'asyncapi',
}),
},
{
baseUrl: '/docs',
// [!code ++:2] optional: adds the direction of messages to each page item in page tree
plugins: [asyncapi.loaderPlugin()],
},
);
```
```ts tab="React Router" title="app/lib/source.ts"
import { loader } from 'fumadocs-core/source';
import { defineDocs } from 'fumadocs-mdx/macro'; // [!code --]
// [!code ++:2]
import { docs } from '@/lib/collections';
import { asyncapi } from '@/lib/asyncapi';
// [!code --:6]
export const docs = defineDocs({
dir: 'content/docs',
docs: {
async: true,
},
});
// server-only now, as `staticSource()` reads files [!code ++]
export const source = loader(
// [!code ++:6]
{
docs: docs.toFumadocsSource(),
asyncapi: await asyncapi.staticSource({
baseDir: 'asyncapi',
}),
},
{
baseUrl: '/docs',
// [!code ++:2] optional: adds the direction of messages to each page item in page tree
plugins: [asyncapi.loaderPlugin()],
},
);
```
```ts tab="React Router" title="app/lib/collections.ts"
import { defineDocs } from 'fumadocs-mdx/macro';
export const docs = defineDocs({
dir: 'content/docs',
docs: {
async: true,
},
});
```
```ts tab="Tanstack Start" title="src/lib/source.ts"
import { loader } from 'fumadocs-core/source';
import { defineDocs } from 'fumadocs-mdx/macro'; // [!code --]
// [!code ++:2]
import { docs } from '@/lib/collections';
import { asyncapi } from '@/lib/asyncapi';
// [!code --:6]
export const docs = defineDocs({
dir: 'content/docs',
docs: {
async: true,
},
});
// server-only now, as `staticSource()` reads files [!code ++]
export const source = loader(
// [!code ++:6]
{
docs: docs.toFumadocsSource(),
asyncapi: await asyncapi.staticSource({
baseDir: 'asyncapi',
}),
},
{
baseUrl: '/docs',
// [!code ++:2] optional: adds the direction of messages to each page item in page tree
plugins: [asyncapi.loaderPlugin()],
},
);
```
```ts tab="Tanstack Start" title="src/lib/collections.ts"
import { defineDocs } from 'fumadocs-mdx/macro';
export const docs = defineDocs({
dir: 'content/docs',
docs: {
async: true,
},
});
```
```ts tab="Waku" title="src/lib/source.ts"
import { loader } from 'fumadocs-core/source';
import { defineDocs } from 'fumadocs-mdx/macro';
import { asyncapi } from '@/lib/asyncapi'; // [!code ++]
const docs = defineDocs({
dir: 'content/docs',
});
export const source = loader(
// [!code ++:6]
{
docs: docs.toFumadocsSource(),
asyncapi: await asyncapi.staticSource({
baseDir: 'asyncapi',
}),
},
{
baseUrl: '/docs',
// [!code ++:2] optional: adds the direction of messages to each page item in page tree
plugins: [asyncapi.loaderPlugin()],
},
);
```
It generates a page per operation by default, see [Generating Pages](/docs/integrations/asyncapi/generate-files) to customize them.
**It will change the type of your pages**, make sure to update all references to your `source`.
For example, where you return text for LLM:
```ts
import { llms } from 'fumadocs-core/source';
import { source } from '@/lib/source';
export const docsLlms = llms(source, {
renderPage: async (page) => {
// [!code ++:3]
if (page.type === 'asyncapi') {
return `# ${page.data.title}\n\n${await page.data.getText('processed')}`;
}
// your original flow below...
},
});
```
And update your page renderer:
```tsx tab="Next.js" title="app/docs/[[...slug]]/page.tsx"
import { AsyncAPIPage } from '@/components/api-page'; // [!code ++]
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
// [!code ++:10]
if (page.type === 'asyncapi') {
return (
{page.data.title}
);
}
// ...
}
```
```tsx tab="React Router" title="app/routes/docs.tsx"
import { docs, source } from '@/lib/source'; // [!code --]
// [!code ++:3]
import { docs } from '@/lib/collections';
import { source } from '@/lib/source';
import { AsyncAPIPage } from '@/components/api-page';
export async function loader({ params }: Route.LoaderArgs) {
const slugs = params['*'].split('/').filter((v) => v.length > 0);
const page = source.getPage(slugs);
if (!page) throw new Response('Not found', { status: 404 });
// [!code ++:9]
const pageTree = await source.serializePageTree(source.getPageTree());
if (page.type === 'asyncapi') {
return {
type: 'asyncapi' as const,
title: page.data.title,
props: page.data.getAsyncAPIPageProps(),
pageTree,
};
}
return { type: 'docs' as const, path: page.path, pageTree }; // [!code highlight]
}
export default function Page({ loaderData }: Route.ComponentProps) {
const data = useFumadocsLoader(loaderData);
return (
{/* [!code ++:8] */}
{data.type === 'asyncapi' && (
{data.title}
)}
{/* [!code highlight] */}
{data.type === 'docs' && }
);
}
```
```tsx tab="Tanstack Start" title="src/routes/docs/$.tsx"
import { docs, source } from '@/lib/source'; // [!code --]
// [!code ++:4]
import { docs } from '@/lib/collections';
import { source } from '@/lib/source';
import { AsyncAPIPage } from '@/components/api-page';
import type { AsyncAPI } from '@fumadocs/asyncapi';
// [!code ++:7]
// the type-level check of server function results can't verify AsyncAPI documents,
// register them as serializable (install `@tanstack/router-core` if it isn't resolvable)
declare module '@tanstack/router-core' {
interface SerializableExtensions {
asyncapiDocument: AsyncAPI.AsyncAPIObject;
}
}
export const Route = createFileRoute('/docs/$')({
component: Page,
loader: async ({ params }) => {
const slugs = params._splat?.split('/') ?? [];
const data = await serverLoader({ data: slugs });
if (data.type === 'docs') await docs.getPage(data.path)?.preload(); // [!code highlight]
return data;
},
});
const serverLoader = createServerFn({
method: 'GET',
})
.validator((slugs: string[]) => slugs)
.handler(async ({ data: slugs }) => {
const page = source.getPage(slugs);
if (!page) throw notFound();
// [!code ++:9]
const pageTree = await source.serializePageTree(source.getPageTree());
if (page.type === 'asyncapi') {
return {
type: 'asyncapi',
title: page.data.title,
props: page.data.getAsyncAPIPageProps(),
pageTree,
};
}
return { type: 'docs', path: page.path, pageTree }; // [!code highlight]
});
function Page() {
const data = useFumadocsLoader(Route.useLoaderData());
return (
{/* [!code ++:8] */}
{data.type === 'asyncapi' && (
{data.title}
)}
{/* [!code highlight] */}
{data.type === 'docs' && }
);
}
```
```tsx tab="Waku" title="src/pages/docs/[...slugs].tsx"
import { AsyncAPIPage } from '@/components/api-page'; // [!code ++]
export default function Page({ slugs }: PageProps<'/docs/[...slugs]'>) {
const page = source.getPage(slugs);
if (!page) unstable_notFound();
// [!code ++:10]
if (page.type === 'asyncapi') {
return (
{page.data.title}
);
}
// ...
}
```
Run a type check to verify before continuing, e.g.
```npm
npm run types:check
```
To write MDX files into your content directory instead, see [MDX Files](/docs/integrations/asyncapi/generate-files#mdx-files).
## Features [#features]
The official AsyncAPI integration supports AsyncAPI 3:
- The direction of messages for readers, the server and channel address of operations
- Messages with their payload and header schemas, including Avro, Protobuf and RAML schemas
- Message examples, from the document or generated from schemas
- Request and reply, correlation IDs
- Security schemes, including OAuth 2.0 flows and SASL mechanisms
- Protocol details from bindings, for the selected server
- A playground for WebSocket, Server-Sent Events and custom transports, with text and binary messages
- Code samples in JavaScript and Python, for WebSocket, MQTT, Kafka, AMQP and NATS
- TypeScript definitions of payloads and headers
- Markdown of pages for LLMs
- Traits
AsyncAPI 2 documents can be converted with `npx @asyncapi/cli convert`.
### Demo [#demo]
See the [AsyncAPI example](/docs/asyncapi).
# Fumadocs (Framework Mode): createAsyncAPI()
URL: /docs/integrations/asyncapi/server
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/integrations/asyncapi/server.mdx
The AsyncAPI server instance.
## AsyncAPI Server [#asyncapi-server]
The main config for Fumadocs AsyncAPI.
> It should not be referenced in browser environments.
### `input` [#input]
The AsyncAPI schemas to read from.
- File Paths
- External URLs
- Functions (see below)
Only schemas listed here can be loaded, other IDs passed to `getSchema()` or preloaded by pages throw an error.
Do not pass untrusted schemas to `input`, their `$ref`s can read local files and fetch URLs, private hosts included.
```ts tab="Basic"
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
export const asyncapi = createAsyncAPI({
input: ['./streetlights.yaml'],
});
```
```ts tab="Functions"
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
export const asyncapi = createAsyncAPI({
input: {
// [id]: downloaded AsyncAPI Schema
galaxy: async () => {
const res = await fetch('https://example.com/asyncapi.yaml');
return res.text();
},
},
});
```
### `disableCache` [#disablecache]
Disable caching of loaded schemas. Useful during development when schemas change frequently.
```ts
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
export const asyncapi = createAsyncAPI({
input: ['./asyncapi.yaml'],
disableCache: true,
});
```
## Loading Documents Once [#loading-documents-once]
Pages receive the whole document in their props, every page load, navigation and prefetch sends it again. For large documents, leave it out:
```ts title="lib/asyncapi.ts"
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
export const asyncapi = createAsyncAPI({
// IDs end up in URLs, keep them URL-safe
input: { streetlights: './streetlights.yaml' },
includeDocument: false, // [!code ++]
});
```
On Next.js, Waku and React Router, `` loads each document once per browser session with `loadDocument`, it also runs during server rendering:
```tsx title="components/api-page.tsx"
'use client';
import { preload } from 'react-dom';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage({
async loadDocument(id) {
// `import.meta.env.SSR` on Waku and React Router
if (typeof window === 'undefined') {
// the browser starts downloading it with the page
preload(`/api/asyncapi/${id}`, { as: 'fetch', crossOrigin: 'anonymous' });
const { asyncapi } = await import('@/lib/asyncapi');
return (await asyncapi.getSchema(id)).bundled;
}
return (await fetch(`/api/asyncapi/${id}`)).json();
},
});
```
And serve documents to the browser:
```ts tab="Next.js" title="app/api/asyncapi/[id]/route.ts"
import { asyncapi } from '@/lib/asyncapi';
export async function GET(_req: Request, { params }: RouteContext<'/api/asyncapi/[id]'>) {
return Response.json((await asyncapi.getSchema((await params).id)).bundled);
}
// for static exports
export async function generateStaticParams() {
return Object.keys(await asyncapi.getSchemas()).map((id) => ({ id }));
}
```
```ts tab="React Router" title="app/routes/asyncapi.ts"
import type { Route } from './+types/asyncapi';
import { asyncapi } from '@/lib/asyncapi';
export async function loader({ params }: Route.LoaderArgs) {
return Response.json((await asyncapi.getSchema(params.id)).bundled);
}
```
```ts tab="React Router" title="app/routes.ts"
import { route, type RouteConfig } from '@react-router/dev/routes';
export default [
// [!code ++]
route('api/asyncapi/:id', 'routes/asyncapi.ts'),
// ...
] satisfies RouteConfig;
```
```ts tab="Waku" title="src/pages/_api/api/asyncapi/[id].ts"
import type { ApiContext } from 'waku/router';
import { asyncapi } from '@/lib/asyncapi';
export async function GET(_: Request, { params }: ApiContext<'/api/asyncapi/[id]'>) {
return Response.json((await asyncapi.getSchema(params.id)).bundled);
}
```
On Tanstack Start, route loaders run on both sides, call a server function only when the browser doesn't have the document yet:
```tsx title="src/routes/docs/$.tsx"
import { createServerFn } from '@tanstack/react-start';
import type { AsyncAPI } from '@fumadocs/asyncapi';
import { asyncapi } from '@/lib/asyncapi';
// documents the browser has loaded
const documents = new Map();
const getDocument = createServerFn({ method: 'GET' })
.validator((id: string) => id)
.handler(async ({ data: id }) => (await asyncapi.getSchema(id)).bundled);
export const Route = createFileRoute('/docs/$')({
component: Page,
loader: async ({ params }) => {
const data = await serverLoader({ data: params._splat?.split('/') ?? [] });
if (data.type === 'asyncapi') {
const { document: id, payload } = data.props;
payload.bundled = documents.get(id) ?? (await getDocument({ data: id }));
}
return data;
},
});
function Page() {
const page = useFumadocsLoader(Route.useLoaderData());
// the document of the first page too
if (page.type === 'asyncapi' && typeof window !== 'undefined')
documents.set(page.props.document, page.props.payload.bundled!);
// ...
}
```
- With generated MDX files, only Tanstack Start needs a change: put documents in `preloaded.docs` instead.
- For the [UI installed by Fumadocs CLI](/docs/integrations/asyncapi/api-page#full-ui), pass `loadDocument` to `createAsyncAPIRenderer()`.
- Browsers keep each document until a reload. With [`disableCache`](#disablecache), leave `includeDocument` on.
# Fumadocs (Framework Mode): Blog
URL: /docs/integrations/blog
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/integrations/blog.mdx
Add a blog to your app.
## Installation [#installation]
```npm
npx @fumadocs/cli@latest add blog
```
It installs the components to `components/blog`, and the utilities of posts to `lib/blog.ts`.
Define a collection for your posts with `blogPageSchema`, and register your authors:
```ts tab="Fumadocs MDX" title="lib/source.ts"
import { defineCollections } from 'fumadocs-mdx/macro';
import { loader } from 'fumadocs-core/source';
import { type BlogAuthor, blogPageSchema } from '@/lib/blog';
const blog = defineCollections({
type: 'doc',
dir: 'content/blog',
schema: blogPageSchema,
});
export const blogSource = loader({
baseUrl: '/blog',
source: blog.toFumadocsSource(),
});
export const blogAuthors: Record = {
fuma: {
name: 'Fuma Nama',
title: 'Maintainer',
url: 'https://fuma-nama.dev',
image: 'https://avatars.githubusercontent.com/u/76240755',
},
};
```
Write your posts under `content/blog`:
```mdx title="content/blog/hello-world.mdx"
---
title: Hello World
description: My first post.
date: 2026-10-12
tags: [Announcement]
authors: [fuma]
image: /blog/hello-world.png
---
```
| Field | Description |
| --------- | -------------------------------------------- |
| `date` | Publish date, posts are sorted by it |
| `tags` | Each tag has its page under `/blog/tags` |
| `authors` | Keys of your authors, unknown keys are names |
| `image` | Cover image URL, shown on the cards of posts |
Cover images and avatars render with the `Image` component of your framework. On Next.js, allow
their hosts in `images.remotePatterns`.
## Pages [#pages]
`getPosts()` resolves the authors and tags of pages, newest first. Render them in your routes under `app/(home)`, so they share the [Home Layout](/docs/ui/layouts/home-layout).
### Posts [#posts]
```tsx tab="Next.js" title="app/(home)/blog/page.tsx"
import { blogAuthors, blogSource } from '@/lib/source';
import { getPosts } from '@/lib/blog';
import { BlogCards } from '@/components/blog';
export default function Page() {
return (
);
}
```
### Post [#post]
`findPost()` finds a post along with the newer and older posts. With `toc`, a floating panel shows the table of contents and a share button.
```tsx tab="Next.js" title="app/(home)/blog/[slug]/page.tsx"
import { notFound } from 'next/navigation';
import { blogAuthors, blogSource } from '@/lib/source';
import { findPost, getPosts } from '@/lib/blog';
import { getMDXComponents } from '@/components/mdx';
import {
BlogAuthors,
BlogBackLink,
BlogBody,
BlogDate,
BlogDescription,
BlogHeader,
BlogPage,
BlogPostNav,
BlogTags,
BlogTitle,
} from '@/components/blog';
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params;
const page = blogSource.getPage([slug]);
const found = page && findPost(getPosts(blogSource.getPages(), blogAuthors), page.url);
if (!page || !found) notFound();
const { post, newer, older } = found;
const MDX = page.data.body;
return (
{post.title}{post.description}
{post.date && }
);
}
```
### Tags [#tags]
List all tags with `getTags()`:
```tsx tab="Next.js" title="app/(home)/blog/tags/page.tsx"
import { blogSource } from '@/lib/source';
import { getPosts, getTags } from '@/lib/blog';
import { BlogHeader, BlogTagCards, BlogTitle } from '@/components/blog';
export default function Page() {
const tags = getTags(getPosts(blogSource.getPages()));
return (
All Tags
);
}
```
And the posts of a tag with `getTagPosts()`, it returns `undefined` for unknown tags:
```tsx tab="Next.js" title="app/(home)/blog/tags/[tag]/page.tsx"
import { notFound } from 'next/navigation';
import { blogAuthors, blogSource } from '@/lib/source';
import { getPosts, getTagPosts, getTags } from '@/lib/blog';
import { BlogCards, BlogHeader, BlogTitle } from '@/components/blog';
export default async function Page(props: PageProps<'/blog/tags/[tag]'>) {
const { tag } = await props.params;
const result = getTagPosts(getPosts(blogSource.getPages(), blogAuthors), tag);
if (!result) notFound();
return (
{result.tag.name}
);
}
export function generateStaticParams() {
return getTags(getPosts(blogSource.getPages())).map((tag) => ({ tag: tag.slug }));
}
```
## Internationalization [#internationalization]
Pass the pages of a locale to `getPosts()`, like `blogSource.getPages(lang)`.
The URLs of tag pages come from `getBlogUrl` in `lib/blog.ts`, pass it your i18n config to match the URLs of your loader:
```ts title="lib/blog.ts"
import { i18n } from '@/lib/i18n';
export const getBlogUrl = createGetUrl('/blog', i18n);
```
To publish a feed of your posts, see [RSS Feed](/docs/guides/rss).
# Fumadocs (Framework Mode): Changelog
URL: /docs/integrations/changelog
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/integrations/changelog.mdx
Show the releases of your project.
## Installation [#installation]
Install the timeline component, it adds `@fumadocs/changelog` to your dependencies:
```npm
npx @fumadocs/cli@latest add changelog
```
## Usage [#usage]
Create a page under your [Home Layout](/docs/ui/layouts/home-layout). Fetch a page of entries, render them with `ChangelogTimeline`, and link to the other pages with `ChangelogPagination`:
```tsx tab="Next.js" title="app/(home)/changelog/[[...page]]/page.tsx"
import { notFound } from 'next/navigation';
import { githubReleases } from '@fumadocs/changelog/github';
import { ChangelogPagination, ChangelogTimeline } from '@/components/changelog';
export const revalidate = 3600;
export default async function Page(props: PageProps<'/changelog/[[...page]]'>) {
const { page: segments = [] } = await props.params;
const page = Number(segments[0] ?? 1);
const { entries, nextPage } = await githubReleases({
owner: 'fuma-nama',
repo: 'fumadocs',
token: process.env.GITHUB_TOKEN,
page,
});
if (entries.length === 0) notFound();
return (
);
}
export const getConfig = async () => ({ render: 'dynamic' }) as const;
```
```tsx tab="Waku" title="src/pages/(home)/changelog/index.tsx"
// the first page
export { default, getConfig } from './[page]';
```
## Sources [#sources]
### GitHub Releases [#github-releases]
`githubReleases()` returns a page of the published releases of a repository, with `nextPage` when there are older releases.
- Versions are read from the release names like `fumadocs-ui@16.17.0`, or their tags.
- Entries have a `prerelease` property for pre-releases.
- GitHub limits unauthenticated requests to 60 per hour, pass a `token` and cache the page, like `export const revalidate = 3600` on Next.js.
- GitHub lists the first 1000 releases, or 10000 with a token, later pages have no entries.
### $Fumadocs
| Prop | Type | Description |
| ---------- | -------- | ------------------------------------------------------------------------ |
| `owner` | `string` | |
| `repo` | `string` | |
| `token?` | `string` | GitHub access token, unauthenticated requests are limited to 60 per hour |
| `page?` | `number` | Page of releases, starting from 1 Default: `1` |
| `perPage?` | `number` | Releases per page, up to 100 Default: `30` |
| `baseUrl?` | `string` | Base URL of GitHub API Default: `'https://api.github.com'` |
### Custom Entries [#custom-entries]
Entries are plain objects, create them from any source:
### $Fumadocs
| Prop | Type | Description |
| ----------- | -------- | --------------------------------------------- |
| `id` | `string` | unique, e.g. a release tag |
| `title` | `string` | |
| `date` | `object` | |
| `versions?` | `array` | released packages, like `fumadocs-ui@16.17.0` |
| `url?` | `string` | URL of the entry, e.g. a GitHub release |
| `content?` | `string` | content in Markdown |
For example, a collection of MDX files. Pass `body` to render the content yourself:
```tsx title="app/(home)/changelog/page.tsx"
import { changelogSource } from '@/lib/source';
import { getMDXComponents } from '@/components/mdx';
import { ChangelogTimeline, type ChangelogTimelineEntry } from '@/components/changelog';
export default function Page() {
const entries: ChangelogTimelineEntry[] = changelogSource.getPages().map((page) => {
const MDX = page.data.body;
return {
id: page.url,
title: page.data.title,
date: new Date(page.data.date),
body: ,
};
});
return (
);
}
```
# Fumadocs (Framework Mode): Build Your Own
URL: /docs/integrations/content/custom
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/integrations/content/custom.mdx
Build your own content source for Fumadocs.
## Introduction [#introduction]
For a custom content source implementation, there's two ways:
### Using Loader API [#using-loader-api]
Loader API has a file-system-like interface for integrating different content sources.
See [Loader API `source`](/docs/headless/source-api/source) for details.
### Using Low-Level API [#using-low-level-api]
For more robust control, you can use lower level APIs directly.
#### Page Tree [step] [#page-tree]
You can either hardcode the page tree, or write some code to generate one.
See [Definitions of Page Tree](/docs/headless/page-tree).
Pass your page tree to `DocsLayout` (usually in a `layout.tsx`):
```tsx title="layout.tsx"
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
{children}
);
}
```
The page tree is like a smarter "sidebar items", they will be referenced everywhere in the UI for navigation elements, such as the page footer.
#### Page Content [step] [#page-content]
For docs page, it's the same logic:
- Define path params (`slugs`).
- Fetch page content from path params.
- Render the content (inside the `DocsPage` component).
You may need table of contents, which can be generated on your own, or using the [`getTableOfContents`](/docs/headless/utils/get-toc) utility (Markdown/MDX only).
```tsx
import { DocsPage, DocsBody } from 'fumadocs-ui/layouts/docs/page';
import { getPage } from './my-content-source';
import { notFound } from 'next/navigation';
export default function Page({ params }: { params: { slug?: string[] } }) {
const page = getPage(params.slug);
if (!page) notFound();
return (
{page.render()}
);
}
```
#### Pre-rendering [step] [#pre-rendering]
The mechanism for pre-rendering differs depending on your React.js framework.
It's important to pre-render pages (especially the frequently visited ones) to improve initial load time.
Define the [`generateStaticParams`](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) function to populate dynamic & catch-all routes.
When pre-rendering pages from a remote source, make sure to optimize repeated reads during the build phase.
For example, if you're fetching content via GitHub API, it is better to clone the repository content locally for pre-rendering.
```text tab="Before"
/docs/* -> GET github.com
/docs/introduction -> GET github.com
/docs/my-page -> GET github.com
Error: API Ratelimit
```
```text tab="After"
start -> git clone
/docs/* -> ls
/docs/introduction -> read file
/docs/my-page -> read file
```
#### Document Search [step] [#document-search]
This can be difficult considering your content may not be necessarily Markdown/MDX.
For Markdown and MDX, the built-in [Search API](/docs/headless/search/orama) + search index usage is adequate for most use cases.
Otherwise, you will have to bring your own implementation.
We recommend 3rd party solutions like Orama or Algolia Search. They are more flexible than the built-in Search API, and is easier to integrate with remote sources.
Fumadocs offers a variant of [Search Integrations](/docs/headless/search).
# Fumadocs (Framework Mode): Content Source
URL: /docs/integrations/content
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/integrations/content/index.mdx
Integrate different content sources with Fumadocs.
## Introduction [#introduction]
**Fumadocs is very flexible.** You can integrate with any content source, even without an official adapter.
### Examples [#examples]
You can see examples to use Fumadocs with a CMS, which allows a nice experience on publishing content, and real-time update without re-building the app.
- [BaseHub](https://github.com/fuma-nama/fumadocs-basehub)
- [Sanity](https://github.com/fuma-nama/fumadocs-sanity)
- [Community: Payload CMS by `MFarabi619`](https://github.com/MFarabi619/fumadocs-payloadcms)
- [Community: Payload CMS by `bapspatil`](https://github.com/bapspatil/fumadocs-payload-template)
Not enough? [Build your own content source](/docs/integrations/content/custom).
# Fumadocs (Framework Mode): Local HTML
URL: /docs/integrations/content/local-html
Source: https://raw.githubusercontent.com/fuma-nama/fumadocs/refs/heads/main/apps/docs/content/docs/(framework)/integrations/content/local-html.mdx
Content source for local HTML files.
## Introduction [#introduction]
`@fumadocs/local-html` is a content source for local HTML files, it is bundleless (works fully at runtime) by design.
It is made for pages you receive as HTML instead of author yourself, like exported decks, reports, and the output of other tools. Each file is adapted to your docs theme rather than rendered as it came.
As compared to [Local Markdown](/docs/integrations/content/local-md), HTML is treated as data to adapt, not as a document format to write in.
### Limitations [#limitations]
- Scripts are stripped, the interactive behaviour of the original page is not preserved.
- No MDX components in content, but you can map HTML tags to components at render phase.
- Processing is a transform, not sanitization. Nothing that executes survives it, but the remaining markup is rendered as-is, and the resources it references (images above all) are fetched by everyone who opens the page. Keep the content directory to files you trust, like your Markdown content.
## Setup [#setup]
Install the package:
```package-install
@fumadocs/local-html
```
Create a `localHtml` instance and connect it to Fumadocs:
```ts title="lib/source.ts"
import { dynamicLoader } from 'fumadocs-core/source';
import { localHtml } from '@fumadocs/local-html';
const pages = localHtml({
dir: 'content/pages',
// options
});
const pagesLoader = dynamicLoader(pages.dynamicSource(), {
baseUrl: '/docs',
});
export async function getSource() {
return pagesLoader.get();
}
```
The returned source from `pagesLoader.get()` is a normal [content loader](/docs/headless/source-api) instance.
## Usage [#usage]
`page.data.load()` returns a renderer for the processed content:
```tsx title="app/docs/[[...slug]]/page.tsx"
import { getSource } from '@/lib/source';
import { DocsPage, DocsBody, DocsTitle, DocsDescription } from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const source = await getSource();
const page = source.getPage(params.slug);
if (!page) notFound();
const renderer = await page.data.load();
const { body, toc } = await renderer.render();
return (
{page.data.title}{page.data.description}{body}
);
}
```
Pass components to `render()` to map HTML tags onto your own, like headings with anchors:
```tsx
import { getMDXComponents } from '@/mdx-components';
const { body, toc } = await renderer.render(getMDXComponents());
```
### Page Metadata [#page-metadata]
The title and description of each page are read from its ``:
- `` becomes the page title.
- `` becomes the description.
You can override them (and set an icon) without touching the visible content, using `fumadocs:` metas:
```html
```
Every ``/`content` pair of the document is also available on `page.data.metadata`.
Like other local sources, `meta.json` files customize the page tree, validated with `metaSchema`.
### Content Options [#content-options]
A page keeps only its content element: the only ``, then the only ``, falling back to `` with page chrome like `` and `