# Next.js

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

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.

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

```console title="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:

```js
// 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](https://nextjs.one9x.app/)**.

[`one9x/template-nextjs`](https://github.com/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

```yaml
- 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' }}
```