Not every web application needs a Node.js server running in production. Documentation sites, marketing pages, blogs, and developer portals are fundamentally static content: the HTML, CSS, and JavaScript are the same for every visitor. Running a server to generate identical responses on every request wastes compute, adds latency, and introduces an operational dependency that can go down. Static site generation (SSG) pre-renders all pages at build time, producing a directory of HTML files that can be served from any CDN, S3 bucket, or GitHub Pages—no server required.
Next.js supports this pattern through its output: "export" configuration. When enabled, next build produces a fully static out/ directory containing HTML, CSS, JavaScript, and assets for every route. Dynamic routes use generateStaticParams to enumerate all possible parameter combinations at build time. The result is a zero-server deployment that loads instantly from edge CDNs, costs pennies to host, and has no cold starts, no runtime errors from server code, and no server-side security surface.
As an example, a documentation and marketing site can use static export for all its public pages. All pages are pre-rendered at build time, deployed to Cloudflare Pages, and served from 300+ edge locations worldwide. A marketing website can use a hybrid approach: marketing pages are statically generated, while dashboard routes use server-side rendering. Understanding when and how to use static export—and its limitations—is essential for choosing the right rendering strategy for each part of an application.
Setting output: "export" in next.config.js tells Next.js to produce a fully static site. The build process renders every page to HTML, bundles client-side JavaScript, and writes everything to an out/ directory. This directory can be served by any static file host. Features that require a server—like API routes, server actions, middleware, and dynamic headers—are not available in export mode.
For dynamic routes (e.g., /docs/[slug]), generateStaticParams is an async function that returns an array of all possible parameter objects. At build time, Next.js calls this function, iterates over the returned values, and pre-renders one HTML page per parameter combination. This replaces getStaticPaths from the Pages Router and works with the App Router’s nested layout system.
Next.js’s <Image> component uses a server-side image optimization API by default, resizing and converting images on demand. Static export has no server, so this API is unavailable. Setting images: { unoptimized: true } in the Next.js config disables server-side optimization, serving images as-is. For production quality, images should be pre-optimized during the build pipeline or via a CDN’s built-in image transformation features.
Next.js App Router can mix static and dynamic rendering within the same application. Pages that call cookies(), headers(), or use searchParams are dynamically rendered. Pages that only use params and fetch data at build time are statically rendered. In export mode, all pages must be statically renderable. Understanding this boundary helps you design routes that work in both modes.
Static hosts serve files differently than Next.js’s built-in server. A route like /docs/getting-started maps to out/docs/getting-started.html or out/docs/getting-started/index.html depending on the trailingSlash config. The basePath option prefixes all routes for deployment to a subdirectory (e.g., /docs). Both settings must match the static host’s URL rewriting behavior to avoid 404s.
The minimal configuration for static export requires two settings in next.config.js: the output mode and unoptimized images.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
// Required: Next.js Image Optimization requires a server
images: {
unoptimized: true,
},
// Optional: match your hosting platform's URL behavior
trailingSlash: true,
// Optional: deploy to a subdirectory like github.io/repo-name
// basePath: "/my-docs",
};
module.exports = nextConfig;
Dynamic routes must enumerate all possible parameter values at build time. This example generates pages for all documentation files discovered from the filesystem.
// app/docs/[category]/[slug]/page.tsx
import fs from "fs/promises";
import path from "path";
import { notFound } from "next/navigation";
import { loadContent } from "@/lib/content";
const CONTENT_DIR = path.join(process.cwd(), "content");
const CATEGORIES = ["guides", "reference", "api", "changelog"];
// Tell Next.js which pages to generate at build time
export async function generateStaticParams() {
const params: { category: string; slug: string }[] = [];
for (const category of CATEGORIES) {
const dir = path.join(CONTENT_DIR, category);
try {
const files = await fs.readdir(dir);
for (const file of files) {
if (file.endsWith(".mdx")) {
params.push({
category,
slug: file.replace(/\.mdx$/, ""),
});
}
}
} catch {
// Category directory doesn't exist yet — skip
}
}
return params;
}
interface PageProps {
params: Promise<{ category: string; slug: string }>;
}
export default async function DocPage({ params }: PageProps) {
const { category, slug } = await params;
try {
const { content, frontmatter } = await loadContent(category, slug);
return (
<article>
<h1>{frontmatter.title}</h1>
<div>{content}</div>
</article>
);
} catch {
notFound();
}
}
Metadata for statically exported pages is generated at build time and embedded in the HTML. This ensures CDN-served pages have correct <title>, <meta>, and Open Graph tags without requiring JavaScript execution.
// app/docs/[category]/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { category, slug } = await params;
try {
const { frontmatter } = await loadContent(category, slug);
return {
title: `${frontmatter.title} — Docs`,
description: frontmatter.description,
openGraph: {
title: frontmatter.title,
description: frontmatter.description,
type: "article",
publishedTime: frontmatter.date,
authors: [frontmatter.author],
},
};
} catch {
return { title: "Not Found" };
}
}
// app/layout.tsx — shared metadata for all pages
export const metadata: Metadata = {
metadataBase: new URL("https://docs.example.com"),
title: {
template: "%s — Docs",
default: "Documentation",
},
description: "Search infrastructure documentation and guides",
};
The build produces a self-contained out/ directory that can be deployed to any static host. This CI pipeline builds, validates, and deploys to Cloudflare Pages.
# .github/workflows/deploy-docs.yml
name: Deploy Documentation
on:
push:
branches: [main]
paths: ["content/**", "app/**", "components/**"]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
# Validate: ensure all expected pages exist
- name: Validate static output
run: |
test -d out
test -f out/index.html
test -f out/docs/guides/getting-started/index.html
echo "Static output validated"
# Count generated pages
- name: Report page count
run: find out -name "*.html" | wc -l
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
accountId: ${{ secrets.CF_ACCOUNT_ID }}
projectName: my-docs
directory: out
Static export still includes Next.js’s client-side router. After the initial HTML loads, navigation between pages uses client-side routing with prefetched JSON data, avoiding full page reloads. The Link component works identically to server-rendered Next.js.
// components/DocsSidebar.tsx
import Link from "next/link";
interface NavItem {
title: string;
slug: string;
category: string;
}
export function DocsSidebar({ items, current }: {
items: NavItem[];
current: string;
}) {
return (
<nav className="sidebar">
{items.map((item) => (
<Link
key={item.slug}
href={`/docs/${item.category}/${item.slug}`}
className={item.slug === current ? "active" : ""}
prefetch={true}
>
{item.title}
</Link>
))}
</nav>
);
}
A documentation site using static export is fully pre-rendered at build time. The build reads hundreds of MDX files from the content/ directory, compiles each through the remark/rehype pipeline, and generates HTML pages with embedded metadata for SEO. Deployment to Cloudflare Pages takes under 60 seconds, and the site serves globally from edge caches with sub-100ms Time to First Byte.
The changelog section demonstrates generateStaticParams for date-based routes. Each release gets a page at /changelog/2026-03-15, generated from MDX files named by date. The changelog index page lists all releases with frontmatter metadata (version, date, summary) without compiling the full MDX content—gray-matter extracts just the YAML frontmatter for the listing.
For the website project, static export is used selectively. Marketing pages (/, /pricing, /about) are statically generated and cached aggressively. The dashboard (/dashboard/*) uses server-side rendering with authentication. This hybrid approach lets the marketing site survive traffic spikes (Product Hunt launch, Hacker News front page) without scaling servers, while the authenticated dashboard retains full dynamic capabilities.
route.ts), middleware (middleware.ts), server actions, cookies(), headers(), and revalidateTag() all require a running server. Attempting to use them with output: "export" produces build errors. Audit your route tree for server dependencies before enabling export mode.generateStaticParams on dynamic routes: In export mode, every dynamic route segment ([param]) must have a generateStaticParams function. Without it, Next.js cannot know which pages to generate and the build fails. This is easy to miss when adding a new dynamic route to an existing static site.trailingSlash is true, pages are written as out/docs/slug/index.html. If false, they’re written as out/docs/slug.html. If the static host expects one format but the build produces the other, users get 404 errors. Cloudflare Pages and Vercel handle both, but S3 and nginx require explicit configuration.images: { unoptimized: true }: The Next.js <Image> component calls /_next/image for on-demand optimization. In a static export, this endpoint does not exist. Without the unoptimized flag, images render as broken links in production.useEffect + fetch or libraries like SWR/TanStack Query to load dynamic data after the static shell loads. Design loading states so the page feels responsive even before client data arrives.