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';
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, render pages with it:

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 source = await getSource();

  return <DocsLayout tree={source.getPageTree()}>{children}</DocsLayout>;
}
app/docs/[[...slug]]/page.tsx
import { notFound } from 'next/navigation';
import { DocsBody, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { getSource } from '@/lib/source';
import { getMDXComponents } from '@/components/mdx';

export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
  const params = await props.params;
  const source = await getSource();
  const page = source.getPage(params.slug);
  if (!page) notFound();

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

  return (
    <DocsPage toc={toc}>
      <DocsTitle>{page.data.title}</DocsTitle>
      <DocsBody>{body}</DocsBody>
    </DocsPage>
  );
}

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

local-md works at runtime, so nothing watches your files by default. During development, pick one of the two watchers.

Vite

Vite already watches your project, so its watcher can invalidate the source in the same process. No extra process or port.

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

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

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

The plugin adds your content directory to Vite's watcher, since it usually sits outside the module graph. Content is read at runtime rather than imported, so there is no module to invalidate and the browser is reloaded instead. Pass localContentPlugin({ reload: false }) to handle that yourself.

Dev Server

For everything else, run the bundled dev server alongside your app. The watcher lives in one process and broadcasts over a websocket, so it works when your framework runs several workers.

package.json
{
  "scripts": {
    "dev": "local-md dev -- next dev"
  }
}
lib/source.ts
import { watchWithDevServer } from '@fumadocs/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);
}

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

Both keep the loader in sync when local Markdown or MDX files, or the files they include, change. 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.

JavaScript Engine

MDX files (*.mdx) run with new Function, they don't work where eval() is disallowed, like Cloudflare Workers.

Markdown files (*.md) can render from the syntax tree without eval(), a virtual JavaScript engine evaluates the 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

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';
import { watchWithDevServer } from '@fumadocs/local-md/dev/ws';
import { localMd } from '@fumadocs/local-md';

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

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

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