@gregorygrant/nextjs-build-adapter
v0.1.2
Published
Pantheon Next.js build adapter (NextAdapter) — normalizes Next.js 16.2+ build output into the canonical STA build manifest.
Maintainers
Readme
@pantheon-systems/nextjs-build-adapter
The Pantheon Next.js build adapter. A Node module implementing Next.js 16.2+'s stable
NextAdapter API, loaded zero-touch via the NEXT_ADAPTER_PATH env var (no client
next.config edit, no dependency install on their side). It tunes the build for Pantheon's
Single Tenant Architecture (STA) and normalizes the typed Next.js build output into a
canonical build manifest the orchestration layer consumes.
- API verified against
[email protected]— see adapter-api-verified.md. - Injection flow & output→STA mapping — see adapter-injection-design.md.
- Manifest contract — see adapter-manifest-schema.md.
- End-to-end testing — see e2e/README.md.
What it does
The adapter has two runtime pieces that run in sequence around next build:
- The
NextAdapter(src/index.ts) — loaded by Next.js during the build.modifyConfig(production build only): forcesoutput: 'standalone'(STA serves the app from the standalone Node output behindsta-caddy-proxy), injects the Pantheon cache handlers for ISR/route/fetch +'use cache'(backed by the GCS shared cache, zero-touch), and pins a deterministic build id whenPANTHEON_BUILD_IDis set.onBuildComplete: writes the canonicalmanifest.jsonfromctx.outputs/ctx.routing, and warns (never fails) if the build still uses the unsupported edge runtime (see Edge runtime below). The adapter stays stateless — the builder/orchestration layer uploads the manifest.
- The
pantheon-stageCLI (src/stage-cli.ts) — run afternext buildreturns. Next writes thestandalone/directory only afteronBuildCompletefinishes, so staging cannot happen in the hook. This step copies.next/staticandpublic/into the standalone tree (monorepo-aware) so the standalone output is self-contained and runnable.
Builder order: next build (adapter injected) → pantheon-stage → package image.
Requirements
- Node.js >= 20 (developed on 26; tests use native TypeScript type-stripping — no
tsx/ts-node). next>= 16.2 (peer dependency; provided by the build environment).- pnpm (matches the Next.js adapter test harness).
sharp's native build is ignored;.npmrcsetsverify-deps-before-run=false.
Commands
| Command | What it does |
|---|---|
| pnpm install | Install dependencies. |
| pnpm build | Compile src/ → dist/ (ESM). Entry: dist/index.js (matches NEXT_ADAPTER_PATH). |
| pnpm dev | tsc --watch. |
| pnpm typecheck | Type-check without emitting. |
| pnpm test | Run all tests: node --test "test/**/*.test.ts" (native TS, no transpile step). |
| pnpm test:one "<name>" | Run a single test by name (--test-name-pattern). |
| pnpm lint / pnpm lint:fix | ESLint (flat config). |
| pnpm e2e:smoke | Build the fixture app through the real adapter + harness and boot the staged server. |
| pnpm e2e:fixtures | Build the Pantheon fixture matrix (e2e/fixtures/*) through the adapter and assert each manifest.json against its expected.json. PR-gating. Append a name to run one, e.g. pnpm e2e:fixtures pages-router. |
| pnpm clean | Remove dist/. |
The Pantheon fixture suite (pnpm e2e:fixtures) gates every PR; the official Next.js
deploy-mode e2e suite runs via bash scripts/run-suite.sh nightly (the heavyweight CI
correctness bar). See e2e/README.md.
Usage (zero-touch injection)
Building the adapter itself from this repo (local dev):
pnpm build
export NEXT_ADAPTER_PATH="$(pwd)/dist/index.js"
next build # Next.js loads the adapter; no next.config change required
pantheon-stage # post-build: stage static + public into the standalone treeAdding it to a client app (verified against the greg-site3 sample)
The build environment injects the adapter — the client's repo stays clean. The portable
pattern resolves NEXT_ADAPTER_PATH from the installed package, so it works on any host
regardless of where node_modules lives (this is exactly what the greg-site3 MediSupplyTech
sample uses):
// package.json — the build script the deploy environment runs
"build": "NEXT_ADAPTER_PATH=\"$(node --input-type=module -e \"import{fileURLToPath}from'node:url';process.stdout.write(fileURLToPath(import.meta.resolve('@pantheon-systems/nextjs-build-adapter')))\")\" next build && pantheon-stage"What the sample integration confirmed:
- Coexist with an app's own cache handler. If the app already sets
cacheHandlerinnext.config, the adapter detects it and leaves the cache config untouched — it only adds the deployment concerns (standalone output, build-id pinning, manifest emit, staging). It never clobbers an explicit choice; it only removes the requirement to configure one. See src/cache/config.ts. - pnpm 11 native-build gotcha. A consumer app on pnpm 11 needs its
pnpm-workspace.yamlto opt out of optional native builds so install/tracing is deterministic, mirroring this repo's own.npmrc:# pnpm-workspace.yaml verifyDepsBeforeRun: false allowBuilds: sharp: false unrs-resolver: false - Every Node rendering mode maps cleanly. The sample exercises static,
'use cache'+cacheLife, PPR,generateStaticParams, catch-alls, Node route handlers, metadata routes, and a coexisting Pages Router (appType: hybrid) — allnodejs, no edge.
Edge runtime (unsupported)
Pantheon does not support the Next.js edge runtime. STA serves the standalone Node server
behind sta-caddy-proxy, with a Fastly CDN edge in front for caching and invalidation — there is
no edge-compute tier. The edge runtime is also deprecated in Next.js, so this is where the
platform is heading anyway.
The adapter does not fail a build that still declares the edge runtime — that would break
existing apps for no gain. Next executes edge middleware and edge route handlers inside the
standalone Node server at origin, so they still run. Instead, onBuildComplete emits a warning
nudging the app to migrate to Node, and the emitted manifest.json carries no edge-runtime
vocabulary (no runtime, edgeRuntime, hasEdgeRuntime, or compute block) — every route is
treated as a Node route.
Migrating off the edge runtime (Next.js 16.2 rules):
| App file | Runtime | Migration |
|---|---|---|
| middleware.ts | edge by default | Add export const config = { runtime: 'nodejs' }, or move to proxy.ts. |
| proxy.ts (the v16 replacement) | Node.js always | Already Node — nothing to do. Setting runtime in a proxy file is a build error. |
| Route with export const runtime = 'edge' | edge | Remove the runtime export so the route uses the Node runtime. |
The adapter's cache handler is deliberately left externalized (not in
transpilePackages) so that apps which still contain edge code compile instead of failing withThe edge runtime does not support Node.js 'fs' module— see src/cache/config.ts.
Environment variables
| Var | Read by | Purpose |
|---|---|---|
| NEXT_ADAPTER_PATH | Next.js | Path to dist/index.js — the zero-touch load mechanism. |
| PANTHEON_BUILD_ID | modifyConfig | Pin a deterministic build id (stable /_next/data/<id>/ URLs on unchanged-source rebuilds). Unset ⇒ Next.js default. |
| PANTHEON_DEPLOYMENT_ID | modifyConfig | Bake config.deploymentId for skew protection: the server emits the x-nextjs-deployment-id header and suffixes assets with ?dpl=<id>, so clients on a stale deployment hard-navigate instead of fetching mismatched data. Set a value unique per deployment. Unset ⇒ off (no header/suffix). |
| PANTHEON_ADAPTER_MANIFEST_PATH | onBuildComplete, pantheon-stage | Override manifest location (default <distDir>/manifest.json). |
| PANTHEON_DIST_DIR | pantheon-stage | Dist dir for staging (default .next, resolved from cwd). |
| CACHE_BUCKET | injected cache handler (runtime) | Selects the GCS-backed shared cache (else file-based). Late-bound by tenant-secrets-agent; surfaced in the manifest's image.envRequired. |
Diagnostics go to stderr — the e2e-deploy harness reserves stdout for the deployment URL.
Publishing to npm
The adapter ships to the public npm registry as @pantheon-systems/nextjs-build-adapter
(scoped; publishConfig.access is public, so no --access public flag is needed). dist/ is
git-ignored, but prepublishOnly (and prepack) run clean && build, so publish always packs a
freshly compiled dist/. Releases are cut manually.
Prerequisites: membership in the @pantheon-systems npm org with publish rights, and a clean,
tested tree (pnpm test, pnpm lint, pnpm typecheck green).
1. Log in to npm
npm login # interactive: username, password/2FA — one-time per machine
npm whoami # confirm you're authenticatedFor CI/automation, skip npm login and use an npm automation token instead:
npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN2. Set the version
package.json is already at 0.1.0 — publish it as-is for the first release. For later releases,
bump the version (this writes package.json and creates a git commit + tag):
npm version patch # 0.1.0 -> 0.1.1 (use minor / major as appropriate)3. Publish (push to npm)
Inspect the tarball first, then publish:
npm pack --dry-run # lists what ships: dist/, README.md, LICENSE, package.json
npm publish # prepublishOnly rebuilds dist/ automatically
# add --otp=<code> if your npm account enforces 2FA for publishingpnpm equivalent (also runs prepublishOnly): pnpm publish — add --no-git-checks when
publishing from a tree that isn't a clean, tagged commit.
4. Verify
npm view @pantheon-systems/nextjs-build-adapter versionConsumers then add it like any dependency — pnpm add @pantheon-systems/nextjs-build-adapter —
and the build environment points NEXT_ADAPTER_PATH at the installed
dist/index.js. To rehearse the whole inject + install path against the local package before
publishing, use scripts/run-against-sample.sh, which packs the
tarball and installs it into a sample app exactly as npm would.
Layout
├── src/
│ ├── index.ts # the NextAdapter (name, modifyConfig, onBuildComplete)
│ ├── manifest.ts # BuildManifest type + buildManifest(ctx) (schema-aligned)
│ ├── emit.ts # writeManifest(ctx) — resolve path + write manifest.json
│ ├── stage.ts # stageAssets() — copy static/public into standalone tree
│ ├── stage-cli.ts # `pantheon-stage` bin — post-build staging entry point
│ ├── layout.ts # standalone output layout (shared by manifest + staging)
│ ├── build-id.ts # generateBuildId composition from PANTHEON_BUILD_ID
│ ├── deployment-id.ts # config.deploymentId (skew protection) from PANTHEON_DEPLOYMENT_ID
│ └── cache/
│ ├── config.ts # cache-handler injection into next.config
│ └── handlers/ # cache-handler + use-cache-handler entry files
├── test/ # node:test suites (native .ts)
├── e2e/ # fixtures/ (Pantheon matrix; app-router doubles as the smoke app) + e2e docs
├── scripts/ # e2e harness (e2e-deploy/logs/cleanup) + suite runners
│ # run-fixtures.sh + check-manifest.mjs
├── eslint.config.mjs
├── tsconfig.json
└── package.json # bin: pantheon-stage → dist/stage-cli.jsStatus
Adapter implemented: config injection (standalone + Pantheon cache handlers + build-id pinning),
manifest emit (schemaVersion 1.1: Node routes/middleware + CDN caching hints), and the post-build
pantheon-stage staging CLI, with an e2e smoke test and the official Next.js deploy-mode suite
wired in CI. The edge runtime is not supported — builds that use it still succeed (the output
runs in the standalone Node server) but the adapter warns and emits no edge-runtime metadata; see
Edge runtime. Known follow-ups: emit an
experimental.immutableAssetToken so the immutable-asset e2e tests can verify long-TTL caching
(see e2e/README.md). The Go orchestration/ layer is not yet scaffolded.
