next-zonesv0.1.0

Configuration: zoneConfig

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
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
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, 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. 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 <base>/metrics to an admin. Off, the metrics functions do nothing. See metrics
livePull boolean no, false Whether a ping (an admin request to Zones' <base>/images/<zone>/<version>/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

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:

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 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#

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)#

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.

Edit this page on GitHub · Markdown