Loading
Create a documentation site with MDX content, sidebar navigation, full-text search, versioning, and dark mode using Next.js.
Good documentation is the difference between a library people adopt and one they abandon. In this tutorial, you will build a full-featured documentation site using Next.js and MDX. It will have a collapsible sidebar with nested navigation, full-text search across all pages, version switching, dark mode, and a table of contents generated from headings.
The content lives as MDX files in the repository so authors write in Markdown with embedded React components. No CMS or database required. Everything is statically generated for instant page loads.
Initialize a Next.js project with MDX support.
Create the content directory structure:
Add a configuration file that defines the documentation structure.
Load and parse MDX files with frontmatter from the content directory.
Build a collapsible sidebar that shows the navigation tree.
Extract headings from MDX content and render a sticky TOC sidebar.
Create a search index at build time and a search component for instant results.
Implement a theme toggle using CSS variables and localStorage persistence.
Let users switch between documentation versions.
Assemble the sidebar, content area, and table of contents into the doc page layout.
Create an MDX file that exercises all features.
Head to the Quick Start guide to build your first integration.
Run npm run build to generate static pages for every version and slug combination. Deploy to Vercel, Netlify, or any static host.
You now have a documentation site with sidebar navigation, full-text search via Ctrl+K, dark mode, version switching, and a table of contents that highlights the current section as you scroll. All content lives as MDX files in the repository, making it easy for contributors to add and edit pages without touching any code.
From here, you could add syntax highlighting with Shiki, previous/next page navigation, edit-on-GitHub links, or an AI-powered search using embeddings.
npx create-next-app@latest my-docs --typescript --tailwind --app --no-src-dir
cd my-docs
npm install next-mdx-remote gray-matter
npm install -D @types/nodemkdir -p content/docs/v1
mkdir -p content/docs/v2// lib/docs-config.ts
export interface NavItem {
title: string;
slug: string;
children?: NavItem[];
}
export interface DocsVersion {
label: string;
path: string;
}
export const versions: DocsVersion[] = [
{ label: "v2.0", path: "v2" },
{ label: "v1.0", path: "v1" },
];
export const defaultVersion = "v2";
export const navigation: NavItem[] = [
{
title: "Getting Started",
slug: "getting-started",
children: [
{ title: "Installation", slug: "installation" },
{ title: "Quick Start", slug: "quick-start" },
{ title: "Configuration", slug: "configuration" },
],
},
{
title: "Guides",
slug: "guides",
children: [
{ title: "Authentication", slug: "authentication" },
{ title: "Data Fetching", slug: "data-fetching" },
{ title: "Deployment", slug: "deployment" },
],
},
{
title: "API Reference",
slug: "api-reference",
children: [
{ title: "Client", slug: "client" },
{ title: "Server", slug: "server" },
],
},
];// lib/mdx.ts
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import matter from "gray-matter";
const CONTENT_DIR = join(process.cwd(), "content", "docs");
export interface DocPage {
slug: string;
title: string;
description: string;
content: string;
version: string;
lastModified: string;
}
export function getDocBySlug(version: string, slug: string): DocPage | null {
const filePath = join(CONTENT_DIR, version, `${slug}.mdx`);
if (!existsSync(filePath)) return null;
const raw = readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
return {
slug,
title: data.title ?? slug,
description: data.description ?? "",
content,
version,
lastModified: data.lastModified ?? new Date().toISOString(),
};
}
export function getAllDocs(version: string): DocPage[] {
const dir = join(CONTENT_DIR, version);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith(".mdx"))
.map((f) => getDocBySlug(version, f.replace(".mdx", "")))
.filter((doc): doc is DocPage => doc !== null);
}// components/Sidebar.tsx
"use client";
import { useState } from "react";
import Link from "next/link";
import { NavItem } from "@/lib/docs-config";
interface SidebarProps {
navigation: NavItem[];
version: string;
currentSlug: string;
}
function NavSection({
item,
version,
currentSlug,
depth,
}: {
item: NavItem;
version: string;
currentSlug: string;
depth: number;
}): JSX.Element {
const [isOpen, setIsOpen] = useState(true);
const hasChildren = item.children && item.children.length > 0;
const isActive = currentSlug === item.slug;
return (
<div>
<div
className={`flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm ${isActive ? "bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-100" : "hover:bg-gray-100 dark:hover:bg-gray-800"} ${depth === 0 ? "font-semibold" : "font-normal"}`}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
onClick={() => hasChildren && setIsOpen(!isOpen)}
>
{hasChildren ? (
<span>{item.title}</span>
) : (
<Link href={`/docs/${version}/${item.slug}`} className="w-full">
{item.title}
</Link>
)}
{hasChildren && <span className="text-xs">{isOpen ? "\u25BC" : "\u25B6"}</span>}
</div>
{hasChildren && isOpen && (
<div>
{item.children!.map((child) => (
<NavSection
key={child.slug}
item={child}
version={version}
currentSlug={currentSlug}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
export function Sidebar({ navigation, version, currentSlug }: SidebarProps): JSX.Element {
return (
<nav className="sticky top-0 h-screen w-64 overflow-y-auto border-r border-gray-200 p-4 dark:border-gray-700">
{navigation.map((item) => (
<NavSection
key={item.slug}
item={item}
version={version}
currentSlug={currentSlug}
depth={0}
/>
))}
</nav>
);
}// lib/toc.ts
export interface TocItem {
id: string;
text: string;
level: number;
}
export function extractToc(content: string): TocItem[] {
const headingRegex = /^(#{2,4})\s+(.+)$/gm;
const items: TocItem[] = [];
let match: RegExpExecArray | null;
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length;
const text = match[2].trim();
const id = text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
items.push({ id, text, level });
}
return items;
}// components/TableOfContents.tsx
"use client";
import { useEffect, useState } from "react";
import { TocItem } from "@/lib/toc";
export function TableOfContents({ items }: { items: TocItem[] }): JSX.Element {
const [activeId, setActiveId] = useState("");
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
}
},
{ rootMargin: "-80px 0px -80% 0px" }
);
for (const item of items) {
const el = document.getElementById(item.id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [items]);
return (
<nav className="sticky top-20 hidden h-fit w-56 xl:block">
<p className="mb-3 text-sm font-semibold text-gray-500 dark:text-gray-400">On this page</p>
<ul className="space-y-1">
{items.map((item) => (
<li key={item.id} style={{ paddingLeft: `${(item.level - 2) * 12}px` }}>
<a
href={`#${item.id}`}
className={`block py-1 text-sm ${
activeId === item.id
? "font-medium text-blue-600 dark:text-blue-400"
: "text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
{item.text}
</a>
</li>
))}
</ul>
</nav>
);
}// lib/search.ts
import { getAllDocs } from "./mdx.js";
export interface SearchResult {
slug: string;
title: string;
excerpt: string;
version: string;
}
export function buildSearchIndex(version: string): SearchResult[] {
const docs = getAllDocs(version);
return docs.map((doc) => {
const plainText = doc.content
.replace(/```[\s\S]*?```/g, "")
.replace(/[#*`\[\]()>_~|]/g, "")
.trim();
return {
slug: doc.slug,
title: doc.title,
excerpt: plainText.substring(0, 200),
version: doc.version,
};
});
}
export function search(index: SearchResult[], query: string): SearchResult[] {
const terms = query.toLowerCase().split(/\s+/);
return index
.filter((item) => {
const text = `${item.title} ${item.excerpt}`.toLowerCase();
return terms.every((term) => text.includes(term));
})
.slice(0, 10);
}// components/SearchDialog.tsx
"use client";
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import { SearchResult, search } from "@/lib/search";
interface SearchDialogProps {
index: SearchResult[];
version: string;
}
export function SearchDialog({ index, version }: SearchDialogProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const results = query.length > 1 ? search(index, query) : [];
useEffect(() => {
function handleKeyDown(e: KeyboardEvent): void {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setIsOpen(true);
}
if (e.key === "Escape") setIsOpen(false);
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
useEffect(() => {
if (isOpen) inputRef.current?.focus();
}, [isOpen]);
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-500 dark:border-gray-600"
>
Search docs...{" "}
<kbd className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800">Ctrl+K</kbd>
</button>
);
}
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[20vh]"
onClick={() => setIsOpen(false)}
>
<div
className="mx-4 w-full max-w-lg rounded-xl bg-white shadow-2xl dark:bg-gray-900"
onClick={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
type="text"
placeholder="Search documentation..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full border-b border-gray-200 bg-transparent px-4 py-3 text-lg outline-none dark:border-gray-700"
/>
<div className="max-h-80 overflow-y-auto p-2">
{results.map((r) => (
<Link
key={r.slug}
href={`/docs/${version}/${r.slug}`}
onClick={() => setIsOpen(false)}
className="block rounded-lg p-3 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<p className="text-sm font-medium">{r.title}</p>
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{r.excerpt}</p>
</Link>
))}
{query.length > 1 && results.length === 0 && (
<p className="py-8 text-center text-sm text-gray-500">No results found</p>
)}
</div>
</div>
</div>
);
}// components/ThemeToggle.tsx
"use client";
import { useState, useEffect } from "react";
export function ThemeToggle(): JSX.Element {
const [isDark, setIsDark] = useState(true);
useEffect(() => {
const saved = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const dark = saved ? saved === "dark" : prefersDark;
setIsDark(dark);
document.documentElement.classList.toggle("dark", dark);
}, []);
function toggle(): void {
const next = !isDark;
setIsDark(next);
localStorage.setItem("theme", next ? "dark" : "light");
document.documentElement.classList.toggle("dark", next);
}
return (
<button
onClick={toggle}
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label="Toggle theme"
>
{isDark ? "Light" : "Dark"}
</button>
);
}// components/VersionSwitcher.tsx
"use client";
import { useRouter } from "next/navigation";
import { DocsVersion } from "@/lib/docs-config";
interface VersionSwitcherProps {
versions: DocsVersion[];
currentVersion: string;
currentSlug: string;
}
export function VersionSwitcher({
versions,
currentVersion,
currentSlug,
}: VersionSwitcherProps): JSX.Element {
const router = useRouter();
function handleChange(e: React.ChangeEvent<HTMLSelectElement>): void {
router.push(`/docs/${e.target.value}/${currentSlug}`);
}
return (
<select
value={currentVersion}
onChange={handleChange}
className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900"
>
{versions.map((v) => (
<option key={v.path} value={v.path}>
{v.label}
</option>
))}
</select>
);
}// app/docs/[version]/[slug]/page.tsx
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { getDocBySlug, getAllDocs } from "@/lib/mdx";
import { extractToc } from "@/lib/toc";
import { buildSearchIndex } from "@/lib/search";
import { navigation, versions } from "@/lib/docs-config";
import { Sidebar } from "@/components/Sidebar";
import { TableOfContents } from "@/components/TableOfContents";
import { SearchDialog } from "@/components/SearchDialog";
import { VersionSwitcher } from "@/components/VersionSwitcher";
import { ThemeToggle } from "@/components/ThemeToggle";
interface PageProps {
params: Promise<{ version: string; slug: string }>;
}
export async function generateStaticParams(): Promise<Array<{ version: string; slug: string }>> {
const params: Array<{ version: string; slug: string }> = [];
for (const v of versions) {
const docs = getAllDocs(v.path);
for (const doc of docs) params.push({ version: v.path, slug: doc.slug });
}
return params;
}
export default async function DocPage({ params }: PageProps): Promise<JSX.Element> {
const { version, slug } = await params;
const doc = getDocBySlug(version, slug);
if (!doc) notFound();
const toc = extractToc(doc.content);
const searchIndex = buildSearchIndex(version);
return (
<div className="flex min-h-screen bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<Sidebar navigation={navigation} version={version} currentSlug={slug} />
<div className="flex flex-1">
<main className="mx-auto max-w-3xl flex-1 px-8 py-12">
<div className="mb-8 flex items-center justify-between">
<SearchDialog index={searchIndex} version={version} />
<div className="flex items-center gap-3">
<VersionSwitcher versions={versions} currentVersion={version} currentSlug={slug} />
<ThemeToggle />
</div>
</div>
<h1 className="mb-4 text-4xl font-bold">{doc.title}</h1>
<p className="mb-8 text-gray-500">{doc.description}</p>
<article className="prose dark:prose-invert max-w-none">
<MDXRemote source={doc.content} />
</article>
</main>
<TableOfContents items={toc} />
</div>
</div>
);
}npm install my-libraryimport { Client } from "my-library";
const client = new Client({ apiKey: process.env.API_KEY });
const result = await client.ping();
console.log(result); // { status: "ok" }npm run dev
# Open http://localhost:3000/docs/v2/installation## Step 10: Build and Deploy
Add build scripts and verify everything works.
```json
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
}
}## {/* content/docs/v2/installation.mdx */}
title: "Installation"
description: "How to install and set up the project"
lastModified: "2025-01-15"
---
## Prerequisites
Make sure you have Node.js 18 or later installed:
```bash
node --version
```