Skip to main content
ASoc
Tutorial

Next.js as a Static Site Generator: 754 Pages in 8.8 Seconds, and Why We Skip Export

Use Next.js as a static site generator two ways: output export or the default build. Measured here: 754 prerendered pages in 8.8s, with 8 routes that need a server.

The ASoc Team8 min read

Next.js works as a static site generator in two ways: output: "export" writes a plain out/ folder of HTML you can host anywhere, while the default build prerenders every route that has no per-request input and keeps a server for the rest. This storefront uses the default — 754 static pages built in 8.8 seconds — because eight of its routes need a server.

The decision in one table

output: "export"Default build (this repo)
Outputout/ folder of .html files.next/ with prerendered HTML plus a server
HostingAny static host or object storeA Node-capable host (Vercel, Docker, etc.)
Dynamic routesOnly with generateStaticParams, no dynamicParams: trueSame, plus on-demand rendering when you allow it
proxy.ts (middleware), Server Actions, headers() in configUnsupportedSupported
Route HandlersOnly GET handlers that don't read the requestAny
Login, checkout, webhooksMust live elsewhereSame project

The list of what static export refuses comes straight from the docs shipped in node_modules/next/dist/docs/01-app/02-guides/static-exports.md: Proxy, Server Actions, Cookies, Rewrites, Redirects, Headers, ISR, Draft Mode and the default image loader are all in its "Unsupported Features" section. If a single one of those is load-bearing, you are on the default build whether you wanted a static site or not.

What this repo actually builds

npm run build on this storefront ends with a route table. Reading it is the whole audit:

Generating static pages using 3 workers (754/754) in 8.8s

├ ● /blog/[slug]
│ ├ /blog/supabase-cron-jobs
│ └ [+300 more paths]
├ ● /templates/[slug]
│ ├ /templates/asoc-admin
│ └ [+108 more paths]
├ ○ /pricing  ○ /docs  ○ /license  ○ /sitemap.xml  …
├ ƒ /login  ƒ /signup  ƒ /dashboard  ƒ /api/download  ƒ /auth/callback  …

Three symbols carry the story. ○ is static content with no params, ● is static HTML generated from generateStaticParams, and ƒ is rendered on demand. Here that is 111 product pages, 303 posts, 22 top-level HTML files, and eight ƒ routes: /login, /signup, /reset-password, /dashboard, /dashboard/settings, /auth/callback, /api/download and /api/webhooks/lemonsqueezy. Those eight are the entire reason the project cannot use output: "export" — they need cookies, a database and a signed webhook, which a folder of HTML cannot provide. Everything a visitor or crawler sees before buying is ○ or ●.

The code that makes a dynamic segment static

A dynamic segment is only static if you list its params at build time. The product page in src/app/templates/[slug]/page.tsx does it in one line from the typed catalog:

export function generateStaticParams(): Params[] {
  return catalog.map((p) => ({ slug: p.slug }));
}

The blog does the same from its registry, and adds the flag that makes it behave like a true static site:

export function generateStaticParams(): Params[] {
  return getPublishedSlugs().map((slug) => ({ slug }));
}

/** Nothing outside `generateStaticParams` exists — no on-demand rendering. */
export const dynamicParams = false;

dynamicParams = false means a URL outside the list returns 404 instead of rendering on demand. It is also exactly the setting a static export requires, so a route written this way would survive a later move to output: "export". Content comes from files in the repository (src/data/catalog.ts, src/content/blog/*.mdx), so "content changed" and "new build" are the same event and no ISR is needed. If your content lives in a CMS that editors change between deploys, that assumption breaks — read static rendering in the App Router for what silently flips a route to dynamic.

The defect we shipped around: the OG image route

Static generation is per route, and a sibling file can opt out on its own. Each post's social card is src/app/blog/[slug]/opengraph-image.tsx. Without its own generateStaticParams, that card is rendered per request (ƒ) while the page beside it is baked at build time. The fix is the three lines now in the file:

export function generateStaticParams() {
  return getPublishedSlugs().map((slug) => ({ slug }));
}

The failure is invisible in the browser. The page loads fine; only the build table shows a function where a file should be. It is the most common way a "static" Next.js site quietly grows a server bill.

What the static pages weigh

Because the HTML is real files, you can measure it. After the build above, .next/server/app/pricing.html is 177,306 bytes and the largest product page, templates/asoc-admin.html, is 170,642 bytes before compression. Both are mostly inlined markup plus the serialized React payload Next.js embeds for hydration. Those numbers are why we keep images out of the HTML path: covers are pre-built WebP files in public/, resolved by src/lib/imageVariants.ts, so no request-time optimizer runs. Static export has the same constraint — the default next/image loader is on its unsupported list — and the same fix. See image optimization without next/image.

A one-line audit for every deploy

You do not need a plugin to catch a regression. Save the build output and count the functions:

npm run build | tee build.log
grep -cE "^(├|└) ƒ" build.log   # 8 here: the auth, dashboard, download and webhook routes

Pin that number in CI. This repo's eight is deliberate and nameable; the day it becomes nine, something that used to be a file became a function, and the diff of the route table tells you which. It is the cheapest guard a static-first Next.js project has, and it works the same whether you ever adopt output: "export" or not.

Keeping the static site honest for crawlers

A static build is only as good as the signals it emits. Two rules live in CLAUDE.md and in src/app/sitemap.ts:

  • lastModified in the sitemap is a declared date, never the build clock. A generator that stamps every URL "changed today" on every deploy is making a claim a crawler can check.
  • A coming-soon product is noindex and absent from the sitemap, so unfinished pages cost no crawl budget.

For the broader pattern of generating thousands of pages from data, see programmatic SEO with Next.js.

When to pick which

  • Choose output: "export" for a brochure site, docs or blog with no accounts, deployed to a CDN or object store, where you can live without redirects and custom headers from Next.js itself.
  • Choose the default build when anything on the site is per-user (auth, dashboard, downloads) or you need response headers such as a CSP from next.config.ts — this repo's async headers() is one of the features export rejects.
  • Choose another generator (Astro, Eleventy) if you have no React components to share and want zero client JavaScript by default; see Astro vs Next.js for a marketing site.

Troubleshooting

SymptomCauseFix
next build errors after adding output: "export"A Proxy, Server Action, redirect or header is in useRemove it, or stay on the default build
Dynamic route fails the exportMissing generateStaticParams, or dynamicParams is trueExport the params list and set dynamicParams = false
A page shows ƒ but you expected ○It reads cookies(), headers() or searchParamsMove that read into a Client Component or a separate route
Social image route shows ƒopengraph-image.tsx has no generateStaticParamsAdd one that mirrors the page's
Images 404 or fail after exportDefault image loader needs a serverPre-build files or define a custom loader
Stale content after a data editContent is baked at build timeRedeploy; add ISR only on the default build

FAQ

Is Next.js a static site generator? It can be. next build prerenders every route that needs no per-request data, and output: "export" turns that into a folder of HTML. It is also a server framework, which is why you choose between the two modes.

How long does a large static Next.js build take? This repo prerendered 754 pages in 8.8 seconds on 3 workers, with compile at 15.1 seconds and type-checking at 6.9 seconds either side of it.

Do I need output: "export" to get static pages? No. The default build already serves prerendered HTML for every ○ and ● route. Export is only for hosting without a server.

Can a static Next.js site have a login? Not from the same export. Here the eight auth and commerce routes are server-rendered while the other pages stay static in the same project.

Templates in this post

ASoc Ledger, ASoc Lens and ASoc Magnet are landing-page templates built on the statically prerendered Next.js setup described above.

Browse the full set: Next.js landing page templates and Tailwind landing page templates.

Keep reading

Tutorial11 min read

Storefront Search Without a Search Service

111 products, no search index. The predicate, the rule that stops results looking broken, and the bundle trade we took on one page only.

Read more
Tutorial8 min read

Next.js Testing: 305 Tests, Zero Rendered Components

31 test files, zero jsdom, zero @testing-library — every assertion is a data invariant or a security boundary. What that catches, and what it can't.

Read more
Tutorial10 min read

Next.js Trailing Slash: The Config Isn't Your Problem, the Env Var Is

344 prerendered routes, zero trailing slashes, and no trailingSlash config at all. The bug that did reach production came from one env var, and the one-line fix it now has.

Read more