Stack Builder

Fumadocs

Powering your current documentation reading experience. No further explanation needed.

Installation

pwd # See where you are
#/home/mfarabi/workspace
mkdir dirname # Create and navigate to new directory
git init -b main # Initialize Git repository
npx create-fumadocs-app

Follow the prompt with the responses.

┌  Create Fumadocs App

◇  Project name
│  .

◇  Choose a content source
│  Fumadocs MDX

◇  Use Tailwind CSS for styling?
│  Yes

◇  Do you want to install packages automatically? (detected as pnpm)
│  Yes

◇  . already exists, do you want to delete it?
│  No

◓  Generating ProjectLockfile is up to date, resolution step is skipped
◑  Generating ProjectAlready up to date
◓  Generating ProjectDone in 605ms
◇  Project Generated

└  Done

✔ Tailwind CSS
✔ Typescript

Open the project
cd .

Run Development Server
npm run dev | pnpm run dev | yarn dev

You can now open the project and start writing documents
npm i; npm run dev

You should see:

> next dev

  ▲ Next.js 14.2.4
  - Local:        http://localhost:3000

 ✓ Starting...
   automatically enabled Fast Refresh for 1 custom loader
 ✓ Ready in 2.1s

Navigate to the localhost url in your browser to see the app running. You can also +Click or Ctrl+Click it.

If you have other servers running locally, it may be on a different port than 3000 and that's okay.

Migrate project to use 'src' directory

As the time of writing this, Fumadocs creates a Next.js app with the app directory at the root level, and does not provide the option to use the src directory, which would be provided by the Next.js CLI.

Therefore, we need to manually move the app directory into the src directory, and then make some pathing changes in various files.

Create a src directory at the root level, and move the app and content directories into it.

You can click on the folders to expand them.
.gitignore
.map.ts
README.md
mdx-components.tsx
next-env.d.ts
next.config.mjs
package.json
pnpm-lock.yaml
postcss.config.js
tailwind.config.js
tsconfig.json
Terminal
pwd # /home/mfarabi/workspace/dirname
mkdir src;
mv app src/
mv content src/

Now see the After tab.

Fix Broken Paths

If you try to run the app, you'll see that it doesn't work. This is because the paths in the tsconfig.json, tailwind.config.js, route.ts, app/page.tsx, layout.config.tsx, source.ts, and next.config.mjs files are still pointing to the app and content directories at the root level. Let's fix them.

route.ts
- import { getPages } from '@/source';
+ import { getPages from '@/app/source';

Run the app again, everything should be working now.

Terminal
pnpm i; pnpm dev

Configuration

Add Theme Presets

Easily see the UI differences between the home route and the docs route. Use whichever one you prefer, this one uses purple.

tailwind.config.js
    './node_modules/fumadocs-ui/dist/**/*.js',
  ],
+  presets: [createPreset({
+    // preset: 'default',
+    // preset: 'neutral',
+    // preset: 'dusk',
+    preset: 'purple',
+    // preset: 'ocean',
+    // preset: 'catppuccin',
+  })],
} satisfies Config

Document CSS Variables for future reference & customization

docs/fumadocs.global.css
@config '../../../tailwind.fumadocs.config.ts';
@tailwind base;
@tailwind components;
@tailwind utilities;
 
/*Fumadocs Presets*/
/*var defaultPreset = {*/
/*  light: {*/
/*    background: "0 0% 98%",*/
/*    foreground: "0 0% 3.9%",*/
/*    muted: "0 0% 96.1%",*/
/*    "muted-foreground": "0 0% 45.1%",*/
/*    popover: "0 0% 100%",*/
/*    "popover-foreground": "0 0% 15.1%",*/
/*    card: "0 0% 99.7%",*/
/*    "card-foreground": "0 0% 3.9%",*/
/*    border: "0 0% 89.8%",*/
/*    primary: "0 0% 9%",*/
/*    "primary-foreground": "0 0% 98%",*/
/*    secondary: "0 0% 96.1%",*/
/*    "secondary-foreground": "0 0% 9%",*/
/*    accent: "0 0% 94.1%",*/
/*    "accent-foreground": "0 0% 9%",*/
/*    ring: "0 0% 63.9%"*/
/*  },*/
/*  dark: {*/
/*    background: "0 0% 3.9%",*/
/*    foreground: "0 0% 98%",*/
/*    muted: "0 0% 12.9%",*/
/*    "muted-foreground": "0 0% 60.9%",*/
/*    popover: "0 0% 7%",*/
/*    "popover-foreground": "0 0% 88%",*/
/*    card: "0 0% 8%",*/
/*    "card-foreground": "0 0% 98%",*/
/*    border: "0 0% 18%",*/
/*    primary: "0 0% 98%",*/
/*    "primary-foreground": "0 0% 9%",*/
/*    secondary: "0 0% 12.9%",*/
/*    "secondary-foreground": "0 0% 98%",*/
/*    accent: "0 0% 14.9%",*/
/*    "accent-foreground": "0 0% 90%",*/
/*    ring: "0 0% 14.9%"*/
/*  }*/
/*};*/
/*var oceanPreset = {*/
/*  light: {*/
/*    background: "0 0% 98%",*/
/*    foreground: "0 0% 3.9%",*/
/*    muted: "220 90% 96.1%",*/
/*    "muted-foreground": "0 0% 45.1%",*/
/*    popover: "0 0% 98%",*/
/*    "popover-foreground": "0 0% 15.1%",*/
/*    card: "220 50% 98%",*/
/*    "card-foreground": "0 0% 3.9%",*/
/*    border: "220 50% 89.8%",*/
/*    primary: "210 80% 20.2%",*/
/*    "primary-foreground": "0 0% 98%",*/
/*    secondary: "220 90% 96.1%",*/
/*    "secondary-foreground": "0 0% 9%",*/
/*    accent: "220 50% 94.1%",*/
/*    "accent-foreground": "0 0% 9%",*/
/*    ring: "220 100% 63.9%"*/
/*  },*/
/*  dark: {*/
/*    "card-foreground": "220 60% 94.5%",*/
/*    "primary-foreground": "0 0% 9%",*/
/*    "secondary-foreground": "220 80% 90%",*/
/*    ring: "205 100% 85%",*/
/*    card: "220 50% 10%",*/
/*    muted: "220 50% 10%",*/
/*    "muted-foreground": "220 30% 65%",*/
/*    "accent-foreground": "220 80% 90%",*/
/*    popover: "220 50% 10%",*/
/*    "popover-foreground": "220 30% 65%",*/
/*    accent: "220 40% 20%",*/
/*    secondary: "220 50% 20%",*/
/*    background: "220 60% 6%",*/
/*    foreground: "220 60% 94.5%",*/
/*    primary: "205 100% 85%",*/
/*    border: "220 50% 20%"*/
/*  },*/
/*  css: {*/
/*    ".dark body": {*/
/*      "background-image": "linear-gradient(rgba(5, 105, 255, 0.15), transparent 20rem, transparent)"*/
/*    }*/
/*  }*/
/*};*/
/*var neutral = {*/
/*  light: {*/
/*    background: "0 0% 96%",*/
/*    foreground: "0 0% 3.9%",*/
/*    muted: "0 0% 96.1%",*/
/*    "muted-foreground": "0 0% 45.1%",*/
/*    popover: "0 0% 100%",*/
/*    "popover-foreground": "0 0% 15.1%",*/
/*    card: "0 0% 94.7%",*/
/*    "card-foreground": "0 0% 3.9%",*/
/*    border: "0 0% 89.8%",*/
/*    primary: "0 0% 9%",*/
/*    "primary-foreground": "0 0% 98%",*/
/*    secondary: "0 0% 93.1%",*/
/*    "secondary-foreground": "0 0% 9%",*/
/*    accent: "0 0% 90.1%",*/
/*    "accent-foreground": "0 0% 9%",*/
/*    ring: "0 0% 63.9%"*/
/*  },*/
/*  dark: {*/
/*    background: "0 0% 8.9%",*/
/*    foreground: "0 0% 92%",*/
/*    muted: "0 0% 12.9%",*/
/*    "muted-foreground": "0 0% 60.9%",*/
/*    popover: "0 0% 9.8%",*/
/*    "popover-foreground": "0 0% 88%",*/
/*    card: "0 0% 10%",*/
/*    "card-foreground": "0 0% 98%",*/
/*    border: "0 0% 18%",*/
/*    primary: "0 0% 98%",*/
/*    "primary-foreground": "0 0% 9%",*/
/*    secondary: "0 0% 12.9%",*/
/*    "secondary-foreground": "0 0% 98%",*/
/*    accent: "0 0% 16.9%",*/
/*    "accent-foreground": "0 0% 90%",*/
/*    ring: "0 0% 14.9%"*/
/*  },*/
/*  css: {*/
/*    "#nd-sidebar": {*/
/*      "--muted": "0deg 0% 89%",*/
/*      "--secondary": "0deg 0% 99%",*/
/*      "--muted-foreground": "0 0% 30%"*/
/*    },*/
/*    ".dark #nd-sidebar": {*/
/*      "--muted": "0deg 0% 16%",*/
/*      "--secondary": "0deg 0% 18%",*/
/*      "--muted-foreground": "0 0% 72%"*/
/*    }*/
/*  }*/
/*};*/
/*var catppuccin = {*/
/*  light: {*/
/*    popover: "220deg 22% 92%",*/
/*    "popover-foreground": "234deg 16% 35%",*/
/*    "secondary-foreground": "234deg 16% 35%",*/
/*    border: "223deg 16% 83%",*/
/*    primary: "266deg 85% 58%",*/
/*    "primary-foreground": "234deg 16% 35%",*/
/*    muted: "220deg 22% 92%",*/
/*    card: "220deg 22% 92%",*/
/*    accent: "223deg 16% 83%",*/
/*    "accent-foreground": "234deg 16% 35%",*/
/*    "card-foreground": "234deg 16% 35%",*/
/*    "muted-foreground": "233deg 10% 47%",*/
/*    foreground: "234deg 16% 35%",*/
/*    secondary: "220deg 22% 92%",*/
/*    background: "220deg 23% 95%",*/
/*    ring: "267deg 84% 81%"*/
/*  },*/
/*  dark: {*/
/*    ring: "267deg 84% 81%",*/
/*    primary: "267deg 84% 81%",*/
/*    background: "240deg 21% 15%",*/
/*    foreground: "226deg 64% 88%",*/
/*    popover: "240deg 23% 9%",*/
/*    card: "240deg 21% 12%",*/
/*    muted: "240deg 21% 12%",*/
/*    border: "237deg 16% 23%",*/
/*    accent: "237deg 16% 23%",*/
/*    secondary: "240deg 21% 12%",*/
/*    "primary-foreground": "240deg 23% 9%",*/
/*    "card-foreground": "226deg 64% 88%",*/
/*    "secondary-foreground": "226deg 64% 88%",*/
/*    "popover-foreground": "226deg 64% 88%",*/
/*    "accent-foreground": "226deg 64% 88%",*/
/*    "muted-foreground": "228deg 24% 72%"*/
/*  },*/
/*  css: {*/
/*    "#nd-sidebar": {*/
/*      "--secondary": "223deg 16% 83%",*/
/*      "--muted": "223deg 16% 83%"*/
/*    },*/
/*    ".dark #nd-sidebar": {*/
/*      "--secondary": "237deg 16% 23%",*/
/*      "--muted": "237deg 16% 23%"*/
/*    }*/
/*  }*/
/*};*/
/*var purple = {*/
/*  light: {*/
/*    background: "256 100% 96%",*/
/*    primary: "270 100% 52%",*/
/*    border: "270 40% 80%",*/
/*    accent: "270 60% 86%",*/
/*    "accent-foreground": "270 100% 20%",*/
/*    muted: "256 60% 94%",*/
/*    "muted-foreground": "256 50% 50%",*/
/*    foreground: "256 60% 26%",*/
/*    secondary: "270 60% 90%",*/
/*    "secondary-foreground": "256 60% 10%",*/
/*    card: "256 60% 92%",*/
/*    "card-foreground": "256 100% 20%",*/
/*    "popover-foreground": "256 100% 20%",*/
/*    popover: "256 60% 96%",*/
/*    "primary-foreground": "270 100% 20%",*/
/*    ring: "270 100% 52%"*/
/*  },*/
/*  dark: {*/
/*    background: "256 60% 6%",*/
/*    primary: "270 100% 86%",*/
/*    border: "270 100% 20%",*/
/*    accent: "256 60% 26%",*/
/*    "accent-foreground": "270 100% 86%",*/
/*    muted: "256 60% 10%",*/
/*    foreground: "256 60% 90%",*/
/*    "muted-foreground": "256 50% 75%",*/
/*    secondary: "270 100% 20%",*/
/*    "secondary-foreground": "256 60% 90%",*/
/*    card: "256 60% 10%",*/
/*    "card-foreground": "256 60% 90%",*/
/*    "popover-foreground": "256 60% 90%",*/
/*    popover: "256 60% 6%",*/
/*    "primary-foreground": "256 60% 6%",*/
/*    ring: "270 100% 86%"*/
/*  }*/
/*};*/
/*var dusk = {*/
/*  light: {*/
/*    background: "250 20% 92%",*/
/*    primary: "340 40% 48%",*/
/*    border: "240 40% 90%",*/
/*    accent: "250 30% 90%",*/
/*    "accent-foreground": "250 20% 20%",*/
/*    muted: "240 30% 94%",*/
/*    "muted-foreground": "240 10% 50%",*/
/*    foreground: "220 20% 30%",*/
/*    secondary: "250 40% 94%",*/
/*    "secondary-foreground": "240 40% 10%",*/
/*    card: "250 20% 92%",*/
/*    "card-foreground": "250 20% 20%",*/
/*    "popover-foreground": "250 40% 20%",*/
/*    popover: "250 40% 96%",*/
/*    "primary-foreground": "240 80% 20%",*/
/*    ring: "340 40% 48%"*/
/*  },*/
/*  dark: {*/
/*    ring: "340 100% 90%",*/
/*    "primary-foreground": "240 40% 4%",*/
/*    popover: "240 20% 5%",*/
/*    "popover-foreground": "250 20% 90%",*/
/*    primary: "340 100% 90%",*/
/*    border: "220 15% 15%",*/
/*    background: "220 15% 6%",*/
/*    foreground: "220 15% 87%",*/
/*    muted: "220 20% 15%",*/
/*    "muted-foreground": "220 15% 60%",*/
/*    accent: "250 20% 15%",*/
/*    secondary: "240 20% 15%",*/
/*    "card-foreground": "240 15% 87%",*/
/*    card: "240 20% 5%",*/
/*    "secondary-foreground": "250 20% 90%",*/
/*    "accent-foreground": "340 5% 90%"*/
/*  }*/
/*};*/
/*var presets = {*/
/*  purple,*/
/*  default: defaultPreset,*/
/*  ocean: oceanPreset,*/
/*  catppuccin,*/
/*  neutral,*/
/*  dusk*/
/*};*/
docs/layout.config.tsx
import type { BaseLayoutProps, DocsLayoutProps } from 'fumadocs-ui/layout'
import { pageTree } from '@/app/docs/source'
 
// shared configuration
export const baseOptions: BaseLayoutProps = {
  nav: {
    title: 'Stack Builder Docs',
  },
+ githubUrl: 'https://github.com/MFarabi619/stack-builder',
  links: [
    {
      text: 'Documentation',
      url: '/docs',
      active: 'nested-url',
    },
  ],
}
 
// docs layout configuration
export const docsOptions: DocsLayoutProps = {
  ...baseOptions,
  tree: pageTree,
}

Add Header

The properties currently accepted by the Docs Layout can be accepted by the Home layout as well.

docs/layout.tsx
import type { BaseLayoutProps, DocsLayoutProps } from 'fumadocs-ui/layout'
import { pageTree } from '@/app/docs/source'
 
// shared configuration
export const baseOptions: BaseLayoutProps = {
  nav: {
    title: 'Stack Builder Docs',
  },
  githubUrl: 'https://github.com/MFarabi619/stack-builder',
  links: [
    {
      text: 'Documentation',
      url: '/docs',
      active: 'nested-url',
    },
  ],
}
+
+ // home layout configuration
+ export const homeOptions: BaseLayoutProps = {
+  ...baseOptions,
+}
+
// docs layout configuration
export const docsOptions: DocsLayoutProps = {
  ...baseOptions,
  tree: pageTree,
}

For the header to appear, the DocsLayout component needs to be wrapped in the HomeLayout component.

docs/layout.tsx
```diff title="docs/layout.tsx"
 import './fumadocs.global.css'
-import { DocsLayout } from 'fumadocs-ui/layout'
+import { DocsLayout, Layout as HomeLayout } from 'fumadocs-ui/layout'
 import type { ReactNode } from 'react'
 import { RootProvider } from 'fumadocs-ui/provider'
-import { docsOptions } from './layout.config'
+import { docsOptions, homeOptions } from './layout.config'
 
 export default function Layout({ children }: { children: ReactNode }) {
   return (
     <html lang="en" suppressHydrationWarning>
       <body>
         <RootProvider>
-          <DocsLayout {...docsOptions}>{children}</DocsLayout>
+          <HomeLayout {...homeOptions}>
+            <DocsLayout {...docsOptions}>{children}</DocsLayout>
+          </HomeLayout>
         </RootProvider>
       </body>
     </html>

At this point the header should appear on the docs site. However, it contains many of the same elements as the sidebar, such as the title, search bar, theme toggle, links, etc.

Unfortunately, Fumadocs does not provide a way to customize the header(HomeLayout) and sidebar(DocsLayout) separately through a clean API due to opinion. Therefore we have to target the CSS classes directly to conditionally render them at different screen sizes.

Customize Header

docs/fumadocs.global.css
@config '../../../tailwind.fumadocs.config.ts';
@tailwind base;
@tailwind components;
@tailwind utilities;
+
+/*Hide the header on mobile screens, as the sidebar turns into the header on mobile screens*/
+#nd-nav {
+  @apply max-md:hidden md:sticky
+}
+
+/*Make the header full width, otherwise it is constrained to a container and looks odd at larger screens*/
+#nd-nav nav {
+  @apply max-w-full
+}
+
+/*Due to the addition of the header, the sidebar is pushed down.*/
+/*When the page is scrolled, the sidebar scrolls as well and is covered by the header. Fix this by making the sidebar sticky, and setting the height to be viewport height minus the header height.*/
+#nd-sidebar {
+  @apply md:sticky md:top-16 md:h-[calc(100vh-4rem)]
+}
+
+/*Hide the first container for sidebar, with title, horizontal rule, search bar, and dots button*/
+#nd-sidebar > div:first-child {
+  @apply md:hidden
+}
+
+/*Sidebar search bar*/
+/*#nd-sidebar > div:first-child > button:last-child {*/
+/*  @apply md:hidden*/
+/*}*/
+
+/*Make the theme toggle button invisible on desktop screens, as the header already has a theme toggle button*/
+#nd-sidebar > div:nth-child(3) > [aria-label="Toggle Theme"] {
+  @apply md:invisible
+}
+
+/*The Table of Contents also faces the same issue as the sidebar when desktop header is visible. Apply similar fixes*/
+#nd-docs-layout > div:last-child {
+  @apply md:sticky md:top-16 md:h-[calc(100vh-4rem)] md:pt-8
+}
+
/*Fumadocs Presets*/
...

Add Package Install plugin

npm install fumadocs-docgen

Add Remark plugin with custom Tabs configuration

next.config.mjs
 import createMDX from 'fumadocs-mdx/config'
+ import { remarkInstall } from 'fumadocs-docgen'
 
- const withMDX = createMDX({ rootContentPath: './src/content' })
 const withMDX = createMDX({
   rootContentPath: './src/content',
   mdxOptions: {
+     remarkPlugins: [
+       [remarkInstall, { Tabs: 'InstallTabs' }],
+     ],
   },
 })
 
 /** @type {import('next').NextConfig} */
 const config = {

Add Tabs configuration to mdx-components.tsx. Whichever tab the user chooses will persist, even if they navigate to another page.

mdx-components.tsx
 import type { MDXComponents } from 'mdx/types'
 import defaultComponents from 'fumadocs-ui/mdx'
 
```diff title="mdx-components.tsx"
 import type { MDXComponents } from 'mdx/types'
 import defaultComponents from 'fumadocs-ui/mdx'
+import type { ReactNode } from 'react'
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
 
 export function useMDXComponents(components: MDXComponents): MDXComponents {
   return {
     ...defaultComponents,
     ...components,
+    Tab,
+    Tabs,
+    InstallTabs: ({
+      items,
+      children,
+    }: {
+      items: string[]
+      children: ReactNode
+    }) => (
+      <Tabs items={items} id="package-manager">
+        {children}
+      </Tabs>
+    ),
   }
 }

Add Lucide React Icons

npm install lucide-react

Add finder function to loader

This allows us to use icons in docs without importing them.

docs/source.ts
 
 import { map } from '@root/.map'
 import { createMDXSource } from 'fumadocs-mdx'
 import { loader } from 'fumadocs-core/source'
+import { createElement } from 'react'
+import { icons } from 'lucide-react'
 
 export const { getPage, getPages, pageTree } = loader({
   baseUrl: '/docs',
   rootDir: 'docs',
+  icon(icon) {
+    if (!icon) {
+      // You may set a default icon
+      // return createElement(HomeIcon)
+      return
+    }
+
+    if (icon in icons)
+      return createElement(icons[icon as keyof typeof icons])
+  },
+
   source: createMDXSource(map),
 })

Use Icons in Docs

You can now use Lucide React icons in your docs.

index.mdx
---
title: Manual Setup
description: Set up the template your way.
icon: GitBranch
---
meta.json
{
  "title": "Manual Setup",
  "icon": "GitBranch"
}

Define Pages

Use a pages variable for single source of truth.

docs/source.ts
 
 import { map } from '@root/.map'
 import { createMDXSource } from 'fumadocs-mdx'
 import { loader } from 'fumadocs-core/source'
 import { createElement } from 'react'
+import {
+  Palette as DesignIcon,
+  GitPullRequestCreateArrow as DevelopmentIcon,
+  Dock as HomeIcon,
+  Kanban as ProjectManagementIcon,
   icons,
+} from 'lucide-react'
+
+export const pages = [
+  {
+    title: 'Home',
+    description: 'Best decisions start with the best information',
+    url: '',
+    icon: HomeIcon,
+  },
+  {
+    title: 'Project Management',
+    description: 'Turn visions into reality with confidence',
+    url: 'project-management',
+    icon: ProjectManagementIcon,
+  },
+  {
+    title: 'Design',
+    description: 'Deliver outstanding UI/UX at record velocity',
+    url: 'design',
+    icon: DesignIcon,
+  },
+  {
+    title: 'Development',
+    description: 'Architect your solution for scale and longevity',
+    url: 'development',
+    icon: DevelopmentIcon,
+  },
+]
 
 export const { getPage, getPages, pageTree } = loader({
   baseUrl: '/docs',
   ...

You can import these pages in .mdx files and use them like this:

index.mdx
---
title: Welcome to Stack Builder
description: Your dream, your stack, your way.
---
 
import { pages } from "../../app/docs/source";
 
## Choose your path
 
<Cards>
  {pages
    .filter((page) => page.title !== "Home") // Filter out the Home page
    .map((page) => (
      <Card
        key={page.title}
        title={page.title}
        description={page.description}
        href={`/docs/${page.url}`}
        icon={
          <page.icon
            style={{
              color: `hsl(var(--accent-foreground))`,
            }}
          />
        }
      />
    ))}
</Cards>

As of right now, I haven't been able to get path aliasing to work, hence the spaghetti import.

The url property must be specified in that format, otherwise RootToggle will not change options when clicked.

Add Root Toggle button

docs/layout.config.tsx
 import { pageTree, pages } from '@/app/docs/source'
 import type { BaseLayoutProps, DocsLayoutProps } from 'fumadocs-ui/layout'
-import { pageTree } from '@/app/docs/source'
+import { RootToggle } from 'fumadocs-ui/components/layout/root-toggle'
+import { Library as DocumentationPageIcon, Layers as StackBuilderIcon } from 'lucide-react'
+import { pageTree, pages } from '@/app/docs/source'
 
 // shared configuration
 export const baseOptions: BaseLayoutProps = {
   nav: {
-    title: 'Stack Builder Docs',
+    title: (
+      <>
+        <StackBuilderIcon />
+        <span className="text-lg font-bold">Stack Builder</span>
+      </>
+    ),
   },
   githubUrl: 'https://github.com/MFarabi619/stack-builder',
   links: [
     {
-      text: 'Documentation',
+      text: 'Home',
       url: '/docs',
+      icon: <DocumentationPageIcon />,
       active: 'nested-url',
     },
   ],
 
...
 
 // docs layout configuration
 export const docsOptions: DocsLayoutProps = {
   ...baseOptions,
+  sidebar: {
+    banner: (
+      <RootToggle
+        options={pages.map(page => ({
+          title: page.title,
+          description: page.description,
+          url: `/docs/${page.url}`,
+          icon: (
+            <page.icon
+              className="size-9 shrink-0 rounded-md bg-gradient-to-t from-background/80 p-1.5"
+              style={{
+                backgroundColor: `hsl(var(--primary)/.3)`,
+                color: `hsl(var(--accent-foreground))`,
+              }}
+            />
+          ),
+        }))}
+      />
+    ),
+  },
   tree: pageTree,
 }

The url property must be specified in that format, otherwise RootToggle will not change options when clicked.

Add Roll Button

Add the button you currently see that scrolls to the top when clicked.

docs/[[...slug]]/page.tsx
 import type { Metadata } from 'next'
 import { DocsBody, DocsPage } from 'fumadocs-ui/page'
 import { notFound } from 'next/navigation'
+import { RollButton } from 'fumadocs-ui/components/roll-button'
 import { getPage, getPages } from '@/app/docs/source'
 
 ...
 
   return (
     <DocsPage toc={page.data.exports.toc} full={page.data.full}>
+      <RollButton />
       <DocsBody>
         <h1>{page.data.title}</h1>
         <MDX />

The button is hidden behind the desktop header. Apply the following CSS to make it visible.

docs/fumadocs.global.css
/*The Table of Contents also faces the same issue as the sidebar when desktop header is visible. Apply similar fixes*/
#nd-docs-layout > div:last-child {
  @apply md:sticky md:top-16 md:h-[calc(100vh-4rem)] md:pt-8;
}
+
+/*---------------- ROLL BUTTON ----------------*/
+
+/*The button appears behind the desktop header, move it down*/
+[aria-label='Scroll to Top'] {
+  @apply md:top-20 !important;
+}
+
/*---------------- THEME PRESETS ----------------*/

Add Steps

mdx-components.tsx
...
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
 
export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    ...defaultComponents,
    ...components,
    Tab,
    Tabs,
+   Step,
+   Steps,
    InstallTabs: ({
      items,
    ...
 
docs/[[...slug]]/page.tsx
   const MDX = page.data.exports.default
 
   return (
-    <DocsPage toc={page.data.exports.toc} full={page.data.full}>
+    <DocsPage
+      toc={page.data.exports.toc}
+      lastUpdate={page.data.exports.lastModified}
+      full={page.data.full}
+    >
       <RollButton />
       <DocsBody>
         <h1>{page.data.title}</h1>
next.config.mjs
 import createMDX from 'fumadocs-mdx/config'
 import { remarkInstall } from 'fumadocs-docgen'
 
 const withMDX = createMDX({
   rootContentPath: './src/content',
   mdxOptions: {
+    lastModifiedTime: 'git',
     remarkPlugins: [
       [remarkInstall, { Tabs: 'InstallTabs' }],
     ],
   },
 })
 
 /** @type {import('next').NextConfig} */
 const config = {

Render page descriptions under title

docs/[[...slug]]/page.tsx
       ...
       <RollButton />
       <DocsBody>
         <h1>{page.data.title}</h1>
+        <p className="mb-8 text-lg text-muted-foreground">
+          {page.data.description}
+        </p>
         <MDX />
       </DocsBody>
     </DocsPage>
      ...

Add 'Edit on GitHub' button

Use repository URL in package.json as source.

package.json
 {
   "name": "stack-builder",
   "version": "0.0.0",
```diff title="package.json"
 {
   "name": "stack-builder",
   "version": "0.0.0",
-  "private": true,
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/MFarabi619/stack-builder.git"
+  },
+  "homepage": "https://github.com/MFarabi619/stack-builder",
   "scripts": {
 ...

Create button.

docs/[[...slug]]/page.tsx
...
 import { DocsBody, DocsPage } from 'fumadocs-ui/page'
 import { notFound } from 'next/navigation'
 import { RollButton } from 'fumadocs-ui/components/roll-button'
+import { Edit } from 'lucide-react'
+import packageJson from '@root/package.json'
 import { getPage, getPages } from '@/app/docs/source'
 
...
 
   const MDX = page.data.exports.default
+  const path = `src/content/docs/${page.file.path}`
+  const gitHubRepoUrl = packageJson.repository.url.replace(/\.git$/, '') // Remove .git suffix
+
+  const footer = (
+    <a
+      href={`${gitHubRepoUrl}/blob/main/${path}`}
+      target="_blank"
+      rel="noreferrer noopener"
+      className="inline-flex items-center justify-center rounded-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border bg-secondary text-secondary-foreground hover:bg-secondary/80 h-9 px-3 text-xs gap-1.5"
+    >
+      <Edit className="size-3" />
+      Edit on Github
+    </a>
+  )
 
   return (
     <DocsPage
       toc={page.data.exports.toc}
       lastUpdate={page.data.exports.lastModified}
       full={page.data.full}
+      tableOfContent={{
+        footer,
+      }}
+      tableOfContentPopover={{ footer }}
     >
       <RollButton />
       <DocsBody>
      ...

Create Custom MDX Component for Index Page Cards

As of writing, Fumadocs has no API to autogenerate index page cards, so create it.

Terminal
touch src/components/mdx/index-page-cards.tsx
src/components/mdx/index-page-cards.tsx
import { Card, Cards } from "fumadocs-ui/components/card";
import { getPages } from "@/app/docs/source";
 
// Render cards for each page in the directory
export async function IndexPageCards(meta: { title: string; pages: string[] }) {
  const allPages = getPages();
 
  // Destructure title and pages from meta
  const { title: INDEX_PAGE_TITLE, pages: INDEX_PAGE_SUBPAGES } = meta;
 
  // Get the index page by title
  const INDEX_PAGE = allPages.find(
    ({ data }) => data.title === INDEX_PAGE_TITLE,
  );
  if (!INDEX_PAGE) return null;
 
  // Destructure dirname and slugs from INDEX_PAGE
  const {
    file: { dirname: INDEX_PAGE_DIRNAME },
    slugs: INDEX_PAGE_SLUGS,
  } = INDEX_PAGE;
 
  // Create an order map to sort the filtered pages
  const orderMap = INDEX_PAGE_SUBPAGES.reduce(
    (map, name, index) => {
      map[name] = index;
      return map;
    },
    {} as { [key: string]: number },
  );
 
  // Filter and sort pages by order in meta.json "pages" array
  const filteredPages = allPages
    .filter(
      ({ file: { name }, slugs }) =>
        (name === "index" &&
          slugs.length === INDEX_PAGE_SLUGS.length + 1 &&
          slugs[INDEX_PAGE_SLUGS.length - 1] === INDEX_PAGE_DIRNAME) ||
        INDEX_PAGE_SUBPAGES.includes(name),
    )
    .sort((a, b) => orderMap[a.file.name] - orderMap[b.file.name]);
 
  return (
    <Cards>
      {filteredPages.map((page) => (
        <Card
          key={page.file.name}
          title={page.data.title}
          description={page.data.description || "No description provided"}
          href={page.url}
        />
      ))}
    </Cards>
  );
}

Add Favicon and Icon

Create a public/ directory at the root level to store static assets.

I use convert.io to convert SVGs into favicons.

Paste the favicon.ico and icon.ico files in the app, (home), and docs directories. As of right now I'm not sure which one it was that made it work.

Last updated on

On this page

Edit on GitHub