Skip to Content
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 as @fumadocs/local-md and is also bundleless, so it works at runtime with no type-gen or bundler step.

Use it when you want the Sätteri compiler, its plugin model, and the Fumadocs Sätteri 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

getSource() returns a content loader. Render pages the same way as @fumadocs/local-md, importing from @fumadocs/satteri/local-md instead.

Each page has load(), which compiles the page and returns a renderer:

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

The renderer also has structuredData and exports for values the document or its plugins exported.

Include

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

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

Hot Reload

The source reads files at runtime, so nothing watches them by default.

During development, run the dev server of @fumadocs/local-md alongside your app:

package.json
{
  "scripts": {
    "dev": "local-md dev -- next dev"
  }
}

Connect the source to it:

lib/source.ts
import { watchWithDevServer } from '@fumadocs/satteri/local-md/dev/ws';

if (process.env.NODE_ENV === 'development') {
  void watchWithDevServer(docs);
}

This invalidates the source on the server, while Next.js keeps the rendered page in the browser. Render <DevClient /> in your root layout so the page refreshes too:

app/layout.tsx
import { DevClient } from '@fumadocs/satteri/local-md/dev/react-client';

export function RootLayout({ children }) {
  return (
    <RootProvider>
      <DevClient />
      {children}
    </RootProvider>
  );
}

Included files are watched too. To wire up your own watcher, watch docs.dir and the files passed to docs.onDependency(), then call docs.invalidateFile(path) when a file 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