Professional documentation is not optional once a product reaches real users. Clear documentation shortens onboarding, lowers support costs, improves discoverability through search engines, and helps customers succeed without direct assistance.
Oolvay ships its own fully-featured documentation scaffolding, built on Fumadocs, at /docs. Pages are written as MDX files in content/docs, rendered through the App Router, and come with a Markdown export route, a “Copy Markdown” button, and an “Open in ChatGPT or Claude” action on every page. No separate CMS or hosted docs tool is involved.
In this guide you’ll learn how pages and folders are organized, how the route handlers turn an MDX file into a rendered page, how the raw Markdown export works, how the page actions are built, and how the theme is wired to match the rest of the app.
| File | Purpose |
|---|---|
app/docs/[[...slug]]/page.tsx | Resolves a documentation page, generates metadata and JSON-LD, and renders the MDX content |
app/docs/layout.tsx | Provides the shared documentation layout, sidebar, navigation, and theme controls |
app/docs/raw/[[...slug]]/route.ts | Returns the underlying MDX source as plain text for Markdown export and AI integrations |
page.tsx and route.ts both use Next.js optional catch-all route [[...slug]], allowing a single file to handle the documentation homepage as well as any nested documentation page.
Every page is an .mdx file inside content/docs. Folders are allowed and map directly to URL segments, so content/docs/billing/refunds.mdx becomes /docs/billing/refunds.
index.mdx and the overview slugThe landing page at /docs is written as content/docs/index.mdx, but Fumadocs’ source loader does not treat index as a magic filename the way Next.js treats page.tsx. To get a clean /docs URL, index.mdx carries the slug overview internally, and the page component normalizes it back down.
0 / 2,000 characters
function normalizeDocSlug(slug: string[] | undefined): string[] | undefined {
return slug?.[0] === "overview" && slug.length === 1 ? undefined : slug
}A request for /docs/overview and a request for bare /docs both resolve to the same page. source.getPage(normalizedSlug) is then called with undefined for the root case, which is how the loader knows to serve the index page.
Each folder in content/docs, including the root, has its own meta.json. It controls the page title shown above the sidebar group and the order pages appear in.
{
"title": "Documentation",
"pages": ["index", "getting-started", "core-concepts", "faq"]
}The pages array lists slugs, not filenames, so index.mdx is listed as "index", never "index.mdx". Order in this array is the order shown in the sidebar, it is not alphabetical.
If you add a subfolder under content/docs, give that subfolder its own
meta.json too. A folder without one will not show a proper group title in
the sidebar, and its pages may not appear in the order you expect.
Every page needs title and description in its frontmatter. title is shown in the sidebar, the page heading, and the browser tab title (via the %s | Docs | {brand} template set in app/docs/layout.tsx). description feeds the page’s meta description and Open Graph tags.
---
title: Example Page
description: One sentence describing what this page covers.
---Two files outside app/docs connect the MDX content to the page route. Both are part of the docs scaffolding, not something written per page.
This is fumadocs-mdx’s own config file, read at build time, separate from anything in config/site.ts. It defines where the MDX content actually lives and what frontmatter shape it must follow.
export const docs = defineDocs({
dir: siteConfig.documentation.docsPath,
docs: {
schema: pageSchema.extend({
description: z.string().optional(),
}),
},
})dir reads from siteConfig.documentation.docsPath in config/site.ts, the single source of truth for where MDX content lives. The “Open in GitHub” link, described under Configuration, reads the same value, so changing docsPath in one place updates both consumers together.
schema: pageSchema.extend({ description: z.string().optional() }) extends fumadocs’ base page schema with an optional description field. This is the only reason description frontmatter, used throughout Frontmatter above and read by buildPageMetadata, is valid at all. The base pageSchema does not include it on its own.
The default export configures MDX compilation itself, currently only rehypeCodeOptions.themes, which sets github-light and github-dark as the syntax highlighting theme pair for fenced code blocks, switching automatically with the app’s light and dark mode.
export default defineConfig({
mdxOptions: {
rehypeCodeOptions: {
themes: { light: "github-light", dark: "github-dark" },
},
},
})This file builds the source object that app/docs/[[...slug]]/page.tsx and app/docs/layout.tsx both import and call directly, source.getPage(...) and source.pageTree.
import { docs } from "@/.source/server"
import { siteConfig } from "@/config/site"
export const source = loader({
baseUrl: siteConfig.documentation.docsBaseUrl,
source: docs.toFumadocsSource(),
})@/.source/server is generated output, not a file you write or edit by hand. The .source folder is produced by fumadocs-mdx from compiling every file under content/docs according to the schema in source.config.ts. If .source looks stale or missing after pulling new content changes, regenerating it (typically via your dev server start script or a dedicated fumadocs-mdx build step) resolves it, not editing anything inside .source directly.
baseUrl reads from siteConfig.documentation.docsBaseUrl, the same object dir reads docsPath from above. It is what makes source.pageTree’s generated links point at /docs/... rather than the bare content slugs, and it is the reason the catch-all route lives at app/docs/[[...slug]] rather than some other path. Moving the docs site to a different base path means changing docsBaseUrl and the route folder together.
dir and baseUrl both read from siteConfig.documentation in
config/site.ts, so relocating the docs only requires editing that one
object. Renaming the route folder itself, app/docs/[[...slug]], still has to
be done by hand to match a new docsBaseUrl.
app/docs/[[...slug]]/page.tsx does the same four things for every request: resolve the slug, load the page from source, build metadata and JSON-LD, then render the MDX body inside Fumadocs’ <DocsPage> shell.
normalizeDocSlug first collapses the overview special case described above. source.getPage(normalizedSlug) then returns the compiled page object, or undefined if no MDX file matches, in which case a missing page calls notFound() immediately. getDocMarkdown(normalizedSlug) reads that same source file and returns it as a plain string, used both for the <CopyMarkdownButton> and the /api/docs/raw route.
SEO metadata and structured data are built next. buildPageMetadata produces the page’s <title> and meta tags, buildBreadcrumbJsonLd produces a BreadcrumbList script, and buildDocsJsonLd produces the docs-specific JSON-LD block. Both scripts are injected with dangerouslySetInnerHTML.
Finally, the page wraps <DocsBody> in Fumadocs’ <DocsPage>, passing the page’s toc (table of contents) and full (full-width layout flag) straight through from the compiled MDX data. The page’s compiled body component, page.data.body, is rendered as <MDX>, with a components map that currently only overrides Callout.
const MDX = page.data.body
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
{/* breadcrumb and docs JSON-LD scripts */}
<div className="mb-6 flex items-center gap-3">
<CopyMarkdownButton markdown={markdown} />
<DocsActions githubUrl={githubUrl} markdownUrl={markdownUrl} />
</div>
<DocsBody>
<MDX components={{ Callout }} />
</DocsBody>
</DocsPage>
)To make a custom MDX component (<Steps>, <Card>, and so on) available across every docs page rather than importing it manually in each file, add it to that components object.
app/docs/layout.tsx wraps every page in Fumadocs’ <DocsLayout> and supplies three things: the page tree that drives the sidebar, the nav header content, and the sidebar footer.
| Prop | What it controls |
|---|---|
tree | The full page tree, generated from content/docs by source.pageTree. Drives sidebar structure and prev or next links |
nav.title | Custom JSX shown at the top of the sidebar. Currently the brand logo, brand name, and a “Docs” badge |
sidebar.defaultOpenLevel | How many levels of nested folders start expanded. Set to 1 |
sidebar.tabs | Whether top-level sections render as tabs instead of a flat list. Set to false |
sidebar.footer | Custom JSX pinned to the bottom of the sidebar. Currently the <ModeToggle> |
The page title template, %s | Docs | {brand name}, is set once in this layout’s exported metadata object and applies to every page underneath it, so individual pages do not need to repeat the brand name in their own title frontmatter.
Three different surfaces all read from the same underlying Markdown string, produced by getDocMarkdown(slug).
app/docs/raw/[[...slug]]/route.ts is a GET route handler that takes the same optional catch-all slug shape as the page route, calls getDocMarkdown(slug), and returns it with a text/plain content type.
export async function GET(request: Request, { params }: { params: Params }) {
const { slug } = await params
const markdown = await getDocMarkdown(slug)
return new NextResponse(markdown, {
headers: { "Content-Type": "text/plain" },
})
}This is what powers the markdownUrl (/api/docs/raw/[slug]) used by both <CopyMarkdownButton> and <DocsActions>.
The route handler and the page component both call getDocMarkdown
independently rather than one calling the other. They stay in sync because
both read from the same compiled MDX source through source, not because one
depends on the other’s output.
<CopyMarkdownButton> is a small client component that takes the already-fetched markdown string as a prop, so it does no fetching of its own. Clicking it calls navigator.clipboard.writeText(markdown), flips a copied boolean to swap the button label from “Copy Markdown” to “Copied”, then resets it after two seconds with setTimeout.
<DocsActions> renders a dropdown with up to four entries, built conditionally depending on which props are passed in.
| Entry | Shown when | Destination |
|---|---|---|
| View as Markdown | markdownUrl is set | The raw Markdown route itself |
| Open in GitHub | githubUrl is set | The MDX source file on GitHub, at the configured default branch |
| Open in ChatGPT | markdownUrl is set | chatgpt.com/?prompt=... with a prompt telling ChatGPT to read the raw Markdown URL |
| Open in Claude | markdownUrl is set | claude.ai/new?q=... with the same kind of prompt |
The GitHub URL is built in the page component, not inside <DocsActions> itself, from three pieces of config/site.ts data.
const { github, defaultBranch } = siteConfig.brand.repository
const { docsPath } = siteConfig.documentation
const githubUrl = `${github}/blob/${defaultBranch}/${docsPath}/${slugPath}.mdx`So for the page you are reading right now, with docsPath set to content/docs and the slug documentation, the generated link points at content/docs/documentation.mdx on the master branch of the configured GitHub repository.
The ChatGPT and Claude links both embed window.location.origin, read at render time on the client, concatenated with the page’s markdownUrl. This means the AI hand-off only works correctly once the site is reachable at a real origin. On localhost, the generated prompt will point the AI at a URL it cannot fetch.
Four values across two config/site.ts objects drive the GitHub link, the Markdown source path, and the docs URL prefix described above.
repository: {
github: "https://github.com/acme",
defaultBranch: "master",
},
documentation: {
docsPath: "content/docs",
docsBaseUrl: "/docs",
},| Key | Used for |
|---|---|
repository.github | The base repository URL for “Open in GitHub” |
repository.defaultBranch | The branch segment in the GitHub link |
documentation.docsPath | The real MDX source folder, read by source.config.ts, and the folder segment in the GitHub link |
documentation.docsBaseUrl | The URL prefix for every docs page, read by lib/source.ts |
If you move content/docs to a different path in your repository, update docsPath here. If you move the docs site to a different URL prefix, update docsBaseUrl here and rename the app/docs/[[...slug]] route folder to match. Nothing else in the docs system needs to change.
Fumadocs renders inside its own CSS variable namespace (--color-fd-*), which does not automatically pick up the rest of the app’s design tokens. app/styles/fumadocs-theme.css bridges the two by re-pointing every --color-fd-* variable at the matching app-level variable, for both light and dark mode.
.fd-wrapper {
--color-fd-background: var(--background);
--color-fd-foreground: var(--foreground);
--color-fd-primary: var(--primary);
/* every other --color-fd-* variable follows the same pattern */
}A few details matter beyond the basic variable mapping.
.dark .fd-wrapper, because Fumadocs’ own .dark selector can otherwise win on CSS specificity and override the bridge.fd-wrapper, so its variables are set again on :root and .dark directly, not just inside the wrapper class!important on .fd-wrapper h1 through h6 and on the --tw-prose-* variables, because Fumadocs’ typography plugin sets some of these independently of the --color-fd-* bridge#nd-sidebar div:has(> [data-theme-toggle]) { display: none }) and replaced with the app’s own <ModeToggle>, passed in through sidebar.footer in app/docs/layout.tsxIf a Fumadocs element looks unstyled or mismatched after an update to the
fumadocs-ui package, check whether the new version introduced a
--color-fd-* variable that isn’t yet mapped in this file. New variables fall
back to Fumadocs’ own defaults until added here.
Create the MDX file under content/docs, in a subfolder if it belongs to a
group of related pages. Add title and description frontmatter (this
isn’t optional).
Add the new file’s slug to the pages array in that folder’s meta.json,
in the position you want it to appear in the sidebar.
Import any MDX components you use beyond what’s globally available, for
example Steps and Step from fumadocs-ui/components/steps, at the top
of the file.
Run the dev server and visit /docs/[your-slug] to confirm it renders,
appears in the sidebar in the right place, and that
/api/docs/raw/[your-slug] returns the expected Markdown.