DEV Community

Cover image for Rebuilding a Next.js MDX docs site without a framework
Jules Sarah
Jules Sarah

Posted on Originally published at applighter.com

Rebuilding a Next.js MDX docs site without a framework

  • You don't need Nextra or Fumadocs for a small Next.js MDX docs site
  • next-mdx-remote/rsc + gray-matter + rehype-pretty-code + shiki covers ~95% of the surface
  • Our whole docs loader is 79 lines; the route is about 30
  • Zero client JS ships for the page body, since Shiki tokenizes at build time
  • Under ~50 pages this is faster to build and easier to change. Over ~100, use a framework.

The stack

{
  "next-mdx-remote": "^6.0.0",
  "gray-matter": "^4.0.3",
  "remark-gfm": "^4.0.1",
  "rehype-pretty-code": "^0.14.3",
  "shiki": "^4.0.2"
}
Enter fullscreen mode Exit fullscreen mode

That's the whole runtime. Everything else is our own code: a docs loader, a dynamic route, and about 100 lines of custom MDX component overrides.

Directory layout

content/
  docs/
    index.mdx
    getting-started/
      installation.mdx
      environment.mdx
      quick-start.mdx
    core-concepts/
      expo-integration.mdx
      supabase.mdx
      ui-components.mdx
Enter fullscreen mode Exit fullscreen mode

Each file starts with three-field frontmatter:

---
title: Introduction
description: One-line summary for meta description and sidebar.
order: 1
---
Enter fullscreen mode Exit fullscreen mode

No schema validation. Forget order and the page goes to the end. Forget title and the sidebar shows the filename. This has bitten us zero times.

The loader

// lib/docs.ts
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";

const DOCS_DIR = path.join(process.cwd(), "content/docs");

export function getAllDocs() {
  return walk(DOCS_DIR).map((filePath) => {
    const raw = fs.readFileSync(filePath, "utf8");
    const { data, content } = matter(raw);
    const slug = filePath
      .replace(DOCS_DIR + path.sep, "")
      .replace(/\.mdx$/, "")
      .split(path.sep);
    return { slug, frontmatter: data, content };
  });
}
Enter fullscreen mode Exit fullscreen mode

The rest of lib/docs.ts is getDocsNavigation(), which walks once and returns an ordered tree, and getDocPrevNext(), which is twelve lines and a disproportionate UX win. Whole file is 79 lines.

The dynamic route

// app/docs/[[...slug]]/page.tsx
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
import rehypePrettyCode from "rehype-pretty-code";
import { mdxComponents } from "@/components/docs/mdx-components";
import { getAllDocs } from "@/lib/docs";

export function generateStaticParams() {
  return getAllDocs().map((doc) => ({ slug: doc.slug }));
}

export default async function DocPage({ params }) {
  const { slug } = await params;
  const doc = getAllDocs().find(
    (d) => d.slug.join("/") === (slug ?? []).join("/"),
  );
  if (!doc) notFound();

  return (
    <MDXRemote
      source={doc.content}
      components={mdxComponents}
      options={{
        mdxOptions: {
          remarkPlugins: [remarkGfm],
          rehypePlugins: [[rehypePrettyCode, { theme: "github-dark-dimmed" }]],
        },
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Zero client JS ships for the page body. Shiki tokenizes code at build time on the server. Prism this is not.

Why we skipped the frameworks

We evaluated Nextra, Fumadocs, and Contentlayer.

Framework Cut because
Nextra Good, but the opinionated theme fights our marketing shell
Fumadocs Genuinely modern. Four packages to learn for eight docs pages.
Contentlayer Effectively unmaintained. Extra build step for a marginal type-safety win.

If you have 100+ pages, use Fumadocs. If you have 8, write the 200 lines.

The MDX component override pattern

// components/docs/mdx-components.tsx
export const mdxComponents = {
  h1: (props) => <h1 className="text-3xl font-bold tracking-tight mt-0 mb-4" {...props} />,
  h2: (props) => <h2 className="text-2xl font-semibold mt-10 mb-3 scroll-mt-24" {...props} />,
  h3: (props) => <h3 className="text-xl font-semibold mt-8 mb-2 scroll-mt-24" {...props} />,
  p: (props) => <p className="my-4 leading-7 text-neutral-200" {...props} />,
  a: (props) => <a className="text-primary underline underline-offset-4" {...props} />,
  code: (props) => <code className="rounded bg-neutral-800 px-1.5 py-0.5 text-sm" {...props} />,
  // 23 overrides in total
};
Enter fullscreen mode Exit fullscreen mode

scroll-mt-24 on headings is a small detail with an outsized payoff. Deep links to #section-name scroll to the heading with your sticky header out of the way, so tickets, Slack threads, and Discord messages linking to /docs/foo#bar land where the reader expects rather than one header-height too high.

Do this on day one. Retrofitting it means re-testing every anchor you've already shared.

What we still don't have

  • Search. Our previous search was window.find(). We removed it. Real full-text search is next quarter.
  • Versioned docs. Not needed yet.
  • OpenAPI reference generation. Not needed at all; our surface is React Native, not REST.
  • Live-editable code blocks. Good idea. Not shipping until we need it.

When this pattern breaks down

Around 50 pages you'll start wanting search. Around 100 you'll want versioned docs. At that point migrate to Fumadocs and enjoy the ride.

Until then the DIY pipeline is faster to build, easier to change, and has no lock-in.

References

What we'd do differently

Ship prev/next navigation on day one, not week two. Twelve lines, and it changes how people move through the docs.

Design the callout component before you write the first .mdx file. If you don't, you'll retrofit thirty pages by hand.

Add anchor IDs on h2 and h3 from the start. Load-bearing for every external link anyone ever shares.

The full comparison and the reasoning behind each package pick is on the Applighter blog.


What's your page-count threshold for reaching for a framework? Mine used to be about 20 and this project moved it considerably.

Top comments (0)