Skip to main content
Back to Blog
Tutorials
4 min read
November 23, 2024

How to Build a Documentation Site with Next.js

Create a full documentation site with MDX content, sidebar navigation, search, code highlighting, and versioning.

Ryel Banfield

Founder & Lead Developer

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.

documentationMDXNext.jsdocssearchtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles