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 shikishiki is installed because it has to be externalized by the bundler.
Create a localMd instance and connect it to Fumadocs:
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:
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:
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>;
}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:
<include>../shared/note.md</include>In Markdown files, use the ::include[../shared/note.md] directive with remark-directive configured:
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.
import { localContentPlugin } from '@fumadocs/local-md/dev/vite';
export default defineConfig({
plugins: [localContentPlugin()],
});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.
{
"scripts": {
"dev": "local-md dev -- next dev"
}
}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():
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:
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()innext.config.mjs. - replace
collections/serverimports with a loader created fromlocalMd().
Finally, update references to your source object with getSource():
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
