Fumadocs

Local Markdown (Sätteri)

Content source for local Markdown content, compiled with Sätteri.

Introduction

@fumadocs/satteri/local-md is a content source for local Markdown/MDX files, compiled with Sätteri instead of the unified/remark pipeline.

It has the same API shape as @fumadocs/local-md and is also bundleless, so it works fully at runtime with no type-gen or bundler step.

Reach for it when you want Sätteri's compiler and its plugin model, and to reuse the Fumadocs Sätteri plugins and preset.

Differences

  • Compilation always produces JavaScript, so there is no virtual JavaScript engine option. Rendering uses the native engine.
  • Plugins are Sätteri plugins, configured through satteriOptions, not remark/rehype plugins.

Do not put untrusted Markdown/MDX files in dir, they can run as JavaScript or reference server-side files.

Setup

npm install @fumadocs/satteri shiki

shiki is installed because it has to be externalized by the bundler.

Create a localMd instance and connect it to Fumadocs:

lib/source.ts
import { dynamicLoader } from 'fumadocs-core/source';
import { localMd } from '@fumadocs/satteri/local-md';

const docs = localMd({
  dir: 'content/docs',
});

const docsLoader = dynamicLoader(docs.dynamicSource(), {
  baseUrl: '/docs',
});

export async function getSource() {
  return docsLoader.get();
}

Schema

Pass frontmatterSchema and metaSchema to customize the validation schemas:

lib/source.ts
import { localMd } from '@fumadocs/satteri/local-md';
import { pageSchema, metaSchema } from 'fumadocs-core/source/schema';

const docs = localMd({
  dir: 'content/docs',
  frontmatterSchema: pageSchema.extend({
    // ...
  }),
  metaSchema: metaSchema.extend({
    // ...
  }),
});

Compiler Options

satteriOptions is passed through the Fumadocs Sätteri preset, so plugins and their options are configured the same way as in Fumadocs MDX:

lib/source.ts
const docs = localMd({
  dir: 'content/docs',
  satteriOptions: {
    rehypeCodeOptions: {
      themes: { light: 'github-light', dark: 'github-dark' },
    },
    mdastPlugins: [myPlugin()],
  },
});

Pass a function to build the options lazily.

Usage

Read the source in your route or layout and render it with Fumadocs UI, exactly as with @fumadocs/local-md:

app/docs/layout.tsx
import { getSource } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';

export default async function Layout({ children }: LayoutProps<'/docs'>) {
  const docs = await getSource();

  return <DocsLayout tree={docs.getPageTree()}>{children}</DocsLayout>;
}

Each page exposes load(), which compiles it once and returns a renderer:

const page = source.getPage(params.slug);
const { body, toc } = await (await page.data.load()).render();

The renderer also exposes structuredData, and exports for anything the document or its plugins exported.

Client Rendering

serialize() returns a JSON-safe payload, so a server can hand a compiled page to the client:

app/page.tsx
const serialized = (await page.data.load()).serialize();
components/content.tsx
'use client';
import { rendererFromSerialized } from '@fumadocs/satteri/local-md/client';

export function Content({ serialized }) {
  return rendererFromSerialized(serialized).renderSync().body;
}

Include

<include> and the ::include directive are enabled by default:

content/docs/page.mdx
<include>../shared/note.md</include>

Hot Reload

The source works at runtime, so nothing watches your files by default.

On Vite, use its own watcher:

vite.config.ts
import { localContentPlugin } from '@fumadocs/satteri/local-md/dev/vite';

export default defineConfig({
  plugins: [localContentPlugin()],
});
lib/source.ts
import { watchWithVite } from '@fumadocs/satteri/local-md/dev/vite';

if (import.meta.env.DEV) {
  watchWithVite(docs);
}

Otherwise run the dev server from @fumadocs/local-md alongside your app, and connect to it:

package.json
{
  "scripts": {
    "dev": "local-md dev -- next dev"
  }
}
lib/source.ts
import { watchWithDevServer } from '@fumadocs/satteri/local-md/dev/ws';

// change it if you use a different framework (e.g. import.meta.env.DEV)
if (process.env.NODE_ENV === 'development') {
  void watchWithDevServer(docs);
}

Included files are watched too. To wire up your own watcher instead, watch docs.dir and the files passed to docs.onDependency(), then call docs.invalidateFile(path) when one changes.

Disable Revalidation

Use staticSource() when you only need a one-time snapshot, so a normal loader() works:

lib/source.ts
import { loader } from 'fumadocs-core/source';

export const source = loader(await docs.staticSource(), {
  baseUrl: '/docs',
});

How is this guide?

Last updated on