# next-zones > Zones for Next.js (`@runsnip/next-zones`, Apache-2.0). Build each part of a product as its own Next.js app (App Router, Pages Router, or both), a *zone*, with its own version, and serve them all as one app: one origin, one Node process, soft `` navigation between zones' App Router pages with client state kept, one React, and a module the zones share loaded once. A new version of a zone is installed into the running server without a restart, and rolled back the same way. Next.js 16.3.6, 16.3.7, 16.3.8 and 16.4.0 (production server, Turbopack), Node.js 24. This file is for language models. `llms-full.txt`, beside it, holds every page of the documentation in one file. In an installed project both are in `node_modules/@runsnip/next-zones/`. ## Essentials These are the facts an assistant most often gets wrong. Each is detailed in the pages below. - **Not Next.js Multi-Zones.** Next's Multi-Zones runs one server per zone and hard-navigates between them. next-zones runs every zone in one process (Zones) with soft navigation. Do not answer with `basePath` per zone, `rewrites` to other servers or `assetPrefix`. - **A zone is a normal Next.js app.** It builds with `next build` and runs alone with `next start`; next-zones is needed only to serve zones together. - **The shell** is the zone mounted at `/` (no other flag). Exactly one per workspace. It owns the root layout, sign-in, `proxy.ts` and every route no other zone owns. - **A mount is one URL segment** (`/blog`: lowercase letters, digits, `-`). Every route of a zone lives under it: the files are under `app/blog/…` (or `pages/blog/…`), never at `app/page.tsx` of the zone. Two zones on one mount are refused. A URL at the root that a zone serves is an alias: `aliases: [{ source: "/post/:id", destination: "/blog/:id" }]`. - **Declaring a zone: one object.** ```js // next.config.mjs import { zoneConfig } from "@runsnip/next-zones/config"; export default zoneConfig({ mount: "/blog", aliases: [], transpilePackages: ["shared"] }); ``` `zoneConfig` takes out its own keys (`mount`, `aliases`, `livePull`, `endpoints`, `mode`, `metrics`) and passes every other key to Next unchanged. A Next config that is a function of the phase comes second: `zoneConfig({ mount: "/blog" }, (phase) => ({ … }))`; Next keys left in the first object are kept; the function's result wins on a key both set. The older form `zoneConfig({ mount }, { …nextConfig })` is still read the same way. `endpoints`, `mode` and `metrics` are the shell's only. - **What a zone may not do** (Zones refuses it at install, with the reason): its own `proxy.ts`, an `instrumentation-client` file, the edge runtime (today; planned), routes outside its mount or aliases (`pages/api/`, served at `/api/…`, among them), files at the root of `public/` (put them under `public//…`), and `basePath`, `i18n`, `trailingSlash`, `assetPrefix`, `skipTrailingSlashRedirect`, `cacheComponents`, `partialPrefetching` different from the shell's, or an `images` key the zone sets that differs from the shell's (a zone with no `images` config fits any shell; zone-level `images` is planned). Every zone uses the shell's Next, React and react-dom versions. - **Build options are next-zones' to set.** Only in `next-zones build`, `zoneConfig` turns Turbopack's scope hoisting, unused-export and unused-import removal off (`experimental.turbopackScopeHoisting`, `turbopackRemoveUnusedExports`, `turbopackRemoveUnusedImports`: `false`) and pins the project root (`turbopack.root`). Leave them unset (setting them to `false` is harmless; `turbopack.root`/`outputFileTracingRoot` may be set if the shell and every zone share that root): a config that sets any of the three otherwise is refused in a build for Zones. Zones refuses an image built without them. - **Building** (in the workspace's folder): - `next-zones build`: with the shell's `mode: "zones"` (the default), the shell as an app plus every other zone as a *zone image* in `.zones-store///` (with `--pack`, a `.tgz` in `.zones-images/`), and `zones.json` pinning the versions; with `mode: "single"`, every zone linked into one standard Next app (no Zones at run time). - `next-zones build blog`: only that zone's image, to release it alone. - An image's version is the `version` in the zone's `package.json` (`--version` overrides). Images are immutable: bump the version to rebuild. - **Running:** `next-zones start` (what `build` made), `next-zones serve --shell --store `, or from code `createZones({ shell, store })` from `@runsnip/next-zones/zones`, then `zones.listen(port)`. One Zones per process, created before anything requires Next. - **Releasing a zone while it serves:** `next-zones install blog 1.4.0 --url http://127.0.0.1:3000`, or `zones.install("blog", "1.4.0")`. The same command rolls back to an earlier version still in the store. `install`, `pull --url` and `prune --url` talk to Zones' admin URLs, so the shell must declare `endpoints: { admin: true }` (else 404). - **A zone image enters a store only by a pull from a source**, never by an upload: a folder, a URL template (`https://host/{zone}/{version}.tgz`, `.tgz` or `.zip`, resumed on a dropped connection) or service-connector. `next-zones pull` pulls on the server side. A ping to a running Zones (`next-zones pull … --url`, or installing a version the store lacks) pulls only a zone declaring `livePull: true`; for any other zone, run `next-zones pull --store --source …` on the server (safe beside a running Zones), then `install --url`. Old images are pruned (`--keep 2` by default). - **Zones' own URLs** (`/_next-zones/…`) exist only when the shell declares them: `endpoints: { events, health, admin }`. `events` and `health` are public (health's details go to an admin only); `admin` URLs need `Authorization: Bearer `, or a request from the same machine when no token is set. - **The Pages Router works** (every mode): a zone's pages live in `pages//…`, its own `_app` and `_document` at the top of `pages/`. Each Pages Router page renders whole in its zone's build (its document, build id, chunks): soft navigation inside the zone keeps `_app` state; going to another zone, or between the App Router and the Pages Router, loads a new document (Next's rule). `getStaticProps`, `getStaticPaths` (with `fallback`, `revalidate`) and `getServerSideProps` work. - **Open tabs follow a new version** when the shell renders `` from `@runsnip/next-zones/client` and declares `endpoints: { events: true }`; a Pages Router zone renders it in its own `_app` too. - **Metrics:** the shell's `metrics: true` opens one store for the process, read as Prometheus text at `/_next-zones/metrics` by an admin. `metrics: true` alone opens that URL: no `endpoints` declaration is needed. A zone's code measures with `counter`, `gauge`, `histogram` and `time` from `@runsnip/next-zones/metrics`; with metrics off they do nothing. - **Developing:** `next-zones dev ` runs every zone in one `next dev`, with HMR and soft navigation. `next-zones doctor ` checks a workspace and says what to fix. - **Entry points:** `@runsnip/next-zones/config` (`zoneConfig`, `readZone`), `/client` (`ZoneUpdates`), `/zones` (`createZones`), `/build`, `/sources` (`fromDirectory`, `fromHttp`, `fromConnector`, `packZoneImage`), `/metrics`, `/tsconfig/zone.json`; the `next-zones` command. The documentation follows, page by page. --- # next-zones **Zones for Next.js.** Build each part of a product as its own Next app, a *zone*, with its own version. Serve them as one app: one origin, soft navigation between zones' App Router pages, one React. A new version of a zone can be loaded into the running server without a restart. > **Status: pre-release.** It targets Next.js **16.4.0** and **16.3.8** (and 16.3.6, 16.3.7), App Router and Pages Router, production server. > - **Available:** > - the zone declaration (`zoneConfig`); > - the CLI: `init`, `add`, `check`, `doctor`, `dev`, `build`, `start`, `serve`, `install`, `pack`, `pull`, `prune`, > `watch`; > - Zones (`createZones`), which installs and swaps zones at run time; > - one combined build of every zone (`mode: "single"`, the composer); > - ``; > - metrics: Zones' own and any zone's, as Prometheus text (`@runsnip/next-zones/metrics`). ## Why Next.js has [Multi-Zones](https://nextjs.org/docs/app/guides/multi-zones): - every zone is a separate server; - moving between zones reloads the page and drops client state. next-zones keeps the separation (each zone builds, versions and deploys on its own) without those costs: | | Next Multi-Zones | next-zones | |---|---|---| | Moving between zones | hard navigation | **soft** `` navigation between App Router pages; client state kept | | Servers | one per zone | **one** for all zones | | A module shared by zones | loaded once per zone | loaded **once** | | Releasing a zone | redeploy its server | **install it live**, roll back in milliseconds | ## Pages 1. [Concepts](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/concepts.md): zones, the shell, mounts, aliases, versions 2. [Getting started](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/getting-started.md) 3. [Configuration: `zoneConfig`](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/configuration.md) 4. [CLI](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/cli.md): every command 5. [Root URLs: aliases](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/aliases.md) 6. [Routing rules: a zone's headers, redirects and rewrites](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/routing-rules.md) 7. [Instrumentation](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/instrumentation.md) 8. [Assets: fonts, images, public files](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/assets.md) 9. [Live updates and requirements](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/updates.md) 10. [Zones: live installs](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md) 11. [Metrics](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/metrics.md) 12. [Zones on the Pages Router](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/pages-router.md) 13. [What is supported](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/support.md) ## For assistants The package ships `llms.txt` (the essentials) and `llms-full.txt` (every page above in one file), and a skill for coding assistants, `skills/next-zones/SKILL.md`: copy its folder into a project's skills folder (for Claude Code, `.claude/skills/`) so an assistant reads those files before it answers. ## License Apache-2.0. --- # Concepts ## Zone A zone is a normal Next.js app (the App Router, the [Pages Router](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/pages-router.md), or both), so it builds with `next build` and runs alone with `next start`. It declares itself in its `next.config` with [`zoneConfig`](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/configuration.md). A zone never needs next-zones to run: - **On its own**, it is just a Next app. - **Together with other zones**, it is served by Zones, or built with them into one Next app (the composer). ## The shell The shell is the zone mounted at `/`. There is no special flag: it is simply the owner of `/`. It holds what every page shares: - the root layout; - sign-in and the proxy (`proxy.ts`); - global instrumentation; - every route that is not another zone's. A set of zones has **exactly one** shell. ## Mount Every zone other than the shell owns one URL segment, its *mount*, such as `/blog`. All of its routes live under it: `/blog`, `/blog/[id]`, `/blog/settings`… A mount has one owner. Two zones on the same mount are refused, and so is a zone on a segment the shell already serves. ## Alias A zone can also serve a URL **at the root**, outside its mount, such as `/post/42` for its page `/blog/42`. That is an [alias](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/aliases.md): the page still lives under the mount, and the address bar shows the root URL. ## Version A version of a zone is **a build**, not a copy of its source. Zone images are kept in a *store* (one folder per zone and version). Zones can switch between them, forwards or back, while the server runs. ## The contract between zones Zones served together must share: - **one `node_modules`:** the same Next, the same React, the same layout (a monorepo workspace does this); - **the same root layout output:** in practice, every zone renders the same root layout component from a shared package; - **the same `basePath`, `i18n`, `trailingSlash`, `assetPrefix`, `skipTrailingSlashRedirect`, `cacheComponents`, `partialPrefetching`** (Zones refuses a zone that differs, and names the key); and **no `images` key of a zone's own that differs from the shell's** (a zone with no `images` config fits any shell). **Shared packages may differ in version between zones.** - Modules that are the same in two zones are loaded once and shared. - A module that differs (another version of a shared component, or anything that imports it) runs in its own version in each zone, side by side in the same page. - **Mind the remount.** A shared component that differs between two zones is two different components to React, so it mounts again when the user moves between them. Keep the root layout and providers on the same version in every zone to keep their state across zones. `zoneConfig` sets the build options next-zones needs (for example, Turbopack scope hoisting off, so a module shared by zones loads once). --- # Getting started > Requires Next.js 16.3.6, 16.3.7, 16.3.8 or 16.4.0 and Node.js 24 or later. Zones use the App Router, the > [Pages Router](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/pages-router.md), or both; this guide uses the App Router. ## The quick way ```sh npx @runsnip/next-zones init my-app blog shop # a workspace: shared/, shell/, blog/, shop/, installed cd my-app npm run dev # every zone on one next dev, with HMR: http://localhost:3000 npm run doctor # checks the workspace for Zones and for dev npx next-zones add docs # one more zone, at /docs ``` `init` uses the package manager that runs it (npm, yarn, pnpm or bun) and never overwrites a file. Every tsconfig extends `@runsnip/next-zones/tsconfig/zone.json`, and each zone keeps its own `@/*` → `./src/*`. The steps below are what it sets up, for a workspace made by hand. ## 1. A workspace of zones One workspace (npm, yarn or pnpm), so every zone shares one `node_modules`: ``` my-app/ package.json workspaces: ["shared", "shell", "blog", "shop"] shared/ a package: the root layout, shared UI shell/ the zone mounted at "/" blog/ the zone mounted at "/blog" shop/ the zone mounted at "/shop" ``` ```json { "private": true, "workspaces": ["shared", "shell", "blog", "shop"], "dependencies": { "@runsnip/next-zones": "0.x" } } ``` ## 2. Declare each zone Each zone's `next.config.mjs` (or `.ts`): ```js // shell/next.config.mjs import { zoneConfig } from "@runsnip/next-zones/config"; export default zoneConfig({ mount: "/", endpoints: { events: true, health: true, admin: true }, // Zones' own URLs, under /_next-zones (see below) transpilePackages: ["shared"], }); ``` `endpoints` is the shell's only. `events` feeds [``](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/updates.md), `health` answers a supervisor, and `admin` is what `next-zones install`, `pull --url` and `prune --url` talk to: without it they get a 404. See [endpoints](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md#endpoints). ```js // blog/next.config.mjs import { zoneConfig } from "@runsnip/next-zones/config"; export default zoneConfig({ mount: "/blog", transpilePackages: ["shared"], }); ``` Every route of `blog` lives under `blog/app/blog/…`. At the top of `blog/app/` there may also be the root files Next needs when the zone runs alone (`layout`, `not-found`, `global-error`, `global-not-found`, `error`, `loading`, `template`, `default`, and CSS files); on Zones the shell's are used. ## 3. Share the root layout Every zone renders the same root layout, so a page looks the same however it is reached: ```tsx // shared/root-layout.tsx import Link from "next/link"; export function RootLayout({ children }: { children: React.ReactNode }) { return (
{children}
); } ``` ```tsx // blog/app/layout.tsx (the same in every zone) import { RootLayout } from "shared/root-layout"; export default RootLayout; ``` ## 4. Follow new versions in open tabs In the shell's root layout, render [``](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/updates.md) once. Open tabs then pick up a newly installed zone image on their next navigation. ## 5. Check the workspace ```sh npx next-zones check . ``` ``` ✓ / shell ✓ /blog blog ✓ /shop shop ``` It fails if two zones claim one mount, if no zone owns `/`, or if an alias collides. Run it before every build. ## 6. Build and run Give each zone a `version` in its `package.json`, then: ```sh next-zones build # the shell as an app, blog and shop as images, zones.json pinning their versions next-zones start # Zones on port 3000: the shell, with blog and shop installed ``` - **Releasing a zone** is bumping its version, building its image, and installing it on the running Zones: `next-zones build blog`, then `next-zones install blog 1.1.0` (it needs the shell's `endpoints: { admin: true }`). See [build and start](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/cli.md#build-and-start). - **Each zone still runs on its own** with `next build` and `next start`. - **One Next app instead:** declare `mode: "single"` in the shell's `zoneConfig`; `next-zones build` then builds the workspace as one app, and `next-zones start` runs it with `next start`. No images, no live installs. --- # Configuration: `zoneConfig` ```ts import { zoneConfig } from "@runsnip/next-zones/config"; export default zoneConfig({ ...zone, ...nextConfig }); // one object export default zoneConfig(zone, (phase) => nextConfig); // a Next config that is a function of the phase ``` One object holds both: `zoneConfig` takes out the zone's keys below and passes every other key to Next. A Next config that is a function of the phase comes second, after the zone's keys, so the declaration is known without calling it. `zoneConfig(zone, nextConfigObject)` is read too. In both two-argument forms, Next keys left in the first object are kept, and the second wins on a key both set. ## `zone` | Field | Type | Required | Meaning | |---|---|---|---| | `mount` | `string` | yes | `"/"` for the shell, otherwise one URL segment such as `"/blog"` (lowercase letters, digits, `-`) | | `aliases` | `{ source: string; destination: string }[]` | no | URLs at the root that this zone serves. See [aliases](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/aliases.md) | | `endpoints` | `{ base?, events?, health?, admin? }` | no (the shell only) | The URLs Zones serves of its own, under `base` (`"/_next-zones"` by default); none unless declared. See [endpoints](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md#endpoints) | | `mode` | `"zones"` \| `"single"` | no (the shell only), `"zones"` | How the workspace is served. `"zones"`: the shell is built as an app and every other zone as an [image](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md#zone-images), and Zones installs images while it runs. `"single"`: every zone is linked into one Next app, run by `next start`, with no live installs. See [build and start](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/cli.md#build-and-start). `output` and Next's other options stay Next's keys, in the same object | | `metrics` | `boolean` | no (the shell only), `false` | Opens one metrics store for the whole Zones process: requests by zone and version, installs, pulls, memory, and what a zone's code writes with `@runsnip/next-zones/metrics`; served as Prometheus text at `/metrics` to an admin. Off, the metrics functions do nothing. See [metrics](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/metrics.md) | | `livePull` | `boolean` | no, `false` | Whether a ping (an admin request to Zones' `/images///pull` or `/install`) may make Zones pull this zone's images from its sources. Recorded in each zone image's `zone.json`; read from the zone's latest image before anything is fetched. Without it, the zone's images are pulled on the server side only (`next-zones pull`, a deploy step). Images already in the store install either way. See [live pulls](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md#live-pulls) | An invalid declaration throws as soon as `next.config` is loaded, for example: `mount must be "/" (the shell) or one URL segment like "/blog"`. ## `nextConfig` The zone's own Next config: - every key of the object that is not the zone's; - or a function of the phase, `(phase, { defaultConfig }) => config`, which may be async, as the second argument: what it returns is laid over the first object's Next keys, key by key at the top level. `zoneConfig` returns it with the build options below, the zone declaration (attached under a symbol that Next ignores and next-zones reads back), and, when the zone runs alone, its aliases as rewrites. Everything else in your config is passed through unchanged. ## Next's options are yours `zoneConfig` passes the zone's Next config through as it is, with one exception: in a build made by `next-zones build`, it fills in the [build options for Zones](#build-options-for-zones) below, and nothing else. `output`, `images`, `experimental` and the rest mean what they mean in Next, in every mode. A plain `next build` or `next dev` gets the config untouched. ## Build options for Zones Zones runs zones that were built separately, sharing one instance of each module they have in common. That needs three Turbopack options off in the builds Zones runs (the shell and every zone image). `zoneConfig` fills them in **only in `next-zones build`** (the shell and every zone image, in both modes: `"single"` links the same images), and **only where your config leaves them unset**. Anywhere else (a zone built alone with `next build`, `next-zones dev`) nothing is filled in. If your config sets one of them otherwise, a build for Zones stops with an error naming it, rather than overriding it. Setting them to `false` yourself is allowed, and changes nothing. `turbopack.root` and `outputFileTracingRoot` you may set too: what counts is that the shell and every zone are built from one root. Zones refuses a shell or a zone image that was built without them, and says to build it with `next-zones build`. | Option | In a build for Zones | Why | Cost (measured) | |---|---|---|---| | `experimental.turbopackScopeHoisting` | `false` | Hoisting merges modules into one factory that writes other modules' exports. A module shared by several builds is loaded once, so no build may write into it | Server JS of the test shell +16% (520 → 604 KB); client JS unchanged | | `experimental.turbopackRemoveUnusedExports` | `false` | Turbopack drops the exports a build does not use. A library used by the shell and a zone then differs between their builds: the shell's copy has only what the shell uses. They load as two modules, so a context it creates exists twice, and a zone's hook does not see the shell's provider (found with `@tanstack/react-query`: "No QueryClient set") | Two real apps: client JS +1.9% and +3.4%, server JS +8.6% and +4.4% | | `experimental.turbopackRemoveUnusedImports` | `false` | Turbopack refuses to build with it on while unused exports are kept | Included above | ### The project root Turbopack names every module by its path from the project root, and its module ids come from that name, so the shell and every zone image must be built from one root to share modules. Next infers the root from the topmost lockfile above the app: a lockfile added in a parent folder would move it, and rename every module of the builds after it. A build for Zones therefore pins it: `turbopack.root` and `outputFileTracingRoot` are set, when your config leaves them unset, to `NEXT_ZONES_ROOT`, or else to the nearest folder whose `node_modules` holds `next` (widened to hold that `node_modules` where it really is, when it is a link). Zones refuses a zone image built from another root than the shell's, and says so. Mode `"single"` links the same zone images into one app, where a module several builds use is also loaded once, so it needs them too. The cost is the size in the last column, and nothing else. Getting it back means sharing a module by what it exports, not by its whole code (debt D8 in the README). That was tried and measured: splitting a shared library into a fragment per export, or pinning only the modules that hold state, came back larger or not exact. What stops it is Turbopack itself: unused-export removal is one switch for the whole build, with no way to keep one module's exports whole. So the options stay off. ## Running a zone alone A zone is a normal Next app: it builds and runs on its own (`next build`, `next start`, `next dev`), and can be deployed on its own domain. Alone, it serves its own routes, and `zoneConfig` adds its aliases to its own rewrites. A build for Zones (`next-zones build`) leaves them out, because Zones serves them. Links to the other apps are then links to another site: point them at where those apps live. ## Example ```js import { zoneConfig } from "@runsnip/next-zones/config"; export default zoneConfig({ mount: "/blog", aliases: [{ source: "/post/:id", destination: "/blog/:id" }], transpilePackages: ["shared"], reactStrictMode: true, }); ``` ## `readZone(dir)` ```ts import { readZone } from "@runsnip/next-zones/config"; const zone = await readZone("./blog"); // { mount: "/blog", aliases: [...] }, or null if not a zone ``` It loads the app's `next.config.mjs`, `.js`, `.ts` or `.mts`, and returns its declaration. Tools use it: the CLI, build scripts, Zones. --- # CLI | Command | What it does | |---|---| | `next-zones init [zone…] [--no-install]` | A new workspace: a shared package, the shell and its zones, installed ([getting started](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/getting-started.md#the-quick-way)) | | `next-zones add [--mount /]` | One more zone in the workspace of the current folder | | `next-zones check [more dirs…]` | Checks the zones' declarations: mounts, the shell, aliases (below) | | `next-zones doctor [more dirs…] [--store ] [--url ] [--fast]` | Checks a workspace from source for everything Zones and `dev` need, with what to do (below) | | `next-zones build [--dir .] [--version ] [--store ] [--pack] [--out ]` | Builds the workspace as its shell declares ([build and start](#build-and-start)): with `mode: "zones"`, the shell as an app, every zone as an image, and `zones.json` pinning the versions built | | `next-zones build … [--dir .] [--version ] [--store ] [--pack] [--out ]` | Builds only these zones' images, to release what changed. The shell is not built | | `next-zones start [--dir .] [--store ] [--source ]… [serve's options]` | Runs what `build` made: Zones, the shell, the pinned images | | `next-zones serve --shell [--store ] [--cache ] [--pins zones.json] [--policy zones.config.json] [--source ]… [--connector --connector-owner ] [--keep 2] [--no-prune] [--min-free ] [--port 3000] [--host 0.0.0.0]` | Runs the [Zones](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md), installing the pinned versions of `zones.json` (if present) under the store's `state.json`. `--source` (repeatable): where it pulls zone images from. `--keep`: the rollback window [pruning](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md#pruning) keeps; `--no-prune` turns it off. `--min-free`: what a pull leaves free on the disk (1024 MB by default) | | `next-zones pack [--out ]` | Packs a zone image into one `.tgz`, the form a source serves (see [zone images](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md#zone-images)) | | `next-zones pull --store (--source … \| --connector --connector-owner ) [--min-free ]` | Pulls a zone image into a store, on the server side (any zone): streamed, checked, then moved in. A deploy step | | `next-zones pull --url [--base /_next-zones]` | Pings a running Zones to pull it from its sources: only a zone that allows [live pulls](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md#live-pulls) | | `next-zones prune --store [--keep 2] [--pins zones.json] [--cache ] [--dry-run]` | [Prunes](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md#pruning) a store no Zones runs on (refused while one does) | | `next-zones prune --url [--base /_next-zones] [--keep 2] [--dry-run]` | Asks a running Zones to prune its store | | `next-zones install [--url http://127.0.0.1:3000] [--base /_next-zones]` | Asks a running Zones service to install, swap to or roll back to a version | | `next-zones dev [--port 3000]` | Develops every zone at once: one `next dev`, with HMR and soft navigation (below) | | `next-zones watch [more zones…] [--url http://127.0.0.1:3000] [--base /_next-zones] [--store ]` | Rebuilds zones on every change and installs them on a running Zones service (below) | `start`, `serve`, `install`, `pull`, `prune` and `watch` read the admin token from `NEXT_ZONES_ADMIN_TOKEN`. When the server has a token, a command must send the same one, even from the same machine: the loopback rule holds only with no token configured. - **Sources** (`serve`, `start`, `pull`): `--source`, repeatable, is an `http(s)` URL template with `{zone}` and `{version}` (`https://images.example.com/{zone}/{version}.tgz`), or a folder. `--connector --connector-owner ` adds a service-connector source, its token read from `NEXT_ZONES_CONNECTOR_TOKEN` (never from the command line). `zones.js` reads `NEXT_ZONES_SOURCES`: URL templates and folders, separated by commas; and a connector from `NEXT_ZONES_CONNECTOR`, `NEXT_ZONES_CONNECTOR_OWNER` and `NEXT_ZONES_CONNECTOR_TOKEN`. - **`--shell `** (`serve`) is the shell's folder after `next-zones build`: its `next.config` and its `.next`. On a server, deploy that folder (or, with the shell's `output: "standalone"`, the standalone folder `build` makes, run with `node zones.js`); CI that builds only zone images leaves the shell's deploy as it is. - **Publishing an image** (the `.tgz` of `build --pack`) is yours to do with any tool that puts a file where a source reads it (an object store behind the URL template, a folder): next-zones never uploads, and Zones accepts no upload. - **Versions** are any of letters, digits, `.`, `_` and `-` (`12`, `1.4.0`, `2026-10-09.1`). A command always names the version it means. - **`--format zip`** (`build --pack`): packs each image as a `.zip` instead of a `.tgz`. - **`next-zones pull --store` beside a running Zones** is safe: the image is written to a folder of its own and moved in by one rename, and nothing active changes. The running Zones installs it when asked (`install --url`). `prune --store`, which deletes, is refused while a Zones runs on the store (its `zones.pid`, for a process that is still alive: a pid left by a crash does not block). `pull --store` prunes nothing (short of disk room, it is refused): a running Zones prunes after its own pulls and installs, and at boot. - **Pins and the store's state.** `zones.json` (`--pins`) names the versions a build made; the store's `state.json` records what a running Zones installed since (live installs and rollbacks). At start, `state.json` wins, unless `zones.json` was written after it (its file time is later than `state.json`'s `updatedAt`): then a new build's pins win. `createZones({ pins })` given as an object has no file time: pass `pinsAt` (milliseconds) with it to say when they were made, or `state.json` wins. Make it the time the pins were written (at deploy), never `Date.now()` at start: that would undo every live install at each restart. - **The instrumentation policy** (`zones.config.json`, see [instrumentation](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/instrumentation.md#the-policy)) is read by `start` from the workspace's folder, by `serve` from the current folder (`--policy` names another file), and by `zones.js` from its own folder (`build` copies it there), all when they start. Mode `"single"` reads it when it builds, into the one app's `instrumentation` file. `POST /policy` replaces it in the running Zones only: the file is not written. ## Build and start Like `next build` and `next start`, for a workspace of zones (run in the workspace's folder, or with `--dir`). The shell declares what the workspace builds to, with `zoneConfig({ mount: "/", mode })`: | | Command | Output | Then | |---|---|---|---| | **The workspace**, `mode: "zones"` (default) | `next-zones build` | The shell built as an app (`shell/.next`); every other zone as an [image](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md#zone-images) in `.zones-store///`, or packed into `.zones-images//.tgz` with `--pack`; `zones.json` pinning the versions built | `next-zones start` | | **One zone, or a few** | `next-zones build blog` | `blog`'s image only (`--pack`: a `.tgz`). The shell is not built | A running Zones installs it: `next-zones install blog 1.4.0`, or [pulls](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/zones.md#how-an-image-enters-the-store-a-pull) it from where you published the `.tgz` | | **One app**, `mode: "single"` | `next-zones build` | Every zone built once, as on Zones, then each zone's image linked into one standard Next app, following the shell's Next `output` (below). No `zones.json`, no Zones at run time | `next-zones start`: `next start` of that app; see the outputs below | - **An image's version** is the `version` in the zone's `package.json`; `--version` gives one to every zone built. - **Images are immutable.** A version already built is kept by `next-zones build` (and said so), and refused by `next-zones build `: bump the zone's version. - **One app** (`mode: "single"`) is linked from the same zone images Zones runs: one zone format, built once. The shell's build and every image are linked into one `.next` (`link-app.mjs`): each zone's server files, routes under its mount, aliases, headers, redirects and rewrites, prerendered pages, server actions, fonts, public files, its own packages, and instrumentation as on Zones. Each zone keeps its own build, so its own `@/*` and its own `tsconfig` hold as they were built. Nothing is rebuilt: a zone changed is built again alone, then linked. - **Next's `output`, in mode `"single"`,** is each zone's own Next config's, and the link follows the shell's: - none: `.zones-app/.next`, served by `next start` (`next-zones start` runs it); - `"standalone"`: the zones linked into the shell's standalone folder (`shell/.next/standalone/…/server.js`), with `.next/static` and `public/` copied in, as Next's docs say a deploy must, and each zone's server files traced with Next's own `@vercel/nft`. The folder is the whole deploy: copied anywhere, its `server.js` serves every zone (`singlestandalone.mjs` runs it from outside the workspace). `next-zones start` runs it; - `"export"`: a static site in `.zones-export`, every zone's export linked into the shell's; links between zones stay soft (`singleexport.mjs`). `next-zones start` says where it is, as `next start` does not serve an export. Rewrites, redirects, headers and aliases do not apply to an export, as in Next. - **Next's `output: "standalone"`, in mode `"zones"`:** - **The shell's config says it:** `next-zones build` makes the shell's standalone folder (`shell/.next/standalone/…`) the whole deploy. It adds: - what Next leaves to the deploy (`.next/static`, `public/`); - next-zones, and the files of Next that Zones uses, traced with Next's own `@vercel/nft`; - the shell's declaration; - the images built, and `zones.json`; - `zones.js`, which starts Zones the way Next's `server.js` starts Next. Copied anywhere, `node zones.js` serves every zone (`PORT`, `HOSTNAME`, `NEXT_ZONES_STORE`, `NEXT_ZONES_ADMIN_TOKEN`, `NEXT_ZONES_SOURCES`, `NEXT_ZONES_CONNECTOR`, `NEXT_ZONES_CONNECTOR_OWNER`, `NEXT_ZONES_CONNECTOR_TOKEN`, see **Sources** above). `next-zones start` runs it. Pruning keeps 2 and pulls leave 1024 MB free, as `serve`'s defaults. - **A zone's config says it:** its image carries the packages its server traces need, Next and React aside, so it runs where the workspace's `node_modules` is not. Zones resolves a zone's packages from the shell first (one copy of each), then from the image. - **Next's `output: "export"`, in mode `"zones"`:** the shell's config and every zone's say it (each zone is exported on its own; next-zones never sets it). `next-zones build` exports the shell and each zone as an image (which keeps its `out/`), then links them into one static site, `.zones-export/`, for any static host. Linking does once what Zones does at install: clashing client module ids are remapped, each zone's main chunk is written, and its pages carry the shell's build id, so links between zones stay soft. A zone whose image has no export stops the build with the reason. Rebuilding one zone and linking again leaves the others as they were built. - **`next-zones start`** runs Zones with the shell, installs the versions `zones.json` pins (newer pins replace the versions a previous run left live), and pulls a pinned version the store lacks from `.zones-images` or `--source`. `.zones-images` stays a source while it runs, so `next-zones build --pack` then a ping installs the new one. **About `build`.** It builds each zone where it is, as `next build` would (Turbopack's cache is reused from one version to the next). Two versions may give one id to different modules; Zones tells them apart by what each module is, its dependencies included: an open tab runs the new version's client code after a swap, and the server never hands one version another's module. The store defaults to `NEXT_ZONES_STORE`, else `/.zones-store`. ## `next-zones check` ```sh next-zones check [more dirs…] ``` It reads every sub-folder of each `` whose `next.config` uses `zoneConfig`, and checks the set as a whole. | Check | Error | |---|---| | Each mount is `/` or one segment | `blog: mount must be "/" or one URL segment like "/blog"` | | A mount has one owner | `/ is claimed by 2 zones: a, b` | | Exactly one shell | `no zone owns "/": exactly one zone, the shell, must declare "mount": "/"` | | An alias does not land on a mount | `shop: alias /blog/:x lands on the mount of blog` | | Two zones do not share an alias segment | `aliases under /post are claimed by blog, shop` | It exits with `0` when everything holds and `1` otherwise, so it can gate a build: ```json { "scripts": { "zones:check": "next-zones check .", "build": "npm run zones:check && npm run build --workspaces" } } ``` > With yarn 1, do not name the script `check`: `yarn check` is a built-in command. ## `next-zones doctor` ```sh next-zones doctor . --store .zones-store --url http://127.0.0.1:3000 next-zones doctor . --fast # without Next's and React's checks (lint, types) ``` It reads the workspace from source, before anything is built, and reports every finding with what to do. It exits with `1` on an error. - `✗` **an error:** Zones would refuse the zone at install, or `next-zones dev` would not run. - `!` **a warning:** it works, with a cost or a surprise. - `✓` **a check that holds.** | Check | Why | |---|---| | The declarations, as `check` | One shell, one owner per mount and alias segment | | One `next`, `react` and `react-dom` for every zone | Zones loads each once | | `basePath`, `i18n`, `trailingSlash`, `assetPrefix`, `skipTrailingSlashRedirect`, `cacheComponents`, `partialPrefetching` equal to the shell's | They shape every URL | | No `images` key a zone sets of its own that differs from the shell's (a zone with none fits) | The shell's image optimizer serves every zone | | A zone's `headers`, `redirects` and `rewrites` under its mount or aliases | Root rules belong to the shell | | No `proxy`/`middleware` or `instrumentation-client` in a zone | They would not run when the zone is reached from the shell | | A zone's `app/` holds its mount, plus root files used alone (`layout`, `not-found`, `global-error`, `global-not-found`, `error`, `loading`, `template`, `default`, CSS) | Routes outside the mount are refused | | No zone's mount or alias on a segment the shell's `app/` serves (route groups included) | A segment has one owner; refused at install | | No edge runtime in a zone | Not served by Zones yet | | A zone's `pages/` holds its mount (`pages/`, `pages.tsx`), plus `_app`, `_document`, `_error`, `404`, `500` | Pages outside the mount, `pages/api/` among them (served at `/api/…`), are refused | | A zone's `public/` holds only `public//` | Public files are served at the root, beside other zones' | | `env` keys with one value across zones (warning) | Composed by `dev`, a key has one value | | A path alias that differs between zones has the form `"prefix/*": ["folder/*"]` | The form `dev` gives each zone | | `.next/`, `node_modules/`, `.zones-dev/`, `.zones-store/`, `.zones-images/`, `.zones-cache/`, `.zones-app/`, `.zones-export/` ignored by git (errors); `next-env.d.ts`, `*.tsbuildinfo` (warnings) | Asked of git itself (`git check-ignore`), so any `.gitignore` in the repository, or a global one, counts | | **Next's and React's checks** (not with `--fast`): `eslint-config-next`'s rules on each zone's sources, or the zone's own ESLint config; then `next typegen` and `tsc --noEmit` | Neither Next nor React ships a doctor. These are their official checks: Next's plugin, React's hooks and compiler rules, and the type check `next build` runs. They run from the workspace's installs (`eslint` 9, `eslint-config-next`, `typescript`); nothing is downloaded | | With `--store`: `state.json` pins versions in the store, built with the shell's Next and React | Zones refuses them otherwise | | With `--url`: a Zones answers at that URL (a warning when it does not) | | ## `next-zones dev` ```sh next-zones dev . --port 3000 # then open http://localhost:3000 ``` It composes the shell and every zone into one Next app, `/.zones-dev/`, and runs `next dev` on it: - **HMR everywhere:** in every zone, and in shared packages. An edit shows in under 0.1 s, and the page keeps its state. - **Soft navigation between zones,** with one React, as on Zones. **What it writes.** Nothing is copied: the app is made of links to the zones' own files, and Next sees every edit. - `app/`: the shell's `app/`, and each zone's `app//` under its mount, as real folders of links to files. Next's route discovery does not enter linked folders. - `pages/`: each zone's Pages Router pages under its mount, re-exported (a stub per page: Turbopack's dev server does not follow a linked page), and one `_app` and `_document`: a zone's own when only one has them, else composed ones that render each page with its own zone's, picked by mount (see [Zones on the Pages Router](https://raw.githubusercontent.com/runsnip/next-zones/main/docs/pages-router.md)). - `next.config.mjs`: the shell's config, with each zone's `transpilePackages`, `env`, `headers`, `redirects` and `rewrites` added, and the zones' aliases as rewrites. - `postcss.config.mjs`: the shell's, with Tailwind scanning the zones dir. - `tsconfig.json`: the shell's, with the `paths` every zone agrees on. - **Each zone keeps its own path aliases.** Next reads one tsconfig per app, so an alias that zones point to different folders (each zone's `@/*` → its own `src/`) is given to each zone's files by a loader rule instead. `@/x` in a zone's file becomes the path to that zone's `src/x`. **What zones must agree on, composed:** - **A path alias that differs between zones** must have the form `"prefix/*": ["folder/*"]`, which is what `@/*` is. - **An `env` key has one value** across zones. - **The shell's root files serve everyone.** A zone's own root layout and root `not-found` serve it when it runs alone, as on Zones. ## `next-zones watch` ```sh next-zones serve --shell shell --port 3000 & # Zones, with the shell (its folder) next-zones watch . blog shop # production builds, installed on every change ``` Zones runs production builds, so this is the way to try a change exactly as it will be served. It builds each zone once, then watches the zones dir: - **A change inside a zone** rebuilds that zone. - **A change in a shared package** (any folder that is not a zone) rebuilds every zone it watches. - **Each build** is a version `dev-