Next.js
Static-export Next.js builds to out/ — set output 'export', and don't pass --spa.
read as markdownNext.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 },
};
$ 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/imagedefault loader — setimages: { unoptimized: true }, or pointimages.loaderat 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:
| Name | Value | |
|---|---|---|
| Variable | ONE9X_SITE | the site to deploy to |
| Secret | ONE9X_TOKEN | one9x 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' }}