Installation

Add PzzaDocs to a Next.js 16 app and mount the docs routes.

PzzaDocs is a regular npm package. It plugs into an existing Next.js App Router project; you do not need a template or a separate build step.

Prerequisites#

  • A Next.js 16 project using the App Router (app/ directory).
  • React 19 and React DOM 19. Both are peer dependencies of PzzaDocs and are not installed for you.
  • Node.js 24 or newer.

Install the package#

bash
npm install pzzadocs

The tabs above are generated from a single package-install fence; the choice is remembered across every install block on the site. The package declares next, react, and react-dom as peer dependencies. If your project already has them, nothing else is required. The MDX compiler, syntax highlighter, and theme provider are bundled as regular dependencies.

Set up the project#

Create the config file#

Put a docs.config.ts at the root of your project. defineConfig type-checks the object and fills in defaults for contentDir, baseUrl, search, and theme.

docs.config.ts
import { defineConfig } from "pzzadocs";

export default defineConfig({
  title: "My Docs",
  description: "Documentation for my project.",
  contentDir: "content/docs",
  baseUrl: "/docs",
  nav: {
    links: [{ text: "Overview", href: "/docs" }],
    github: "https://github.com/your-org/your-repo",
  },
  editUrl: (page) => `https://github.com/your-org/your-repo/edit/main/content/docs/${page.file}`,
});

Adjacent fences with a tab attribute merge into one tabbed block, which is how the two variants above are rendered.

Create the content source#

The source is the server-side object that reads and compiles your MDX. Create it once and import it wherever you need pages.

lib/source.ts
import { createSource } from "pzzadocs";

import config from "@/docs.config";

export const source = createSource(config);

Add the root layout#

Load your fonts with next/font/google, expose them as the --pd-font-sans and --pd-font-mono CSS variables, import the stylesheet, and wrap the app in RootProvider with the theme and search sections of your config. suppressHydrationWarning on <html> is required because the theme class is applied on the client before React hydrates.

app/layout.tsx
import type { ReactNode } from "react";
import { Geist, Geist_Mono } from "next/font/google";
import { RootProvider } from "pzzadocs/ui";
import "pzzadocs/styles.css";

import config from "@/docs.config";

const sans = Geist({
  subsets: ["latin"],
  weight: ["400", "500", "600"],
  variable: "--pd-font-sans",
});

const mono = Geist_Mono({
  subsets: ["latin"],
  weight: ["400", "500"],
  variable: "--pd-font-mono",
});

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" className={`${sans.variable} ${mono.variable}`} suppressHydrationWarning>
      <body>
        <RootProvider theme={config.theme} search={config.search}>
          {children}
        </RootProvider>
      </body>
    </html>
  );
}

Add the docs layout#

DocsLayout renders the header, the sidebar, and the mobile drawer. It needs the page tree from the source and your config.

app/docs/layout.tsx
import type { ReactNode } from "react";
import { DocsLayout } from "pzzadocs/ui";

import config from "@/docs.config";
import { source } from "@/lib/source";

export default async function Layout({ children }: { children: ReactNode }) {
  const tree = await source.getPageTree();
  return (
    <DocsLayout tree={tree} config={config}>
      {children}
    </DocsLayout>
  );
}

Add the catch-all page#

One optional catch-all route serves every doc. generateStaticParams pre-renders all pages at build time, generateMetadata reads the frontmatter, and notFound() handles unknown slugs. DocsPage derives breadcrumbs from tree and url, renders the TOC from toc, builds the prev/next footer from neighbours, and shows the page actions menu (copy Markdown, open in GitHub, open in an assistant) when editUrl or markdownUrl is set.

app/docs/[[...slug]]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "pzzadocs/ui";

import config from "@/docs.config";
import { source } from "@/lib/source";

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

export async function generateStaticParams() {
  const pages = await source.getPages();
  return pages.map((page) => ({ slug: page.slug }));
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug = [] } = await params;
  const page = await source.getPage(slug);
  if (!page) {
    return {};
  }
  return {
    title: page.title,
    description: page.description,
  };
}

export default async function Page({ params }: PageProps) {
  const { slug = [] } = await params;
  const page = await source.getPage(slug);
  if (!page) {
    notFound();
  }

  const [tree, neighbours] = await Promise.all([source.getPageTree(), source.getNeighbours(slug)]);

  return (
    <DocsPage
      toc={page.toc}
      tree={tree}
      url={page.url}
      neighbours={neighbours}
      full={page.full}
      lastModified={page.lastModified}
      editUrl={config.editUrl?.(page)}
      markdownUrl={config.llm.enabled ? config.llm.markdownPath(page.url) : undefined}
    >
      <DocsTitle>{page.title}</DocsTitle>
      <DocsDescription>{page.description}</DocsDescription>
      <DocsBody>{page.content}</DocsBody>
    </DocsPage>
  );
}

Add the search index route#

The search dialog fetches a JSON index once and filters it on the client. Serve it from a route handler. Marking it force-static lets Next.js emit it as a static file at build time.

app/api/search/route.ts
import { source } from "@/lib/source";

export const dynamic = "force-static";

export async function GET() {
  return Response.json(await source.getSearchIndex());
}

Write your first page#

Create content/docs/index.mdx. The title field is required; everything else is optional.

content/docs/index.mdx
---
title: Welcome
description: The first page of your documentation.
---

## Hello

This page is rendered from MDX by PzzaDocs.

Start the dev server and open /docs.

Optional: Markdown and llms.txt routes#

The page actions menu links to a raw Markdown version of each page, and assistants can discover the whole site through llms.txt. Add three route handlers; see LLM outputs for the details and the rewrite that serves them at the default .mdx URLs.

app/llms.txt/route.ts
import { source } from "@/lib/source";

export const dynamic = "force-static";

export async function GET() {
  return new Response(await source.getLlmsTxt(), {
    headers: { "content-type": "text/plain; charset=utf-8" },
  });
}

Where does the config live?

docs.config.ts is a plain module. It can live anywhere; the examples above import it with the @/ alias, which Next.js maps to the project root when paths is set in tsconfig.json.

Next steps#