Skip to Content
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 by design, so it works fully at runtime.

Compared to MDX Remote, it handles more of the pipeline (frontmatter, meta files, search indexing) while focused on local files.

Compared to Fumadocs MDX, it doesn't need a type-gen or bundler to work, but build-time image optimization is disabled.

Limitations

  • No build-time image optimization.
  • No imports/exports in MDX files, but you can pass components when rendering.

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

getSource() returns a normal content loader. Each page has page.data.load(), which compiles the page and returns a renderer.

Render it in your page:

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

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

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

Include

<include> is enabled by default:

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

In Markdown files, use the ::include[../shared/note.md] directive with remark-directive configured:

lib/source.ts
import remarkDirective from 'remark-directive';

const docs = localMd({
  dir: 'content/docs',
  mdOptions: { remarkPlugins: [remarkDirective] },
});

Hot Reload

@fumadocs/local-md reads files at runtime, so nothing watches them by default.

During development, run the bundled dev server alongside your app. The dev server watches files in a single process and broadcasts changes over a websocket, so it works when Next.js runs multiple workers.

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

Connect the source to the dev server:

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

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

The command publishes its URL through an environment variable, so no configuration is needed. Pass one explicitly if you run the server yourself, and use -p to change the port:

void watchWithDevServer(docs, { url: 'ws://127.0.0.1:8000/_fumadocs_local_md' });

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/local-md/dev/react-client';

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

The source reloads when Markdown or MDX files change, including files they include. 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.

JavaScript Engine

MDX files (*.mdx) are executed with new Function, so they do not work where eval() is disallowed (for example, Cloudflare Workers).

Markdown files (*.md) render from the syntax tree without eval(). A built-in virtual JavaScript engine evaluates JSX generated by plugins. Replace it with rendererOptions.executor.

Do not put untrusted MDX files in dir, they run as JavaScript in your app.

Disable Revalidation

staticSource() returns a one-time snapshot with no revalidation. It works with loader() instead of 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

To migrate, replace the Fumadocs MDX setup with the @fumadocs/local-md one.

Before

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

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

export default defineConfig();

After

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

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

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

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

Then:

  • remove source.config.ts.
  • remove createMDX() from next.config.mjs.
  • replace collections/server imports with the loader created from localMd().
  • set up hot reload.

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

The page data API also changes:

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

// title & description
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