Fumadocs

Local Markdown

Content source for local Markdown content.

Introduction

@fumadocs/local-md is a content source for local Markdown/MDX files, it is bundleless (works fully at runtime) by design.

As compared to MDX Remote, it is more comprehensive & robust while focused solely on local files.

As compared to Fumadocs MDX, it doesn't need a type-gen or bundler to work, but build-time image optimization will be disabled.

Limitations

  • No build-time image optimization.
  • No imports/exports in MDX files, but you can pass variables & components at render phase.

Setup

Install the package:

npm install @fumadocs/local-md 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/dynamic';
import { localMd } from '@fumadocs/local-md';

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

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

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

Schema

You may pass frontmatterSchema and metaSchema to customize the validation schemas:

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

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

Usage

The recommended integration is:

  1. Create localMd({ dir }) for your content directory.
  2. Pass docs.dynamicSource() to dynamicLoader().
  3. Read the source in your route/layout and render it with Fumadocs UI.

For example, in a docs layout:

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>;
}

The returned source from docsLoader.get() is a normal content loader instance.

Hot Reload

local-md works at runtime, but during development you can connect to its dev server for file watching.

Start the dev server with local-md dev -- xxx like:

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

And connect to it:

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

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

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

This keeps the loader in sync when local Markdown or MDX files change.

JavaScript Engine

When compiling Markdown files (*.md), @fumadocs/local-md uses a virtual JavaScript engine to avoid eval() at runtime.

This allows your app to work on environments like Cloudflare Worker, while the performance will be slower than the native JavaScript JIT compiler.

Disable Revalidation

You can use staticSource() when you only need a one-time snapshot without revalidation.

It works with a normal loader() instead of only dynamicLoader():

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

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

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

Migration from Fumadocs MDX

If you're already using Fumadocs MDX for local docs content, migrating is usually straightforward.

Before

With Fumadocs MDX, a common setup looks like:

import { defineDocs, defineConfig } from 'fumadocs-mdx/config';

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

export default defineConfig();

After

With @fumadocs/local-md, you can replace that setup with:

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

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

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

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

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

Then:

  • remove source.config.ts.
  • remove framework-specific MDX integration like createMDX() in next.config.mjs.
  • replace collections/server imports with a loader created from localMd().

Finally, update references to your source object with getSource():

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

export default async function Layout({ children }: LayoutProps<'/docs'>) {
  return <DocsLayout tree={source.getPageTree()}>{children}</DocsLayout>;

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

And the type of pages is also changed:

const page = source.getPage(['...']);

// title & description are unchanged
page.data.title;

// custom frontmatter properties
page.data.full;

// getText() API
await page.data.getText('processed');

// compiled properties:
page.data.structuredData;
page.data.toc;
return (
  <div>
    <page.data.body components={{}} />
  </div>
);

How is this guide?

Last updated on

On this page