@gregorygrant/nextjs-adapter
v0.1.5
Published
Next.js Adapter for Pantheon.
Maintainers
Readme
Pantheon Next.js Build Adapter
Makes a standard Next.js app build and run on Pantheon, with no changes to the app's repo.
No next.config edit. No dependency to install. No plugin to register. The build environment
loads the adapter automatically, and Next.js 16.2's official adapter API does the rest.
Why it exists
The adapter's job is to take whatever Next.js produced and make it behave correctly behind Pantheon's proxy, cache, and CDN, then describe the result in a manifest the deploy pipeline can act on.
What it actually does
Three steps, in order around next build:
1. Tune the build (modifyConfig)
Runs in-build, production only; next dev and next start are untouched. It sets:
| Setting | Why |
|---|---|
| output: 'standalone' | The app runs as a self-contained Node server behind Pantheon's proxy. |
| Pantheon cache handlers | ISR, route, fetch, and 'use cache' all share one object-storage-backed cache. In-memory caching is turned off so the shared cache is the single source of truth. Skipped entirely if the app sets its own cacheHandler. A deliberate choice is never overridden. |
| Deterministic build ID | Stable /_next/data/<id>/ URLs across rebuilds of unchanged source. |
| Deployment ID | Turns on Next's skew protection, so a visitor on a stale deployment reloads instead of fetching mismatched data. |
| trustHostHeader | So request.nextUrl.origin and absolute self-URLs resolve to the real public hostname, not the container's bind address. Next only turns this on for Vercel by default. |
| Image-decode concurrency | Next's default misreads CPU count inside a CPU-limited container. Pinned instead to the tenant's real budget. |
Notably absent: any default Server Actions origin allowlist. A platform-wide wildcard entry would make every other tenant's site a trusted origin for this one; host/origin agreement is kept at the HTTP boundary instead, per request. Full reasoning: src/server-actions.ts.
2. Write the manifest (onBuildComplete)
Reads Next's typed build output and writes manifest.json: the canonical description of routes,
middleware, and caching hints that the deploy layer consumes. The adapter stays stateless; the
builder uploads the file.
3. Stage the output (pantheon-stage)
Runs after next build returns, because Next only writes the standalone/ directory once the
hooks are done. It copies .next/static and public/ into the standalone tree, then patches one
line of Next's own server.js.
The standalone template hardcodes process.env.HOSTNAME || '0.0.0.0', and a truthy hostname overrides
trustHostHeader when Next builds URLs, making that flag dead code for every standalone deploy.
Dropping the fallback fixes it, and binding behavior is unchanged.
Build order: next build (adapter injected) → pantheon-stage → package the image.
Usage
The adapter is loaded through the NEXT_ADAPTER_PATH environment variable, with no config change
in the app:
pnpm build
export NEXT_ADAPTER_PATH="$(pwd)/dist/index.js"
next build # Next.js loads the adapter; no config change needed
pantheon-stage # stage assets, patch server.jsRequirements
- Node.js >= 22 (developed on 26). Tests run TypeScript natively: no
tsx, no ts-node. - Next.js >= 16.2 (peer dependency, provided by the build environment). Targets the 16.2 build-output contract, with no shims for older majors.
- pnpm 11. Settings live in
pnpm-workspace.yaml.
Commands
| Command | What it does |
|---|---|
| pnpm install | Install dependencies. |
| pnpm build | Compile src/ → dist/. Entry point matches NEXT_ADAPTER_PATH. |
| pnpm test | Run all tests. Add pnpm test:one "<name>" for a single one. |
| pnpm typecheck / pnpm lint | Type-check / lint (lint:fix to autofix). |
| pnpm e2e:smoke | Build a fixture through the real adapter, boot the staged server, probe it. |
| pnpm e2e:local | Run the official Next.js adapter deploy suite locally (one group at a time). |
| pnpm clean | Remove dist/. |
Certification
The adapter is validated against the official Next.js adapter deploy suite, which drives it through the three-script harness contract:
| Script | Contract |
|---|---|
| scripts/e2e-deploy.sh | Builds and "deploys" the test app; prints the deployment URL as its only stdout. |
| scripts/e2e-cdn-proxy.mjs | Fronts the origin during e2e runs, applying the edge's Cache-Control normalization. |
| scripts/e2e-logs.sh | Replays build/server logs, including the BUILD_ID:, DEPLOYMENT_ID: and IMMUTABLE_ASSET_TOKEN: markers. |
| scripts/e2e-cleanup.sh | Tears down whatever the deploy script started. |
.github/workflows/test-e2e-deploy.yml runs the suite
across 16 parallel groups against a vercel/next.js checkout, then
scripts/upload-adapter-test-results.mjs submits the
collected Jest results. Submission requires an ADAPTER_TEST_RESULTS_SECRET repository secret;
without it the upload step is skipped.
The deployment URL the harness receives points at a small reverse proxy, not the Node origin
directly. A real deploy platform rewrites Cache-Control on prerendered/ISR responses from the
origin's s-maxage=... to the browser-facing public, max-age=0, must-revalidate; this
origin-only harness has no edge, so the proxy applies that same normalization. Immutable
/_next/static assets pass through untouched. Without it the suite's deploy-mode caching
assertions would fail against a correctly behaving origin.
The suite runs with no test exclusions.
e2e/deploy-tests-overrides.json (the overlay merged onto
Next's own manifest via NEXT_EXTERNAL_TESTS_FILTERS) is present but empty. Every case in the
deploy suite runs.
Where the suite depends on something the bare origin cannot provide, that thing is supplied
rather than excluded around: the CDN proxy above gives the cache-control cases the edge layer
they assert against, and the deploy script retries with next build --webpack when an upstream
Turbopack defect breaks a build. The overlay file records the bar for adding an entry: an
upstream defect reproducible on a plain next build, or a deploy-platform layer the harness
genuinely does not include. An adapter bug is never hidden there.
Environment variables
Build time
| Variable | Purpose |
|---|---|
| NEXT_ADAPTER_PATH | Path to the built adapter. This is the zero-touch load mechanism. |
| PANTHEON_BUILD_ID | Pin a deterministic build ID. Unset → Next's default. |
| PANTHEON_DEPLOYMENT_ID | Enable skew protection. Unique per deployment, not per build. Unset → off. |
| PANTHEON_ALLOWED_ORIGINS | Extra Server Actions origins. Normally unnecessary. |
| IMAGE_OPT_CONCURRENCY | Image-decode concurrency; set to the tenant's real vCPU budget. Default 2. |
| PANTHEON_DIST_DIR, PANTHEON_ADAPTER_MANIFEST_PATH | Override dist dir / manifest location. |
Runtime
| Variable | Purpose |
|---|---|
| CACHE_BUCKET | Selects the shared object-storage cache. Without it, caching falls back to files. |
Layout
src/
index.ts the adapter itself: modifyConfig + onBuildComplete
manifest.ts build the manifest from Next's typed output
emit.ts write manifest.json
stage.ts copy static/public into standalone, patch server.js
stage-cli.ts the `pantheon-stage` command
env.ts the one place process.env is read
build-id.ts, deployment-id.ts, server-actions.ts,
trust-host-header.ts, image-opt.ts one config concern each
cache/ cache-handler injection + handler entry files
test/ unit tests (node:test, native TypeScript)
e2e/ deploy-suite overrides + the smoke-test fixture
scripts/ the deploy-suite harness contract + local runnerLicense
MIT.
