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.

Serialize it in the loader, and render it on the client:

app/routes/docs.tsx
import type { Route } from './+types/docs';
import { useMemo } from 'react';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { DocsBody, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { useFumadocsLoader } from 'fumadocs-core/source/client';
import { rendererFromSerialized } from '@fumadocs/local-md/client';
import { getSource } from '@/lib/source';
import { useMDXComponents } from '@/components/mdx';

export async function loader({ params }: Route.LoaderArgs) {
  const source = await getSource();
  const page = source.getPage(params['*'].split('/').filter((v) => v.length > 0));
  if (!page) throw new Response('Not found', { status: 404 });

  const { serialize } = await page.data.load();
  return {
    title: page.data.title,
    // the syntax tree, rendered on the client
    render: serialize(),
    pageTree: await source.serializePageTree(source.getPageTree()),
  };
}

export default function Page({ loaderData }: Route.ComponentProps) {
  const { title, render, pageTree } = useFumadocsLoader(loaderData);
  const renderer = useMemo(() => rendererFromSerialized(render), [render]);
  const { body, toc } = renderer.renderSync(useMDXComponents());

  return (
    <DocsLayout tree={pageTree}>
      <DocsPage toc={toc}>
        <DocsTitle>{title}</DocsTitle>
        <DocsBody>{body}</DocsBody>
      </DocsPage>
    </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, the Vite plugin invalidates the source through Vite's file watcher, with no extra process or port. Add the plugin to your config and connect the source:

import { defineConfig } from 'vite';
import { localContentPlugin } from '@fumadocs/local-md/dev/vite';

export default defineConfig({
  plugins: [localContentPlugin()],
});

The plugin adds your content directory to Vite's watcher, since content files sit outside the module graph. Because no module is invalidated, the plugin reloads the browser instead. Pass localContentPlugin({ reload: false }) to handle reloads yourself.

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

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

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

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

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 fumadocsMdx() from your Vite config.
  • set up hot reload.

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

import { getSource } from '@/lib/source';

const source = await getSource();
const page = source.getPage(slugs);

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