nextshipnextship
How it works

How it works

The adapter, the trace-based prune that replaces standalone mode, and the driver interface underneath.

The adapter#

Next.js 16.2 stabilised a Deployment Adapter API. An adapter is a module the build loads through NEXT_ADAPTER_PATH, with two hooks:

  • modifyConfig applies the settings a container deployment requires, so you do not have to know about them
  • onBuildComplete writes a manifest recording what the build produced

nextship ships its adapter inside the CLI package and injects it at build time. Your next.config.js is never edited, and nothing is added to your dependencies.

What modifyConfig sets:

  • deploymentId, so assets carry ?dpl= and navigations carry x-deployment-id. A client running an older build hard-navigates instead of requesting chunks that the new build no longer has
  • X-Accel-Buffering: no, because a buffering reverse proxy leaves streaming apparently working while delivering none of it: Suspense boundaries and Partial Prerendering arrive as one response at the end

Why not standalone mode#

output: 'standalone' is the obvious way to get a small image, and it is incompatible with the Adapter API.

Verified against Next.js 16.3.4: setting it while an adapter is configured fails the build with ENOENT on .next/next-server.js.nft.json, leaving a standalone directory with no server.js. It reproduces with a completely no-op adapter, so the combination is what breaks, not this adapter.

So nextship assembles the equivalent tree itself, from the same trace data standalone mode uses.

The prune#

After the build, a script walks what Next.js traced:

  1. Every per-route .nft.json dependency trace
  2. next-server.js.nft.json, when the build wrote one
  3. A trace of the generated launcher, computed with the same file tracer Next.js bundles, so no extra dependency is needed
  4. The handful of files standalone mode adds that no trace can discover, because they are spawned as child processes by path rather than imported

Everything reachable is copied into a clean tree; everything else is left behind. On a real project that is 1.13 GB down to 591 MB, with no Dockerfile and no standalone flag.

The limit worth knowing#

Packages vendored directly into node_modules without being declared in package.json are not deployed. nextship installs from your lockfile inside the image and does not ship your host's node_modules, because host binaries are built for the wrong platform.

The image#

Two stages from one Dockerfile.

The builder installs dependencies, then runs your own build script. Manifests are copied before sources, so editing a component does not re-run the install: measured on a real project, the install is 97 s and the compile is 6 s.

The runtime starts from the same slim base, drops npm and corepack, adds tini and jemalloc, creates a non-root user, and copies only the pruned tree.

Secrets never enter a layer. Env files and the Server Actions encryption key are mounted as BuildKit secrets, which are excluded from layers and from the cache key.

On the base image#

Alpine was evaluated and rejected on measurement: sharp runs 2.5 times slower there, 91.8 ms against 37.1 ms. Image size is paid once at deploy; that latency is paid on every request that touches an image.

The driver interface#

Everything platform-specific sits behind one interface expressed as intent rather than as any one provider's API.

That distinction is not academic. Rollback originally asked for deployments the API reports as ACTIVE, which is DigitalOcean's spelling and matched only the deployment already live. The interface now asks whether a deployment served, which a second cloud can also answer.

Two targets implement it. DigitalOcean App Platform, and since 1.1.0 any Linux server reached over SSH: one driver that covers every VPS provider rather than a driver per cloud, verified so far on a local test server, a Proxmox VM and DigitalOcean Droplets.

Verified against the real thing#

The Next.js adapter compatibility suite is the framework's own e2e corpus, run in deploy mode against a real container per test file. Against 16.4.0-canary.22, all 1123 suites and all 3599 assertions passed, every suite on its first attempt, in two runs that matched suite by suite. Nine tests that assert what Vercel's CDN or proxy does are skipped, and the results say why for each.

Getting there meant telling the harness's failures apart from nextship's. Most of 1.0.1's 64 failing suites were the harness: the Docker build never received what a test configures, and packages fixtures commit under node_modules were deleted before the build.

Running it has found five defects no unit test would have caught: the build log never reached the harness, the deployment id was exported only under a prefixed name, the builder image carried no Python for dependencies that compile on install, projects shared one Next.js build cache, and the image left out route module contexts a Pages Router page can need.