Product documentation is critical for user adoption. Here is how to build a performant docs site with Next.js.
Step 1: Project Structure
docs/
├── getting-started/
│ ├── installation.mdx
│ ├── quick-start.mdx
│ └── configuration.mdx
├── guides/
│ ├── authentication.mdx
│ ├── database.mdx
│ └── deployment.mdx
├── api-reference/
│ ├── endpoints.mdx
│ └── webhooks.mdx
└── nav.json
Step 2: Navigation Config
// docs/nav.json
{
"sections": [
{
"title": "Getting Started",
"items": [
{ "title": "Installation", "slug": "getting-started/installation" },
{ "title": "Quick Start", "slug": "getting-started/quick-start" },
{ "title": "Configuration", "slug": "getting-started/configuration" }
]
},
{
"title": "Guides",
"items": [
{ "title": "Authentication", "slug": "guides/authentication" },
{ "title": "Database", "slug": "guides/database" },
{ "title": "Deployment", "slug": "guides/deployment" }
]
},
{
"title": "API Reference",
"items": [
{ "title": "Endpoints", "slug": "api-reference/endpoints" },
{ "title": "Webhooks", "slug": "api-reference/webhooks" }
]
}
]
}
Step 3: MDX Setup
pnpm add @next/mdx @mdx-js/react rehype-pretty-code rehype-slug rehype-autolink-headings remark-gfm shiki
// next.config.ts
import createMDX from "@next/mdx";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import remarkGfm from "remark-gfm";
const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: "wrap" }],
[rehypePrettyCode, { theme: "github-dark-default" }],
],
},
});
export default withMDX({
pageExtensions: ["ts", "tsx", "md", "mdx"],
});
Step 4: Sidebar Navigation Component
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, ChevronRight } from "lucide-react";
import navData from "@/docs/nav.json";
interface NavItem {
title: string;
slug: string;
}
interface NavSection {
title: string;
items: NavItem[];
}
export function DocsSidebar() {
const pathname = usePathname();
return (
<nav className="sticky top-20 h-[calc(100vh-5rem)] w-64 overflow-y-auto border-r py-6 pr-4 dark:border-gray-800">
{(navData.sections as NavSection[]).map((section) => (
<SidebarSection
key={section.title}
section={section}
currentPath={pathname}
/>
))}
</nav>
);
}
function SidebarSection({
section,
currentPath,
}: {
section: NavSection;
currentPath: string;
}) {
const hasActiveItem = section.items.some(
(item) => currentPath === `/docs/${item.slug}`
);
const [expanded, setExpanded] = useState(hasActiveItem);
return (
<div className="mb-4">
<button
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between py-1.5 text-sm font-semibold"
>
{section.title}
{expanded ? (
<ChevronDown className="h-4 w-4 text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 text-gray-400" />
)}
</button>
{expanded && (
<ul className="mt-1 space-y-0.5 border-l dark:border-gray-800">
{section.items.map((item) => {
const isActive = currentPath === `/docs/${item.slug}`;
return (
<li key={item.slug}>
<Link
href={`/docs/${item.slug}`}
className={`block border-l-2 py-1.5 pl-4 text-sm transition-colors ${
isActive
? "border-blue-500 font-medium text-blue-600"
: "border-transparent text-gray-600 hover:border-gray-300 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
{item.title}
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
Step 5: Table of Contents
"use client";
import { useEffect, useState } from "react";
interface TOCItem {
id: string;
text: string;
level: number;
}
export function TableOfContents() {
const [headings, setHeadings] = useState<TOCItem[]>([]);
const [activeId, setActiveId] = useState("");
useEffect(() => {
const elements = document.querySelectorAll("article h2, article h3");
const items: TOCItem[] = Array.from(elements).map((el) => ({
id: el.id,
text: el.textContent ?? "",
level: el.tagName === "H2" ? 2 : 3,
}));
setHeadings(items);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: "-80px 0px -80% 0px" }
);
elements.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
if (headings.length === 0) return null;
return (
<nav className="sticky top-20 hidden w-56 xl:block">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500">
On this page
</p>
<ul className="space-y-1.5">
{headings.map((heading) => (
<li key={heading.id} style={{ paddingLeft: heading.level === 3 ? "12px" : 0 }}>
<a
href={`#${heading.id}`}
className={`block text-xs leading-relaxed transition-colors ${
activeId === heading.id
? "font-medium text-blue-600"
: "text-gray-500 hover:text-gray-900 dark:hover:text-gray-300"
}`}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
);
}
Step 6: Docs Layout
// app/docs/layout.tsx
import { DocsSidebar } from "@/components/docs/Sidebar";
import { TableOfContents } from "@/components/docs/TableOfContents";
export default function DocsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto flex max-w-7xl px-4">
<DocsSidebar />
<main className="min-w-0 flex-1 px-8 py-6">
<article className="prose dark:prose-invert max-w-none">{children}</article>
</main>
<TableOfContents />
</div>
);
}
Step 7: Previous/Next Navigation
import Link from "next/link";
import { ArrowLeft, ArrowRight } from "lucide-react";
import navData from "@/docs/nav.json";
function getAllPages() {
return navData.sections.flatMap((s) => s.items);
}
export function DocsPagination({ currentSlug }: { currentSlug: string }) {
const pages = getAllPages();
const currentIndex = pages.findIndex((p) => p.slug === currentSlug);
const prev = currentIndex > 0 ? pages[currentIndex - 1] : null;
const next = currentIndex < pages.length - 1 ? pages[currentIndex + 1] : null;
return (
<div className="mt-12 flex justify-between border-t pt-6 dark:border-gray-800">
{prev ? (
<Link
href={`/docs/${prev.slug}`}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-blue-600 dark:text-gray-400"
>
<ArrowLeft className="h-4 w-4" />
{prev.title}
</Link>
) : (
<div />
)}
{next ? (
<Link
href={`/docs/${next.slug}`}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-blue-600 dark:text-gray-400"
>
{next.title}
<ArrowRight className="h-4 w-4" />
</Link>
) : (
<div />
)}
</div>
);
}
Step 8: Search with Fuse.js
"use client";
import { useState, useEffect, useRef } from "react";
import Fuse from "fuse.js";
import { Search, X } from "lucide-react";
import { useRouter } from "next/navigation";
interface SearchItem {
title: string;
slug: string;
section: string;
content: string;
}
export function DocsSearch({ items }: { items: SearchItem[] }) {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const fuse = new Fuse(items, {
keys: ["title", "content"],
threshold: 0.3,
includeMatches: true,
});
const results = query ? fuse.search(query).slice(0, 8) : [];
// Keyboard shortcut (Cmd+K)
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setIsOpen(true);
inputRef.current?.focus();
}
if (e.key === "Escape") {
setIsOpen(false);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
function navigateToResult(slug: string) {
router.push(`/docs/${slug}`);
setIsOpen(false);
setQuery("");
}
return (
<>
<button
onClick={() => setIsOpen(true)}
className="flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm text-gray-500 dark:border-gray-700"
>
<Search className="h-4 w-4" />
<span>Search docs...</span>
<kbd className="ml-2 rounded border px-1.5 text-xs dark:border-gray-700">
Cmd+K
</kbd>
</button>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]">
<div className="absolute inset-0 bg-black/50" onClick={() => setIsOpen(false)} />
<div className="relative w-full max-w-lg rounded-xl bg-white shadow-2xl dark:bg-gray-900">
<div className="flex items-center border-b px-4 dark:border-gray-800">
<Search className="h-4 w-4 text-gray-400" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search documentation..."
className="flex-1 bg-transparent px-3 py-4 text-sm focus:outline-none"
autoFocus
/>
<button onClick={() => setIsOpen(false)}>
<X className="h-4 w-4 text-gray-400" />
</button>
</div>
{results.length > 0 && (
<ul className="max-h-80 overflow-y-auto p-2">
{results.map(({ item }) => (
<li key={item.slug}>
<button
onClick={() => navigateToResult(item.slug)}
className="w-full rounded-lg px-3 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-800"
>
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-gray-500">{item.section}</p>
</button>
</li>
))}
</ul>
)}
{query && results.length === 0 && (
<p className="p-6 text-center text-sm text-gray-400">
No results for "{query}"
</p>
)}
</div>
</div>
)}
</>
);
}
Summary
- MDX for rich, interactive documentation content
- Collapsible sidebar navigation from a JSON config
- Table of contents with scroll-based active highlighting
- Full-text search with Cmd+K shortcut
- Previous/next page navigation
Need Documentation for Your Product?
We build polished documentation sites that help users succeed. Contact us to discuss your project.