A Reference Object whose target eventually refers back to it overflowed the stack. The schema is now marked while its target resolves, so a cycle resolves to the sibling keywords instead of recursing forever.
Read structured data from page.data.structuredData()
Search indexing no longer falls back to (await page.data.load()).structuredData. Runtime content sources expose structuredData() on page data instead, sharing the compile with load():
The renderer returned by load() still carries structuredData, existing code keeps working.
Read structured data from page.data.structuredData()
Search indexing no longer falls back to (await page.data.load()).structuredData. Runtime content sources expose structuredData() on page data instead, sharing the compile with load():
The renderer returned by load() still carries structuredData, existing code keeps working.
Redesign source API
Content sources can hook into the static loader they are attached to, and dynamic sources can opt out of the loader's in-memory file cache.
configureStatic runs when a source is attached to loader(), and again whenever dynamicLoader() builds a new static loader:
export function createMySource(): DynamicSource { return { cache: 'custom', async files() { return loadFiles(); }, configureStatic({ loader, source }) { // `loader` is the created static loader // `source` is the record key when using named sources }, configure(loader, { source }) { loader.invalidate(); }, };}
cache: 'memory' (default): files() is called once until invalidate().
cache: 'custom': the source caches itself. dynamicLoader() re-runs files() on get() and rebuilds only when the file list is shallowly different (by identity).
Integrations
GraphQL cross-links are generated from the attached loader instead of a baseUrl option on staticSource(). Local, OpenAPI, and AsyncAPI dynamicSource() use cache: 'custom' and reuse generated files by identity until invalidate().
Sanity now uses cache: 'custom' when given a sanityFetch from next-sanity/live, calling invalidate() in draft mode is no longer needed.
Scope lastModified git log to the content directory
git log is scoped to the collection's content directory instead of buffering the repository's entire history in every worker.
Fix Vite dev server crash on declaration-only dependencies
The injected Vite config no longer pre-bundles packages without runtime JavaScript, such as @types/mdx. Pre-bundling them made esbuild parse .d.ts files and fail on imports that only exist in type space, crashing the dev server.
Encode import.meta.glob query values
The Vite codegen passed the query to import.meta.glob as an object, letting the bundler serialize it. Rolldown inlines the values as-is, so a macro id such as src/lib/source.ts#docs left an unescaped / in the content file's module id and relative imports from that module (e.g. images from ) failed to resolve, since the importer's directory is derived from the raw id.
The query is now serialized (and percent-encoded) by Fumadocs itself, matching what the Node.js codegen already did.
Redesign source API
Content sources can hook into the static loader they are attached to, and dynamic sources can opt out of the loader's in-memory file cache.
configureStatic runs when a source is attached to loader(), and again whenever dynamicLoader() builds a new static loader:
export function createMySource(): DynamicSource { return { cache: 'custom', async files() { return loadFiles(); }, configureStatic({ loader, source }) { // `loader` is the created static loader // `source` is the record key when using named sources }, configure(loader, { source }) { loader.invalidate(); }, };}
cache: 'memory' (default): files() is called once until invalidate().
cache: 'custom': the source caches itself. dynamicLoader() re-runs files() on get() and rebuilds only when the file list is shallowly different (by identity).
Integrations
GraphQL cross-links are generated from the attached loader instead of a baseUrl option on staticSource(). Local, OpenAPI, and AsyncAPI dynamicSource() use cache: 'custom' and reuse generated files by identity until invalidate().
Sanity now uses cache: 'custom' when given a sanityFetch from next-sanity/live, calling invalidate() in draft mode is no longer needed.
Remember verified file URLs in the Notion file handler
Every asset request cost between 2 and 34 Notion API calls, so a page with a handful of images tripped the rate limit with two concurrent visitors. The handler now remembers a verified signed URL per block until shortly before Notion's expiry_time.
Do not cache rejected promises
The dynamic loader's files(), Notion's page load(), createFromSource's index build, and Shiki factory init retry on the next call after a transient failure, instead of returning the same rejection forever.
Redesign source API
Content sources can hook into the static loader they are attached to, and dynamic sources can opt out of the loader's in-memory file cache.
configureStatic runs when a source is attached to loader(), and again whenever dynamicLoader() builds a new static loader:
export function createMySource(): DynamicSource { return { cache: 'custom', async files() { return loadFiles(); }, configureStatic({ loader, source }) { // `loader` is the created static loader // `source` is the record key when using named sources }, configure(loader, { source }) { loader.invalidate(); }, };}
cache: 'memory' (default): files() is called once until invalidate().
cache: 'custom': the source caches itself. dynamicLoader() re-runs files() on get() and rebuilds only when the file list is shallowly different (by identity).
Integrations
GraphQL cross-links are generated from the attached loader instead of a baseUrl option on staticSource(). Local, OpenAPI, and AsyncAPI dynamicSource() use cache: 'custom' and reuse generated files by identity until invalidate().
Sanity now uses cache: 'custom' when given a sanityFetch from next-sanity/live, calling invalidate() in draft mode is no longer needed.
Read files in bounded chunks during a cold scan
getFiles() awaits each chunk before starting the next, instead of starting the entire tree concurrently.
@fumadocs/base-ui@16.15.0
fumadocs-core@16.15.0
fumadocs-ui@16.15.0
Redesign source API
Content sources can hook into the static loader they are attached to, and dynamic sources can opt out of the loader's in-memory file cache.
configureStatic runs when a source is attached to loader(), and again whenever dynamicLoader() builds a new static loader:
export function createMySource(): DynamicSource { return { cache: 'custom', async files() { return loadFiles(); }, configureStatic({ loader, source }) { // `loader` is the created static loader // `source` is the record key when using named sources }, configure(loader, { source }) { loader.invalidate(); }, };}
cache: 'memory' (default): files() is called once until invalidate().
cache: 'custom': the source caches itself. dynamicLoader() re-runs files() on get() and rebuilds only when the file list is shallowly different (by identity).
Integrations
GraphQL cross-links are generated from the attached loader instead of a baseUrl option on staticSource(). Local, OpenAPI, and AsyncAPI dynamicSource() use cache: 'custom' and reuse generated files by identity until invalidate().
Sanity now uses cache: 'custom' when given a sanityFetch from next-sanity/live, calling invalidate() in draft mode is no longer needed.
Return heading and text results from the Algolia client
algoliaClient grouped hits into page, heading and text results, then dropped everything except pages. All three are now returned with highlighting, matching the other search clients.
Fix locale-only pages leaking into other locales
i18n storages no longer share folder arrays with the fallback locale. Locale-only pages previously appeared in every locale's page tree as duplicate nodes.
Index pages by URL
getPageByHref resolves absolute URLs through an index instead of scanning all pages on every call.
Do not cache rejected promises
The dynamic loader's files(), Notion's page load(), createFromSource's index build, and Shiki factory init retry on the next call after a transient failure, instead of returning the same rejection forever.
Highlight only the search results within limit
limit bounds the returned hits, so search now stops at that many results instead of highlighting every matched page and slicing afterwards.
Redesign source API
Content sources can hook into the static loader they are attached to, and dynamic sources can opt out of the loader's in-memory file cache.
configureStatic runs when a source is attached to loader(), and again whenever dynamicLoader() builds a new static loader:
export function createMySource(): DynamicSource { return { cache: 'custom', async files() { return loadFiles(); }, configureStatic({ loader, source }) { // `loader` is the created static loader // `source` is the record key when using named sources }, configure(loader, { source }) { loader.invalidate(); }, };}
cache: 'memory' (default): files() is called once until invalidate().
cache: 'custom': the source caches itself. dynamicLoader() re-runs files() on get() and rebuilds only when the file list is shallowly different (by identity).
Integrations
GraphQL cross-links are generated from the attached loader instead of a baseUrl option on staticSource(). Local, OpenAPI, and AsyncAPI dynamicSource() use cache: 'custom' and reuse generated files by identity until invalidate().
Sanity now uses cache: 'custom' when given a sanityFetch from next-sanity/live, calling invalidate() in draft mode is no longer needed.
Redesign source API
Content sources can hook into the static loader they are attached to, and dynamic sources can opt out of the loader's in-memory file cache.
configureStatic runs when a source is attached to loader(), and again whenever dynamicLoader() builds a new static loader:
export function createMySource(): DynamicSource { return { cache: 'custom', async files() { return loadFiles(); }, configureStatic({ loader, source }) { // `loader` is the created static loader // `source` is the record key when using named sources }, configure(loader, { source }) { loader.invalidate(); }, };}
cache: 'memory' (default): files() is called once until invalidate().
cache: 'custom': the source caches itself. dynamicLoader() re-runs files() on get() and rebuilds only when the file list is shallowly different (by identity).
Integrations
GraphQL cross-links are generated from the attached loader instead of a baseUrl option on staticSource(). Local, OpenAPI, and AsyncAPI dynamicSource() use cache: 'custom' and reuse generated files by identity until invalidate().
Sanity now uses cache: 'custom' when given a sanityFetch from next-sanity/live, calling invalidate() in draft mode is no longer needed.
Cache the document proxy in toStaticData
Matches fumadocs-openapi: the magic proxy is created once per document instead of once per generated page.
Sätteri 0.10
@fumadocs/satteri now requires satteri ^0.10.3, and the plugins were rewritten on its new capabilities:
Exports (frontmatter, toc, structuredData, …) are emitted by an after document hook instead of an anchor marker appended to the source, so plugins no longer see (or need to skip) the anchor node.
remark-steps, remark-admonition and remark-code-tab still detect their targets through node visitors (so documents without the construct cost nothing), but process each parent exactly once in an after hook, replacing the per-visit dedup workarounds.
remark-llms stringifies the document root from a before hook instead of subscribing to 19 node types to find it.
Markdown documents compile through Sätteri's own markdownToJs; the hand-assembled pipeline is gone. Raw HTML in .md files is still dropped, matching the previous behavior.
rehype-katex parses KaTeX output with Sätteri's htmlToHast, dropping the hast-util-from-html dependency.
No plugin reads node.position, so Sätteri now skips source-position tracking entirely (~15% faster parse).
Breaking:ExtraPluginHooks.beforeToJs was removed. Seed ctx.data from a Sätteri before hook on the plugin definition instead — it also receives the document root:
@fumadocs/satteri now requires satteri ^0.10.3, and the plugins were rewritten on its new capabilities:
Exports (frontmatter, toc, structuredData, …) are emitted by an after document hook instead of an anchor marker appended to the source, so plugins no longer see (or need to skip) the anchor node.
remark-steps, remark-admonition and remark-code-tab still detect their targets through node visitors (so documents without the construct cost nothing), but process each parent exactly once in an after hook, replacing the per-visit dedup workarounds.
remark-llms stringifies the document root from a before hook instead of subscribing to 19 node types to find it.
Markdown documents compile through Sätteri's own markdownToJs; the hand-assembled pipeline is gone. Raw HTML in .md files is still dropped, matching the previous behavior.
rehype-katex parses KaTeX output with Sätteri's htmlToHast, dropping the hast-util-from-html dependency.
No plugin reads node.position, so Sätteri now skips source-position tracking entirely (~15% faster parse).
Breaking:ExtraPluginHooks.beforeToJs was removed. Seed ctx.data from a Sätteri before hook on the plugin definition instead — it also receives the document root:
Add a main landmark to docs, notebook and flux pages
The page container slot now wraps <article id="nd-page"> in a <main class="contents"> in the docs, notebook and flux layouts. All props, id="nd-page" and the layout classes stay on the <article>, so existing selectors and refs keep working.
Loader: next parameter for custom slugs function
The slugs option now receives a next function as its second argument, which generates the default slugs from the file path. This lets custom slug functions build on the default generation instead of reimplementing it:
loader({ slugs(file, next) { if (file.path.startsWith('blog/')) return ['blog', ...next()]; // return `undefined` to generate default slugs },});
Behavior change: conflicting cases like dir/index.mdx vs dir.mdx are now resolved for custom slugs functions as well. Index files are always processed after other pages, and receive an index suffix when their slugs (custom or default) collide with an existing page — previously, custom slugs functions that produced such collisions threw a Duplicated slugs error.
New package: @fumadocs/local-html
A content source for local HTML files. It integrates externally-produced HTML pages (exported decks, reports, agent-generated deliverables) into a Fumadocs site, adapting them to the docs theme: content is scoped to <main>/<article>, page chrome and scripts/styles are dropped, class/style attributes are removed so prose styling takes over, and headings get generated ids for TOC and search indexing.
Code blocks are highlighted with Shiki, configured through rehypeCodeOptions like @fumadocs/local-md (false to skip it). The language comes from the block's language-* class, which now survives adaptStyles.
Hot reload works like @fumadocs/local-md: @fumadocs/local-html/dev/vite for Vite, or the bundled local-html dev server with @fumadocs/local-html/dev/ws for everything else.
@fumadocs/base-ui@16.14.4
fumadocs-core@16.14.4
fumadocs-ui@16.14.4
Introduce @fumari/image-size, replacing image-size in remarkImage
import { probe, imageSize } from '@fumari/image-size';await probe('./public/banner.png'); // { width: 1200, height: 630, type: 'png', mime: 'image/png' }await probe('https://example.com/banner.png', { timeout: 5000 });imageSize(bytes); // the same result, or `null`
remarkImage now uses it in both fumadocs-core and @fumadocs/satteri. Remote images are no longer downloaded in full just to be measured, and redirects are followed. Sizes are always in pixels, so an SVG sized in em or pt is converted instead of being skipped. Remote requests also time out after 30 seconds by default.
One behaviour difference worth knowing: the supported formats are avif/heic/heif, bmp, gif, ico, jpeg, png, psd, svg, tiff and webp. Sizes for jxl, tga, pnm, dds, icns, cur, ktx and jp2 can no longer be resolved and go through onError instead.
Sequential scanning stops after 512 KB, but that never loses an image: the one format that stores its dimensions past that point — TIFF with a trailing IFD — is resolved by following the header's pointer with a targeted read, using an HTTP Range request for remote files (and skipping through the body when the server ignores ranges).
Introduce @fumari/image-size, replacing image-size in remarkImage
import { probe, imageSize } from '@fumari/image-size';await probe('./public/banner.png'); // { width: 1200, height: 630, type: 'png', mime: 'image/png' }await probe('https://example.com/banner.png', { timeout: 5000 });imageSize(bytes); // the same result, or `null`
remarkImage now uses it in both fumadocs-core and @fumadocs/satteri. Remote images are no longer downloaded in full just to be measured, and redirects are followed. Sizes are always in pixels, so an SVG sized in em or pt is converted instead of being skipped. Remote requests also time out after 30 seconds by default.
One behaviour difference worth knowing: the supported formats are avif/heic/heif, bmp, gif, ico, jpeg, png, psd, svg, tiff and webp. Sizes for jxl, tga, pnm, dds, icns, cur, ktx and jp2 can no longer be resolved and go through onError instead.
Sequential scanning stops after 512 KB, but that never loses an image: the one format that stores its dimensions past that point — TIFF with a trailing IFD — is resolved by following the header's pointer with a targeted read, using an HTTP Range request for remote files (and skipping through the body when the server ignores ranges).
Introduce @fumari/image-size, replacing image-size in remarkImage
import { probe, imageSize } from '@fumari/image-size';await probe('./public/banner.png'); // { width: 1200, height: 630, type: 'png', mime: 'image/png' }await probe('https://example.com/banner.png', { timeout: 5000 });imageSize(bytes); // the same result, or `null`
remarkImage now uses it in both fumadocs-core and @fumadocs/satteri. Remote images are no longer downloaded in full just to be measured, and redirects are followed. Sizes are always in pixels, so an SVG sized in em or pt is converted instead of being skipped. Remote requests also time out after 30 seconds by default.
One behaviour difference worth knowing: the supported formats are avif/heic/heif, bmp, gif, ico, jpeg, png, psd, svg, tiff and webp. Sizes for jxl, tga, pnm, dds, icns, cur, ktx and jp2 can no longer be resolved and go through onError instead.
Sequential scanning stops after 512 KB, but that never loses an image: the one format that stores its dimensions past that point — TIFF with a trailing IFD — is resolved by following the header's pointer with a targeted read, using an HTTP Range request for remote files (and skipping through the body when the server ignores ranges).