Fumadocs

Astro

Setup Fumadocs on Astro with React islands.

Getting Started

Before continuing, make sure to configure:

  • Astro with @astrojs/react.
  • Tailwind CSS 4.

Fumadocs UI is a React library. On Astro, render it inside a React island and pass the current route information from your .astro page.

Installation

npm i fumadocs-core fumadocs-ui @astrojs/react @astrojs/mdx @astrojs/markdown-remark react react-dom @orama/orama takumi-js sharp

Configuration

Configure Astro, MDX, React, and Tailwind CSS:

astro.config.mjs
// @ts-check
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
import mdx from '@astrojs/mdx';
import { unified } from '@astrojs/markdown-remark';
import {
  rehypeCode,
  remarkCodeTab,
  remarkHeading,
  remarkNpm,
  remarkStructure,
} from 'fumadocs-core/mdx-plugins';

const remarkPlugins = [
  remarkHeading,
  remarkCodeTab,
  remarkNpm,
  [remarkStructure, { exportAs: 'structuredData' }],
];
const rehypePlugins = [rehypeCode];

export default defineConfig({
  markdown: {
    processor: unified({
      syntaxHighlight: false,
      remarkPlugins,
      rehypePlugins,
    }),
  },
  integrations: [
    react(),
    mdx({
      extendMarkdownConfig: true,
      syntaxHighlight: false,
    }),
  ],
  vite: {
    plugins: [tailwindcss()],
  },
});

Add the following to your Tailwind CSS file:

src/styles/global.css
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';

Content Collections

Create content collections for docs and meta files:

src/content.config.ts
import { glob } from 'astro/loaders';
import { defineCollection, z } from 'astro:content';

const docs = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './content/docs' }),
  schema: z.object({
    title: z.string(),
    description: z.string().optional(),
    icon: z.string().optional(),
  }),
});

const meta = defineCollection({
  loader: glob({ pattern: '**/*.{json,yaml}', base: './content/docs' }),
  schema: z.object({
    title: z.string().optional(),
    description: z.string().optional(),
    pages: z.array(z.string()).optional(),
    icon: z.string().optional(),
  }),
});

export const collections = {
  docs,
  meta,
};

Create a Fumadocs source from Astro content collections:

src/lib/source.ts
import type { StaticSource } from 'fumadocs-core/source';
import { loader } from 'fumadocs-core/source';
import { type CollectionEntry, getCollection } from 'astro:content';
import * as path from 'node:path';
import { structure, type StructuredData } from 'fumadocs-core/mdx-plugins';

export const source = loader({
  source: await createMySource(),
  baseUrl: '/',
});

export function getStructuredData(entry: CollectionEntry<'docs'>): StructuredData {
  return structure(entry.body);
}

export function getPageImage(slugs: string[]) {
  const segments = [...slugs, 'image.webp'];

  return {
    segments,
    url: `/og/docs/${segments.join('/')}`,
  };
}

async function createMySource() {
  const out: StaticSource<{
    metaData: CollectionEntry<'meta'>['data'];
    pageData: CollectionEntry<'docs'>['data'] & {
      _raw: CollectionEntry<'docs'>;
    };
  }> = {
    files: [],
  };

  for (const page of await getCollection('docs')) {
    const virtualPath = path.relative('content/docs', page.filePath!);

    out.files.push({
      type: 'page',
      path: virtualPath,
      data: {
        ...page.data,
        _raw: page,
      },
    });
  }

  for (const meta of await getCollection('meta')) {
    const virtualPath = path.relative('content/docs', meta.filePath!);

    out.files.push({
      type: 'meta',
      path: virtualPath,
      data: meta.data,
    });
  }

  return out;
}

Create Pages

Create the shared Astro document layout:

---
import { ClientRouter } from 'astro:transitions';
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <ClientRouter />
  </head>
  <body
    class="dark flex flex-col min-h-screen"
    transition:persist
    transition:animate="none"
    transition:name="page-layout"
  >
    <slot />
  </body>
</html>

Create the React island that renders Fumadocs UI:

import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { DocsPage, type DocsPageProps } from 'fumadocs-ui/layouts/docs/page';
import type { Root } from 'fumadocs-core/page-tree';
import type { ReactNode } from 'react';
import { navigate } from 'astro:transitions/client';
import { RootProvider } from 'fumadocs-ui/provider/astro';
import type { AstroProviderProps } from 'fumadocs-core/framework/astro';
import SearchDialog from './search';

export function Docs({
  tree,
  children,
  pathname,
  params,
  page,
}: {
  tree: Root;
  children: ReactNode;
  pathname: string;
  params: AstroProviderProps['params'];
  page?: DocsPageProps;
}) {
  return (
    <RootProvider
      pathname={pathname}
      params={params}
      navigate={navigate}
      theme={{ enabled: false }}
      search={{ SearchDialog }}
    >
      <DocsLayout
        tree={tree}
        themeSwitch={{
          enabled: false,
        }}
        nav={{
          title: 'Fumadocs on Astro',
        }}
      >
        <DocsPage {...page}>{children}</DocsPage>
      </DocsLayout>
    </RootProvider>
  );
}

Create a search dialog:

'use client';
import {
  SearchDialog,
  SearchDialogClose,
  SearchDialogContent,
  SearchDialogHeader,
  SearchDialogIcon,
  SearchDialogInput,
  SearchDialogList,
  SearchDialogOverlay,
  type SharedProps,
} from 'fumadocs-ui/components/dialog/search';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { oramaStaticClient } from 'fumadocs-core/search/client/orama-static';
import { create } from '@orama/orama';
import { useI18n } from 'fumadocs-ui/contexts/i18n';

const oramaLanguageByLocale: Record<string, string | undefined> = {
  ar: 'arabic',
  hy: 'armenian',
  bg: 'bulgarian',
  cs: 'czech',
  da: 'danish',
  nl: 'dutch',
  en: 'english',
  fi: 'finnish',
  fr: 'french',
  de: 'german',
  el: 'greek',
  hu: 'hungarian',
  // Orama 3 names its Hindi stemmer `indian`.
  hi: 'indian',
  id: 'indonesian',
  ga: 'irish',
  it: 'italian',
  lt: 'lithuanian',
  ne: 'nepali',
  no: 'norwegian',
  pt: 'portuguese',
  ro: 'romanian',
  ru: 'russian',
  sr: 'serbian',
  sl: 'slovenian',
  es: 'spanish',
  sv: 'swedish',
  ta: 'tamil',
  tr: 'turkish',
  uk: 'ukrainian',
  sa: 'sanskrit',
};

function getOramaLanguage(locale?: string) {
  const code = locale?.toLowerCase().split(/[-_]/)[0];

  return code ? (oramaLanguageByLocale[code] ?? 'english') : 'english';
}

function initOrama(locale?: string) {
  return create({
    schema: { _: 'string' },
    // https://docs.orama.com/docs/orama-js/supported-languages
    language: getOramaLanguage(locale),
  });
}

export default function DefaultSearchDialog(props: SharedProps) {
  const { locale } = useI18n(); // (optional) for i18n
  const { search, setSearch, query } = useDocsSearch({
    client: oramaStaticClient({
      initOrama,
      locale,
    }),
  });

  return (
    <SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
      <SearchDialogOverlay />
      <SearchDialogContent>
        <SearchDialogHeader>
          <SearchDialogIcon />
          <SearchDialogInput />
          <SearchDialogClose />
        </SearchDialogHeader>
        <SearchDialogList items={query.data !== 'empty' ? query.data : null} />
      </SearchDialogContent>
    </SearchDialog>
  );
}

Create the docs route, search endpoint, and metadata image endpoint:

---
import { render, type CollectionEntry } from "astro:content";
import { Docs } from "@/components/docs";
import defaultMdxComponents from "fumadocs-ui/mdx";
import "@/styles/global.css";
import { getPageImage, source } from "@/lib/source";
import Layout from "@/components/layout.astro";
import type { TOCItemType } from "fumadocs-core/toc";

interface Props {
  page: CollectionEntry<"docs">;
}

export async function getStaticPaths() {
  return source.getPages().map((page) => ({
    params: { slug: page.slugs.length > 0 ? page.slugs.join("/") : undefined },
    props: { page: page.data._raw },
  }));
}

const { page } = Astro.props;
const { Content, headings } = await render(page);
const slugs = typeof Astro.params.slug === "string" ? Astro.params.slug.split("/") : [];
const image = getPageImage(slugs).url;

const toc: TOCItemType[] = headings.map((heading) => ({
  depth: heading.depth,
  title: heading.text,
  url: "#" + heading.slug,
}));
---

<Layout>
  <title>{page.data.title}</title>
  <meta name="title" content={page.data.title} />
  <meta name="description" content={page.data.description} />
  <meta property="og:image" content={image} />
  <Docs
    tree={source.getPageTree()}
    pathname={Astro.url.pathname}
    params={Astro.params}
    page={{ toc }}
    client:load
  >
    <h1 class="font-semibold text-3xl">{page.data.title}</h1>
    <p class="text-lg text-fd-muted-foreground mb-8">{page.data.description}</p>
    <div class="prose flex-1">
      <Content components={{ ...defaultMdxComponents }} />
    </div>
  </Docs>
</Layout>

Done

You can start writing documents at content/docs:

content/docs/index.mdx
---
title: Hello World
description: Your first document
---

I love Fumadocs.

How is this guide?

On this page