documentation menu

Next.js

Static-export Next.js builds to out/ — set output 'export', and don't pass --spa.

read as markdown

Next.js needs one config change: static export. With it, next build writes a complete static site to out/ and there is no Node server to run.

// next.config.js
module.exports = {
  output: 'export',
  trailingSlash: true,   // required — see below
  images: { unoptimized: true },
};
next.js
$ npm run build▲ Next.js 15.0.0✓ Generating static pages (14/14)✓ Exporting (3/3)$ one9x pages release ./out --site mysite --deploy✓ v1 live→ https://mysite.one9x.app/

No --spa. Static export writes a real HTML file per route, so an unmatched path genuinely is a 404 and should say so. Map your own page with --error 404:/404.html if you have one.

What static export rules out

output: 'export' is not a deploy setting, it is a constraint on the app. These stop working, at build time rather than silently:

  • API routes and route handlers
  • Server-side rendering (getServerSideProps), middleware, server actions
  • Incremental static regeneration
  • next/image default loader — set images: { unoptimized: true }, or point images.loader at an external optimizer

If your app needs any of those, it needs a server, and Pages is a static host — that is a real answer rather than a workaround to find.

Set trailingSlash: true

This one is required, not a preference:

// next.config.js
module.exports = {
  output: 'export',
  images: { unoptimized: true },
  trailingSlash: true,
};

By default the export writes out/about.html, and Pages serves paths rather than routes — so a request for /about, which is exactly what next/link generates, gets a 404. With trailingSlash: true Next writes out/about/index.html and the directory index resolves.

The symptom if you skip it: the homepage works and every internal link 404s.

A note on caching

Next fingerprints its assets under /_next/static/<buildId>/. Because the build id changes on every build even when the file contents don’t, those paths revalidate rather than being cached for a year.

That costs exactly one 304 against a content-hash ETag — the bytes are not re-sent. It is the direction our cache is deliberately built to fail in: never serve stale, occasionally revalidate something that turned out to be unchanged. Nothing to configure.

Start from a template

Live, and deployed by the workflow in that repo on every push: nextjs.one9x.app.

one9x/template-nextjs is a minimal Next.js starter with this deploy already wired up. Press Use this template on GitHub to get your own copy, or read it as a reference for a project you already have.

Set two things in the new repo under Settings → Secrets and variables → Actions and it deploys on every push:

NameValue
VariableONE9X_SITEthe site to deploy to
SecretONE9X_TOKENone9x tokens create "github actions"

Until ONE9X_SITE is set the workflow is skipped rather than failed — a fresh copy should not open with a red X.

Deploying on every push

- uses: actions/setup-node@v5
  with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build
- uses: one9x/pages-deploy@v1
  with:
    site: mysite
    dir: out
    error-pages: "404:/404.html"
    token: ${{ secrets.ONE9X_TOKEN }}
    deploy: ${{ github.ref == 'refs/heads/main' }}