Written by
Fuma Nama
At
Fri Oct 09 2026
Fumadocs 16.17
Better Markdown, better search, better performance.
We are pleased to announce the release of Fumadocs 16.17.
This release rethinks how Fumadocs turns your pages into Markdown, and builds on top of it:
- A new stringification pipeline: Markdown that gives humans and agents the right context.
- Better search: records that keep the structure of your content.
- Better performance: Markdown and search records are generated over 10x faster.
The Markdown Form of a Page
A page is read in more places than a browser. Agents fetch its Markdown from .md routes, llms.txt and MCP servers, readers copy it into ChatGPT, and your search indexes it.
## Installation
Install the core package:
```npm
npm i fumadocs-core
```
Installation
Install the core package:
npmpnpmyarnbun
npm i fumadocs-core
Browsers
1/3The rendered page, for humans in browsers.
In Fumadocs, Remark LLMs generates the Markdown, and Remark Structure generates the search records.
For these readers, the Markdown is the page. It should be informative: the content you wrote, not how we render it.
16.17 rethinks the layer that generates it.
The Old Pipeline
Both plugins used to stringify the syntax tree, after the other remark plugins. By then, the tree was already transformed for rendering:
## Installation
```npm
npm i fumadocs-core
```

<auto-type-table path="./props.ts" name="Options" />
1/3You write 9 lines of MDX.
This is what 16.16 generated from a page of 9 lines:
## Installation
```npm
npm i fumadocs-core
```

<auto-type-table path="./props.ts" name="Options" />- Generated elements pollute the Markdown.
<CodeBlockTabs>and<img>are how we render the page, not your content.__img0doesn't even exist outside the bundle. - Components are not rendered. A JSX element is only its tag and props, its content lives in the React component. The type table became a JSON blob.
- Your Markdown is reformatted.
-lists become*,_emphasis_becomes*emphasis*, and2 * 3is escaped into2 \* 3. - It is slow. The whole tree is serialized for every page, and again for every search record.
We fixed it in two parts.
Components, Rendered
Since 16.15, fumadocs-core/server renders React Server Components into Markdown. A component opts in by calling asMarkdown(), and returns its Markdown form:
import { asMarkdown, md } from 'fumadocs-core/server';
export function Callout({ title, children }) {
if (asMarkdown()) return md.linePrefix('> ')`**${title}**\n${children}`;
return <div className="callout">...</div>;
}With output: 'function', Remark LLMs exports the Markdown as a component instead of a string. Markdown stays as you wrote it, JSX elements are rendered by your MDX components:
## InstallationMarkdown
<Callout title="Heads up">…</Callout>Server component
<TypeTable type={{ … }} />Server component
<Tabs items={['npm', 'pnpm']}>…</Tabs>Client component
1/4With output: "function", _markdown is a component of Markdown and JSX elements.
import { defineDocs } from 'fumadocs-mdx/config';
export const docs = defineDocs({
docs: {
postprocess: {
includeProcessedMarkdown: { output: 'function' },
},
},
});import { getMDXComponents } from '@/components/mdx';
const markdown = await page.data.getText('processed', {
components: getMDXComponents(),
});Components that never call asMarkdown(), including client components, are kept as JSX with their props. See Markdown Rendering.
Source + Edits
The other part is where the Markdown comes from.
In 16.17, it is sliced from your source instead of stringified from the tree. Every remark plugin maintains two pipelines:
- the syntax tree, transformed for rendering.
- edits to your source, for the generated Markdown.
1/5remark-heading: an ID in the tree, [#installation] in the Markdown.
The rules are simple:
- A node with a position outputs its source.
- A node inserted by plugins outputs nothing, the plugin describes its Markdown instead.
The same page, in 16.17:
## Installation [#installation]
```npm
npm i fumadocs-core
```

### Options
| Prop | Type | Description |
| ---------- | -------- | ------------------------------------- |
| `dir` | `string` | The directory of pages. |
| `baseUrl?` | `string` | The base URL of pages. Default: `'/'` |Plugins record their edits with replaceSource():
import type { Root } from 'mdast';
import type { VFile } from 'vfile';
import { visit } from 'unist-util-visit';
import { replaceSource } from 'fumadocs-core/mdx-plugins/stringifier';
export function remarkNote() {
return (tree: Root, file: VFile) => {
visit(tree, 'mdxJsxFlowElement', (node) => {
if (node.name !== 'Note') return;
replaceSource(file, node, (s) => `**Note:**\n\n${s.inner(node)}`);
// transform the node for rendering...
});
};
}embedSource()embeds content parsed from another source, like included files.- A namespace limits an edit to some outputs, like
searchfor search records.
It brings:
- Quality: Markdown stays as you wrote it, local images keep their addresses, and generated nodes leave no noise.
- Performance: no tree is serialized, Remark LLMs is up to 45x faster.
The Sätteri compiler has worked this way since @fumadocs/satteri 0.5, now the remark plugins of Fumadocs Core behave the same. See Stringification for details.
Better Search
Search records come from the same pipeline, so they are Markdown as you wrote it. The search dialog renders them with typography, and elements like <Callout /> and <TypeTable /> as components.
Tables, by Row
Option
Default
dir
'docs'
baseUrl
'/'
i18n
-
1/4A table in your page.
A table records each row, as a table of its header and that row. Type tables from auto-type-table record a row for each prop. In the search dialog, the matched rows of a table are grouped back into a table.
Highlights with CSS
Search results no longer wrap matches in <mark>, the content stays as indexed. The search dialog highlights matches with the CSS Custom Highlight API:
::highlight(fd-search) {
color: var(--color-fd-primary);
text-decoration: underline;
}Custom search UIs can use useHighlightQuery() from fumadocs-core/search/client.
Search Hooks
Each search client has its own hook, like useFetchSearch() and useAlgoliaSearch(), returning the props of <SearchDialog />:
import { useFetchSearch } from 'fumadocs-core/search/client';
const search = useFetchSearch();
<SearchDialog {...search} {...props} />;useDocsSearch() is deprecated, see Search.
Mixedbread
sync() from fumadocs-core/search/mixedbread uploads a file for each page, whose chunks are its title, headings and paragraphs. Results link to their headings, and render tables like other integrations.
See Mixedbread.
Better Performance
Median time of the remark plugins (parsing excluded) on the Fumadocs docs, concatenated into one document. Apple M5 Max, Node.js 22.
| Document | Remark LLMs | Remark Structure | Default plugins |
|---|---|---|---|
| 0.5 MB | 49 → 1.1 ms | 49 → 2.4 ms | 119 → 9.4 ms |
| 2 MB | 184 → 4.1 ms | 196 → 11.1 ms | 482 → 34.1 ms |
| 5 MB | 498 → 14.1 ms | 594 → 30.3 ms | 1,496 → 87.5 ms |
Default plugins are the remark plugins of Fumadocs MDX, with Remark LLMs enabled.
- The remark and rehype plugins of Fumadocs Core no longer slow down quadratically on large documents, like
rehypeToc()taking 17 ms instead of 239 ms on a 5 MB page. - The search dialog parses results with micromark and the HTML parser of browsers, instead of a remark & rehype pipeline.
- Twoslash analyzes code blocks about 2x faster, and truncates types longer than 500 characters in popups. Add
.twoslashto your.gitignore.
Refactored & Deprecated APIs
16.17 is a minor release. Re-sync your search indexes to pick up the new records, including Mixedbread stores synced with mxbai store sync.
The internal APIs of stringification and search are refactored, for plugins and search UIs built on them:
| Before | Now |
|---|---|
data._stringify | replaceSource() & embedSource() from fumadocs-core/mdx-plugins/stringifier |
defaultStringifier() | createStringifier() |
placeholder() | the mdxAsPlaceholder option |
stringify & allowedMdxAttributes of Remark plugins | filterElement, and replaceSource() for custom Markdown |
useSearchList() | getActive(), setActive() & subscribeActive() from useSearch() |
contentWithHighlights & renderHighlights | useHighlightQuery() |
- Remark Structure defaults
typestotableinstead oftableCell. <SearchDialogListItem />renders adiv, and itsrenderMarkdownreceivescontent.fumadocs-mdx,fumadocs-docgenandfumadocs-obsidianrequirefumadocs-core16.17.0.
Deprecated APIs keep working:
useDocsSearch(): use the hook of your search client.createContentHighlighter(): matches are highlighted with CSS.
Also in 16.17
- Fumadocs OpenAPI 12.4: auth providers for the API playground (
playground.authProviders), the OAuth redirect URI with a copy button, and fixed code samples in every language. index()andindexNode()ofllms()return a string again when it receives a loader.- The advanced search server returns up to 60 results when the request has no
limit.
Thanks for supporting Fumadocs, share your upgrade experience on GitHub :)