Migrate Docs to Fumadocs

20 Jan 2026 Anshul Raj Verma

Blog Icon: mdi:wrench

Thoughts on migrating my docs to Fumadocs.

Back

I’ve been planning to migrate my docs from Mkdocs (with Material Theme) to other JavaScript based framework. At first I discovered Mintlify but later found that it is not an OSS, so I had to dump it. Later from openalternative.co, I found out about Fumadocs and its fabulous, perfect. This is what I need.

So I did some digging and it is complex to understand for some new eyes. I’ve tried all templates Next.js, React Router, and Tanstack Start. I found the Tanstack Start template easy by File structure and Syntax so I stick with it.

My Requirements for the Docs

  1. Supports MDX.
  2. A top navigation bar. It will be better if it also shows icon.
  3. Sidebar navigation with icon (if specified).
  4. Autogenerated Social cards (only if specified, default otherwise).
  5. Supports UI components paradigm.

Previous Migration - diary/v1.0.0

Previously, I’ve migrated my diary from Obsidian to MkDocs and it went well. I’ve stick with it until, its time to try something new, something better.

Fumadocs Learning

I have chosen the Tanstack Start template for my case due to its simplicity.

File Structure

./
├── content/  # Contains all the .mdx files
├── data/  # Put extra data in structured format to load into app easily
   └── info.yaml
├── public/  # Contains important public assets
   └── favicon.ico
├── src/  # Main source directory
   ├── components/  # Contains UI components
   └── not-found.tsx  # For 404 pages
   ├── lib/
   ├── layout.shared.tsx
   └── source.ts
   ├── routes/  # Contains route specific files
   ├── api/
   └── search.ts
   ├── docs/  # Related to /docs route
   └── $.tsx  # Define layout for "/docs/$" pages
   ├── __root.tsx
   └── index.tsx  # Define layout for "/" route
   ├── types/  # Define custom types for TypeScript
   └── yaml.d.ts  # Just to load YAML files from /data directory
   ├── styles/  # CSS files
   └── app.css
   ├── routeTree.gen.ts  # Autogenerated by Fumadocs
   └── router.tsx
├── README.md
├── biome.json  # Linter and Formatter
├── bun.lock
├── cli.json  # Specify the Base-UI components
├── package.json
├── source.config.ts  # Define other docs collections i.e. /blog, /projects
├── tsconfig.json
└── vite.config.ts

Define New Collections

You can define new collections other than /docs like /blog or /project. You may read the Fumadocs blog for better understanding.

  1. Define blogPosts collections in source.config.ts file:

    import { defineCollections, frontmatterSchema } from "fumadocs-mdx/config";
    import { z } from "zod";
     
    export const blogPosts = defineCollections({
      type: "doc",
      dir: "content/blog",
      schema: frontmatterSchema.extend({
        author: z.enum(["Anshul Raj Verma"] as const),
        date: z.date(),
        description: z.string(),
        icon: z.string(),
      }),
    });
  2. Parse the output collection in lib/source.ts:

    import { blogPosts } from "fumadocs-mdx:collections/server";
    import { loader } from "fumadocs-core/source";
    import { lucideIconsPlugin } from "fumadocs-core/source/lucide-icons";
    import { toFumadocsSource } from "fumadocs-mdx/runtime/server";
     
    export const blogPostSource = loader({
      source: toFumadocsSource(blogPosts, []),
      baseUrl: "/blog",
      plugins: [lucideIconsPlugin()],
    });
  3. Define the /blog page layout at src/routes/blog/index.tsx.

    import { createFileRoute, Link } from "@tanstack/react-router";
     
    import { blogPostSource } from "@/lib/source";
     
    export const Route = createFileRoute("/blog/")({
      component: RouteComponent,
    });
     
    function RouteComponent() {
      const posts = blogPostSource.getPages();
     
      return (
        <main className="flex-1 w-full max-w-350 mx-auto px-4 py-8">
          <h1 className="text-4xl font-bold mb-8">Latest Blog Posts</h1>
          <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
            {posts.map((post) => (
              <Link
                key={post.url}
                to={post.url}
                className="block bg-fd-secondary rounded-lg shadow-md overflow-hidden p-6"
              >
                <h2 className="text-xl font-semibold mb-2">{post.data.title}</h2>
                <p className="mb-4">{post.data.description}</p>
              </Link>
            ))}
          </div>
        </main>
      );
    }
  4. Now, create src/routes/blog/$.tsx to render the blog contents.

    import browserCollections from "fumadocs-mdx:collections/browser";
    import { createFileRoute, Link, notFound } from "@tanstack/react-router";
    import { createServerFn } from "@tanstack/react-start";
    import { useFumadocsLoader } from "fumadocs-core/source/client";
    import { InlineTOC } from "fumadocs-ui/components/inline-toc";
    import defaultMdxComponents from "fumadocs-ui/mdx";
    import { blogPostSource } from "@/lib/source";
     
    export const Route = createFileRoute("/blog/$")({
      component: BlogPage,
      loader: async ({ params }) => {
        const slugs = params._splat?.split("/") ?? [];
        const data = await serverLoader({ data: slugs });
        await clientLoader.preload(data.path);
        return data;
      },
    });
     
    const serverLoader = createServerFn({
      method: "GET",
    })
      .inputValidator((slugs: string[]) => slugs)
      .handler(async ({ data: slugs }) => {
        const page = blogPostSource.getPage(slugs);
        if (!page) throw notFound();
        return {
          path: page.path,
        };
      });
     
    const clientLoader = browserCollections.blogPosts.createClientLoader({
      component({ toc, frontmatter, default: MDX }) {
        return (
          <>
            <div className="w-full max-w-350 mx-auto px-4 py-12 rounded-xl border md:px-8">
              <h1 className="mb-2 text-3xl font-bold">{frontmatter.title}</h1>
              <p className="mb-4 text-fd-muted-foreground">{frontmatter.description}</p>
              <Link to="/blog">Back</Link>
            </div>
            <article className="w-full max-w-350 mx-auto flex flex-col px-4 py-8">
              <div className="prose min-w-0">
                <InlineTOC items={toc} />
                <MDX components={defaultMdxComponents} />
              </div>
              <div className="flex flex-col gap-4 text-sm">
                <div>
                  <p className="mb-1 text-fd-muted-foreground">Written by</p>
                  <p className="font-medium">{frontmatter.author}</p>
                </div>
                <div>
                  <p className="mb-1 text-sm text-fd-muted-foreground">At</p>
                  <p className="font-medium">{new Date(frontmatter.date).toDateString()}</p>
                </div>
              </div>
            </article>
          </>
        );
      },
    });
     
    function BlogPage() {
      const data = useFumadocsLoader(Route.useLoaderData());
      return <div>{clientLoader.useContent(data.path)}</div>;
    }
     
    export function generateStaticParams(): { slug: string }[] {
      return blogPostSource.getPages().map((page) => ({
        slug: page.slugs[0],
      }));
    }
  5. Write your blogs at content/blog/my-blog.mdx.

Integrate Social Cards

I will use Takumi for Social Cards generation (because its written in Rust). You may refer to Fumadocs Takumi integration docs.

How to configure the Takumi OG image generation in my Tanstack Start + vite (SSR) app? I am using bun.

Icons in Docs

By default, Fumadocs gives support to Lucide Icons which is nice but I also want Simple Icons for brand icons.

For this I have to create custom plugin.