---
title: Local Markdown
description: 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](/docs/integrations/content/mdx-remote), it is more comprehensive & robust while focused solely on local files.

As compared to [Fumadocs MDX](/docs/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:

```package-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:

```ts title="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:

```ts title="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:

```tsx title="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](/docs/headless/source-api) instance.

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

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

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

```ts title="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.

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

```ts title="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:

```ts
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 change. To wire up your own watcher instead, call `docs.invalidateFile(path)` when a file changes.

### 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()`:

```ts title="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:

```ts tab="source.config.ts"
import { defineDocs, defineConfig } from 'fumadocs-mdx/config';

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

export default defineConfig();
```

```ts tab="lib/source.ts"
import { docs } from 'collections/server';
import { loader } from 'fumadocs-core/source';

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

### After

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

```ts title="lib/source.ts"
import { dynamicLoader } from 'fumadocs-core/source/dynamic';
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()`:

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

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

  // [!code ++:2]
  const docs = await getSource();
  return <DocsLayout tree={docs.getPageTree()}>{children}</DocsLayout>;
}
```

And the type of pages is also changed:

```tsx tab="Before"
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>
);
```

```tsx tab="After"
const page = docs.getPage(['...']);

// title & description (unchanged)
page.data.title;

// custom frontmatter properties
page.data.frontmatter.full;

// there's no getText() API, use data.content instead
console.log(page.data.content);

// compiled properties:
const { structuredData, render } = await page.data.load();
const { toc, body } = await render(mdxComponents);

return <div>{body}</div>;
```

### Without RSC

If your setup doesn't support RSC, unlike Fumadocs MDX's `collections/browser` API, `local-md` leverages server payload to send AST, then render Markdown at client-side.

For example, in Tanstack Start:

```tsx title="src/routes/docs/$.tsx"
import { createFileRoute, notFound } from '@tanstack/react-router';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { createServerFn } from '@tanstack/react-start';
import { getSource } from '@/lib/source';
import { DocsBody, DocsPage } from 'fumadocs-ui/layouts/docs/page';
import { useFumadocsLoader } from 'fumadocs-core/source/client';
import { useMemo } from 'react';
import { useMDXComponents } from '@/components/mdx';
import { rendererFromSerialized } from '@fumadocs/local-md/client';

export const Route = createFileRoute('/docs/$')({
  component: Page,
  loader: async ({ params }) => {
    const slugs = params._splat?.split('/') ?? [];
    return serverLoader({ data: slugs });
  },
});

const serverLoader = createServerFn({
  method: 'GET',
})
  .validator((slugs: string[]) => slugs)
  .handler(async ({ data: slugs }) => {
    const source = await getSource();
    const page = source.getPage(slugs);
    if (!page) throw notFound();

    // compile
    const { serialize } = await page.data.load();

    return {
      frontmatter: page.data.frontmatter,
      // [!code highlight] pass it to payload
      render: serialize(),
      // ...
    };
  });

function Page() {
  const { pageTree, frontmatter, render } = useFumadocsLoader(Route.useLoaderData());
  // [!code highlight:2] render under sync mode
  const renderer = useMemo(() => rendererFromSerialized(render), [render]);
  const { body, toc } = renderer.renderSync(useMDXComponents());

  return (
    <DocsLayout tree={pageTree}>
      <DocsPage toc={toc}>
        {/* ... */}
        <DocsBody>{body}</DocsBody>
      </DocsPage>
    </DocsLayout>
  );
}
```
