Back to All Articles
reactnextjsmdxtailwindcss

How to Build an Open-Source Documentation Site with Next.js and MDX

Build a lightning-fast developer docs site using Next.js, MDX, and Tailwind with automated sidebar navigation and search.

2026-10-29
How to Build an Open-Source Documentation Site with Next.js and MDX

Let's look at how to build a production-ready documentation site using Next.js App Router, MDX, and Tailwind CSS, similar to modern open-source hubs.

What you'll build:

  • A file-based MDX routing system for technical guides.
  • A responsive two-column layout with persistent sidebar navigation.
  • Optimized typography and custom interactive callout components.

Setting Up File-Based MDX Routing

To keep docs maintainable, we map markdown files directly to URL paths using Next.js dynamic segments. This avoids database overhead and lets contributors write raw markdown.

TSX
// app/docs/[...slug]/page.tsx
import { notFound } from 'next/navigation';
import { getDocBySlug, getAllDocSlugs } from '@/lib/docs';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { CustomComponents } from '@/components/mdx';

interface PageProps {
  params: Promise<{ slug: string[] }>;
}

export async function generateStaticParams() {
  const slugs = await getAllDocSlugs();
  return slugs.map((slug) => ({ slug }));
}

export default async function DocPage({ params }: PageProps) {
  const resolvedParams = await params;
  const slug = resolvedParams.slug || [];
  const doc = await getDocBySlug(slug);

  if (!doc) {
    notFound();
  }

  return (
    <article className="prose prose-invert max-w-none">
      <h1>{doc.meta.title}</h1>
      <MDXRemote source={doc.content} components={CustomComponents} />
    </article>
  );
}

Speed Note: Always use generateStaticParams for documentation sites. Pre-rendering pages at build time cuts TTFB (Time to First Byte) down to zero on CDNs like Vercel or Cloudflare.

Parsing Frontmatter and Content with Gray-Matter

We need a helper utility to read .mdx files from disk, parse frontmatter metadata (like titles and descriptions), and feed the raw string into our MDX renderer.

TSX
// lib/docs.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

const DOCS_PATH = path.join(process.cwd(), 'content/docs');

export async function getDocBySlug(slug: string[]) {
  const realSlug = slug.join('/');
  const fullPath = path.join(DOCS_PATH, `${realSlug}.mdx`);

  if (!fs.existsSync(fullPath)) {
    return null;
  }

  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const { data, content } = matter(fileContents);

  return {
    slug: realSlug,
    meta: data,
    content,
  };
}

Keep your content directory strictly structured. Avoid deep nesting beyond three levels to prevent complex URL structures that frustrate mobile users.

Building the Responsive Sidebar Navigation

A good docs site needs a persistent table of contents. On desktop, we lock it to the left side. On mobile, we collapse it behind a drawer menu to maximize screen real estate.

TSX
// components/Sidebar.tsx
'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';

interface NavItem {
  title: string;
  href: string;
}

const navItems: NavItem[] = [
  { title: 'Getting Started', href: '/docs/getting-started' },
  { title: 'State Management', href: '/docs/state-management' },
  { title: 'API Reference', href: '/docs/api-reference' },
];

export function Sidebar() {
  const pathname = usePathname();

  return (
    <nav className="w-64 space-y-1 hidden md:block shrink-0 border-r border-zinc-800 pr-6">
      <p className="text-xs font-semibold text-zinc-400 uppercase tracking-wider mb-3">
        Documentation
      </p>
      {navItems.map((item) => {
        const isActive = pathname === item.href;
        return (
          <Link
            key={item.href}
            href={item.href}
            className={`block px-3 py-2 rounded-md text-sm font-medium transition-colors ${
              isActive
                ? 'bg-zinc-800 text-white'
                : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900'
            }`}
          >
            {item.title}
          </Link>
        );
      })}
    </nav>
  );
}

Accessibility Tip: Ensure your active link uses aria-current="page" for screen reader users so they know their exact position in the document tree.

Creating Interactive MDX Components

Standard markdown is too rigid for technical docs. By passing custom React components into MDXRemote, authors can embed warnings, tabs, and interactive code playgrounds directly inside markdown files.

TSX
// components/mdx/Callout.tsx
import React from 'react';

interface CalloutProps {
  type?: 'note' | 'warning';
  children: React.ReactNode;
}

export function Callout({ type = 'note', children }: CalloutProps) {
  const isWarning = type === 'warning';
  
  return (
    <div
      className={`my-6 p-4 rounded-lg border text-sm ${
        isWarning
          ? 'bg-amber-950/30 border-amber-800/50 text-amber-200'
          : 'bg-zinc-900/50 border-zinc-800 text-zinc-300'
      }`}
    >
      <div className="font-semibold mb-1 uppercase tracking-wider text-xs">
        {isWarning ? 'Warning' : 'Note'}
      </div>
      <div>{children}</div>
    </div>
  );
}

Map this component inside your global MDX registry file so it automatically parses tags written inside your .mdx files.

Summary

Building a custom documentation site gives you full control over styling, performance, and contributor experience without bloating your bundle with third-party iframe widgets.

Next Steps:

  • Add full-text search using FlexSearch or Algolia DocSearch.
  • Implement an automatic "On this page" right sidebar tracking heading IDs via IntersectionObserver.