Standardizing runtime env across a fleet of Next.js + Supabase apps
Sanitized write-up. Names/hosts/keys generalized. Not for a specific client.
I had a dozen Next.js + Supabase apps that each reinvented environment
configuration — the same layered-dotenv loader forked under a handful of package
scopes, inlined into a preload script in some repos, absent in others. Config
that varies by environment leaked into the build as NEXT_PUBLIC_ constants, so
rotating a URL meant a rebuild. I set out to make one pattern and migrate the
first app end-to-end as the template.
The model
Environment variables are runtime config, not build constants. A single
variable — ENV — selects the stage (dev/test/staging/prod), and a small
loader merges dotenv files in precedence order:
.env < .env.local < .env.${ENV} < secretsPath < .env.${ENV}.local
The server reads process.env at request time and forwards only the
explicitly-public values to the client through a provider — no NEXT_PUBLIC_,
nothing baked into the bundle. Change a value, restart, done. One image promotes
across every stage.
Two shared packages carry it: a framework-agnostic loader and a typed
registry where each variable declares its type (public/secret/connection)
— which also drives what's safe to commit.
The gotchas that actually cost time
ESM-only broke CommonJS consumers. I shipped the loader ESM-only. Wrong:
tracing agents, --require hooks, and a CJS preload all need to require() it —
and Next compiles next.config.ts to CJS and require()s externals, throwing
ERR_PACKAGE_PATH_NOT_EXPORTED. Dual ESM+CJS, with CJS as a first-class citizen,
not a shim.
A build-time env preload clobbered NODE_ENV. Wiring the preload into the
build script overwrote the framework's NODE_ENV=production with development,
producing a dev/prod React mismatch that crashed prerendering. Rule: the env
preload belongs on dev/start, never on build.
Local database bring-up ran migrations before the schema existed. The local Supabase stack runs its own migrations at boot — but the ORM owns the schema, so any migration enabling row-level security or an auth hook that reads app tables failed. The fix: relocate that table-dependent SQL into a post-schema step that runs after the ORM creates tables — the same ordered step locally and in prod.
Row-level security with zero policies silently broke every login. The auth
token hook runs as a role that does not bypass RLS, so it read zero rows and
every token came back with no org/role. A GRANT isn't enough under RLS — you
need an explicit permissive policy for that role. Caught only by decoding a JWT.
Seeded users couldn't log in. Identity is the auth provider's user id; a plain database seed writes app rows with random ids that map to no auth user. The fix: create real auth users via the admin API with email-confirm on (skips signup, captcha, and the email round-trip), then key the app rows to the returned id. Now automated tests log in with known credentials.
.env.prod never reaches the platform. The container build ignores .env.*,
and the managed host doesn't run the preload — so on both platforms every
variable, secret and non-secret, has to be entered in the platform's settings. A
push does not magically configure prod; secrets, migrations, and the auth
dashboard config are one-time manual (or scripted) setup.
What shipped
The pattern is now three composable skills — the app-side loader, the local stack (containerized DB, ordered process orchestration, per-repo port blocks so several stacks coexist), and the first-prod-deploy runbook — plus the two shared packages. The first app migrated cleanly and verified end-to-end: local stack up, seeded accounts logging in with correct roles, no captcha friction for tests. It becomes the diff-template for the remaining eleven.
The meta-lesson: a shared dependency beats a shared document. Every one of those forks started as "we all agree on the pattern." Agreement drifts; a published package doesn't.