Skip to content
2 min read498 words

Shipping a static site to the edge

Notes on turning a Next.js app into a folder of HTML files with zero runtime, and what that choice costs you.

This site is a folder of files. There is no server, no database connection, no cold start, and nothing to scale. It cost me one line of config and a handful of small surrenders.

ts
// next.config.ts
const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  images: { unoptimized: true },
}

What you get

The build writes an out/ directory. Upload it to Cloudflare, Netlify, GitHub Pages, an S3 bucket, or a USB stick pointed at the right web server. Every route is prerendered during next build.

  • No cold starts. Ever. It's HTML.
  • No runtime bill. It's free-tier traffic forever.
  • No server attack surface. There is no server.
  • Cache headers are the CDN's problem. Cache everything, forever.

The three things that actually break

1. next/image

The image optimizer is a server. It will make the build fail loudly rather than quietly, which is at least honest:

txt
Image Optimization using the default loader is not compatible with { output: 'export' }

Your options, in order of preference:

  1. Static imports — import hero from "./hero.png" infers width/height and gives you a blurDataURL for free.
  2. unoptimized per component — automatic for SVG sources.
  3. A custom loader pointing at your own CDN or an image service.

For a portfolio site, static imports are almost always enough.

2. Anything dynamic

Unsupported under output: "export":

  • Dynamic routes without generateStaticParams()
  • Route Handlers that read a Request
  • cookies(), headers(), rewrites, redirects
  • ISR, draft mode, server actions

Every dynamic route must enumerate itself at build time:

tsx
export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}
 
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }> }) {
  const { slug } = await params
  // params is a Promise in Next 15+ — must be awaited
}

Note the Promise. In Next 15 the sync access was deprecated; in Next 16 it's removed. If you see a params.slug type error, this is why.

3. dynamic route config and cache components

export const dynamic = "force-static" still works, but the flag set has been shrinking each release. The safest posture for a fully static site is to set nothing at all and let every route be statically inferred.

Metadata still works

sitemap.ts and robots.ts are route handlers that are cached by default, so they get prerendered into out/ like everything else. generateMetadata runs at build time per route, which means per-post Open Graph titles come for free:

tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = getPostBySlug(slug)
  return { title: post?.title, description: post?.description }
}

If a metadata file touches a request-time API, the export build fails — which is the correct outcome.

The performance consequence

A static export lets you be genuinely aggressive, because there's no server to protect:

  • Fonts self-host at build time. No third-party connection, no FOUT, no privacy leak.
  • JavaScript is whatever you actually shipped.
  • Images can be pre-converted to the exact sizes the layout uses.

This page loads no analytics, no tag manager, no consent banner, and no cookie of any kind. There is nothing to consent to.

When not to do this

Static export is the wrong tool the moment you need per-request anything: auth, A/B tests, ?utm_source= server-side handling, or a CMS preview mode.

But most personal sites, documentation, portfolios and marketing pages never need any of that. For those, output: "export" isn't a compromise — it's the correct architecture that everyone used before we decided SSR was mandatory.

Filed under MAR 09 '26

All posts →

Command palette

Search for a command to run