Build a Tiny Docs Site with Next.js in Under an Hour

A full-blown documentation platform is overkill when you're shipping something small. Docusaurus takes 15 minutes just to configure the sidebar. Mintlify wants you to sign up for an account. Sometimes you just want a few pages of markdown that look decent.
We're going to scaffold a Next.js app, wire up a dead-simple "docs engine" backed by a TypeScript array, and render markdown pages. The whole thing takes about 45 minutes if you type slowly.
Scaffold the Next.js app
npx create-next-app@latest simple-docs-site
cd simple-docs-siteAccept the defaults. We're assuming the App Router (app/ directory), which is what newer versions of Next.js give you out of the box.
Add some docs data
No database, no CMS, no API calls. Just a TypeScript file with markdown strings. Ugly? Sure. But it works, and you can swap it for Ghost or Contentful later without touching your page components.
Create a lib/ folder in the project root (Next.js doesn't generate one for you), then add lib/docs.ts:
// lib/docs.ts
export type Doc = {
slug: string;
title: string;
content: string; // markdown
};
export const docs: Doc[] = [
{
slug: "getting-started",
title: "Getting Started",
content: `
# Getting Started
Welcome to our docs site!
This is a simple markdown-powered page.
## What you can do
- Learn the basics
- Click around
- Extend it later with a real CMS
\`\`\`bash
npm install
npm run dev
\`\`\`
`,
},
{
slug: "api-reference",
title: "API Reference",
content: `
# API Reference
A fake API for demonstration:
\`\`\`http
GET /api/users
\`\`\`
Returns a list of users.
`,
},
];
export function getAllDocs() {
return docs;
}
export function getDocBySlug(slug: string) {
return docs.find((doc) => doc.slug === slug);
}Three exports: the full list, a "get all" helper, and a "get one by slug" helper. That's your entire data layer.
Install dependencies
You need two packages: marked to convert markdown into HTML, and @tailwindcss/typography so the rendered HTML actually looks good (the prose classes we'll use later come from this plugin).
npm install marked @tailwindcss/typographyThen add the plugin to your Tailwind config. If you're on Tailwind v4 (the default with latest Next.js), add this import to your CSS file, typically app/globals.css:
@import "tailwindcss";
@plugin "@tailwindcss/typography";If you're on Tailwind v3 instead, add it to the plugins array in tailwind.config.ts:
// tailwind.config.ts
import type { Config } from "tailwindcss";
import typography from "@tailwindcss/typography";
export default {
// ...
plugins: [typography],
} satisfies Config;Create the docs list page
Replace app/page.tsx:
import Link from "next/link";
import { getAllDocs } from "@/lib/docs";
export default function HomePage() {
const allDocs = getAllDocs();
return (
<main className="max-w-2xl mx-auto py-10">
<h1 className="text-3xl font-bold mb-4">Simple Docs</h1>
<p className="mb-6 text-gray-600">
A tiny docs site powered by Next.js and Markdown.
</p>
<ul className="space-y-3">
{allDocs.map((doc) => (
<li key={doc.slug}>
<Link
href={`/docs/${doc.slug}`}
className="text-blue-600 hover:underline"
>
{doc.title}
</Link>
</li>
))}
</ul>
</main>
);
}Maps over the docs array and renders links. Nothing else going on.
Create the dynamic docs page
Create app/docs/[slug]/page.tsx:
import { notFound } from "next/navigation";
import { marked } from "marked";
import { getAllDocs, getDocBySlug } from "@/lib/docs";
type DocPageProps = {
params: Promise<{ slug: string }>;
};
export default async function DocPage({ params }: DocPageProps) {
const { slug } = await params;
const doc = getDocBySlug(slug);
if (!doc) {
return notFound();
}
const html = marked.parse(doc.content) as string;
return (
<main className="max-w-2xl mx-auto py-10">
<a href="/" className="text-sm text-gray-500 hover:underline">
← Back to docs
</a>
<h1 className="text-3xl font-bold mt-4 mb-6">{doc.title}</h1>
<article
className="prose prose-sm sm:prose lg:prose-lg"
dangerouslySetInnerHTML={{ __html: html }}
/>
</main>
);
}
export async function generateStaticParams() {
const docs = getAllDocs();
return docs.map((doc) => ({ slug: doc.slug }));
}A couple things to note. In Next.js 15+, params is a Promise, so you need to await it before reading the slug. And marked.parse() can return string | Promise<string> depending on configuration, so the as string cast keeps TypeScript happy in synchronous mode.
The generateStaticParams function pre-renders every page at build time. If someone hits a slug that doesn't exist, they get a 404.
Run it
npm run devVisit / for the docs list, /docs/getting-started for an individual page. That's a working docs site.
Enable server-side rendering
Right now, generateStaticParams pre-builds every page as static HTML during next build. Pages are fast but frozen: if your docs content changes, visitors won't see updates until you rebuild and redeploy.
For the hardcoded TypeScript array above, that's fine. But once you swap it for a CMS or database (which you probably will), you'll want pages that reflect the latest content without a full rebuild. That's where server-side rendering comes in.
With SSR, each request generates fresh HTML on the server. Search engines and social media crawlers get the fully rendered page on the first request, which matters for SEO and link previews. Your content stays current without redeploying.
The change is small. Remove generateStaticParams and add a dynamic export at the top of the file:
// app/docs/[slug]/page.tsx
// Force server-side rendering on every request
export const dynamic = "force-dynamic";
// ... rest of the component stays the sameIf you don't need every request to be fresh, ISR (Incremental Static Regeneration) is a middle ground. Replace the dynamic export with a revalidation interval, and keep generateStaticParams so pages are still pre-built at deploy time:
// Rebuild this page at most once every 60 seconds
export const revalidate = 60;
export async function generateStaticParams() {
const docs = getAllDocs();
return docs.map((doc) => ({ slug: doc.slug }));
}ISR gives you the speed of static pages with content that updates automatically. For most docs sites pulling from a CMS, a 60-second revalidation window is a good default.
Where to go from here
The hardcoded TypeScript array was useful for getting something running fast, but it won't scale. Move your markdown into actual .md files and read them with fs.readFileSync at build time, or pull content from Ghost using its Content API. A sidebar and search would make navigation less painful once you have more than a handful of pages.
One thing to watch: we're using dangerouslySetInnerHTML here, which is safe because you control the markdown source. If you ever render content from untrusted users, sanitize the HTML first with a library like DOMPurify.
If you'd rather skip the plumbing entirely, Jamdesk handles OpenAPI support, AI-ready docs, and Git-based deployment out of the box.
More reading: