Environment variables
How env vars flow through Next.js — `.env` files, build-time inlining, client-vs-server boundaries, and how it plays with our Vercel prebuilt deploys.
This document explains how environment variables flow through the development and build lifecycle of a Next.js application — from .env files in the repository, through the build pipeline, into the final client and server bundles, and ultimately to the browser.
Written with specific reference to our monorepo setup (GitHub Actions + Vercel prebuilt deploys), but the core principles apply to any Next.js project.
Server vs Client
A Next.js application runs in two fundamentally different environments:
| Aspect | Server (Node.js) | Client (Browser) |
|---|---|---|
| Runtime | Node.js process | Browser JavaScript engine |
process.env | Real OS-level environment object | Synthetic — only NEXT_PUBLIC_* values, inlined as literals at build time |
| Can read env at runtime? | Yes | No — values are frozen at build time |
This distinction is the source of nearly every environment variable bug in Next.js projects. The rest of this document explains the mechanism in detail and how it interacts with our deployment pipeline.
The Inlining Mechanism
How process.env.NEXT_PUBLIC_* gets replaced
Next.js uses compile-time text substitution (webpack's DefinePlugin, or the equivalent in Turbopack) during next build. Every occurrence of the literal token process.env.NEXT_PUBLIC_FOO is replaced with the actual value as a JSON string literal.
Before build:
const url = process.env.NEXT_PUBLIC_API_URLAfter build (in client bundle):
const url = 'https://api.today.ai'This is a static string match. The bundler searches for the exact character sequence process.env.NEXT_PUBLIC_API_URL and replaces it. It does not perform data flow analysis.
The inlining rules are identical between webpack and Turbopack — both consume the same resolved env values from
@next/env'sloadEnvConfig. Only the bundler internals differ; the developer-facing behavior is the same.
What does NOT get inlined
Any indirection defeats the substitution:
// NONE of these will be inlined in client bundles:
const env = process.env
env.NEXT_PUBLIC_API_URL // bundler sees `env.X`, not `process.env.X`
function resolve(env = process.env) {
return env.NEXT_PUBLIC_API_URL // same problem — parameterised access
}
const key = 'NEXT_PUBLIC_API_URL'
process.env[key] // dynamic key — can't evaluate statically
const { NEXT_PUBLIC_API_URL } = process.env // destructuring — doesn't match the tokenThis is explicitly documented by Next.js:
Note that dynamic lookups will not be inlined, such as:
const env = process.env; setupAnalyticsService(env.NEXT_PUBLIC_ANALYTICS_ID)
Rule: Always use the full literal process.env.NEXT_PUBLIC_* expression. Never abstract it behind a variable, parameter, or function argument.
Writing bundler-safe shared utilities
When building shared utility functions that need to read NEXT_PUBLIC_* vars, split the code path:
function resolveApiUrl(overrideEnv?: Record<string, string | undefined>): string {
if (overrideEnv) return overrideEnv.NEXT_PUBLIC_API_URL || 'https://api.todayai.dev'
return process.env.NEXT_PUBLIC_API_URL || 'https://api.todayai.dev'
}The default path uses a literal process.env.NEXT_PUBLIC_API_URL that the bundler can replace. The override path accepts an injected env object for unit testing. See packages/auth-client/src/urls.ts for the full implementation.
Bridging non-NEXT_PUBLIC_* vars to the client
The env field in next.config.ts lets you define additional compile-time constants:
const nextConfig: NextConfig = {
env: {
NEXT_PUBLIC_VERCEL_ENV: process.env.VERCEL_ENV,
},
}At config evaluation time (during next build), process.env.VERCEL_ENV is read from the real Node.js environment. The resulting value is registered with the bundler's define mechanism, making process.env.NEXT_PUBLIC_VERCEL_ENV available in client code as an inlined literal. The same inlining rules apply.
We use this to bridge VERCEL_ENV (a Vercel system variable, not prefixed with NEXT_PUBLIC_) into client bundles. Both apps/web/next.config.ts and apps/admin/next.config.ts include this bridge.
.env File Loading
Priority order
Next.js resolves each environment variable by scanning sources in this order, stopping at the first match:
| Priority | Source | Committed? | Loaded when |
|---|---|---|---|
| 1 (highest) | OS / CI process.env | — | Always |
| 2 | .env.{mode}.local | No | Matching mode |
| 3 | .env.local | No | Always, except when NODE_ENV=test |
| 4 | .env.{mode} | Yes | Matching mode |
| 5 (lowest) | .env | Yes | Always |
Higher-priority sources win. If NEXT_PUBLIC_API_URL is set in process.env (e.g., via vercel pull), the value in .env.development is ignored.
What is {mode}?
{mode} is not the raw NODE_ENV value. Next.js derives it as:
next dev→mode = "development"next build→mode = "production"NODE_ENV === "test"→mode = "test"
Custom NODE_ENV values (e.g., staging) do not produce a .env.staging lookup. If you set NODE_ENV=staging and run next build, the mode is still production.
Our file layout
apps/web/
.env.development # Committed: default URLs for local dev (*.t.ai)
.env.development.local # Gitignored: credentials (client_id, secret)
.env.development.local.example # Template for the above
.env.profile.local.example # Template: local backend via Proxyman
.env.profile.dev.example # Template: remote dev backend (todayai.dev)
apps/admin/
.env.development # Committed: default URLs for dev (*.todayai.dev)
.env.production # Committed: fallback URLs for production builds
.env.development.local # Gitignored: credentials
.env.development.local.example # Template for the above
.env.local.example # Template: general local config
.env.profile.local.example # Template: local backend via ProxymanThe committed .env.development and .env.production files provide sensible URL defaults. Secrets (OIDC_CLIENT_SECRET, NEXT_PUBLIC_OIDC_CLIENT_ID) go in .env.*.local files which are gitignored.
Note: apps/web has no committed .env.production. In production builds, URL values come entirely from Vercel project environment variables (injected via vercel pull).
Profile files: The .env.profile.*.example files are development convenience templates. They configure the app to connect to either a local backend (via Proxyman at *.t.ai) or the remote dev backend (*.todayai.dev). Copy one to .env.development.local to switch profiles.
Environment Variable Lifecycle
Local Development (pnpm dev)
mode = "development"
Effective load order:
1. OS environment (if any vars are set)
2. apps/web/.env.development.local ← credentials, profile overrides
3. apps/web/.env.local ← (if exists)
4. apps/web/.env.development ← default URLs (*.t.ai)
5. apps/web/.env ← (if exists)next dev runs the bundler in watch mode. NEXT_PUBLIC_* values are inlined on every recompilation. Because mode=development, the committed .env.development is loaded, providing URLs like https://auth.t.ai. Credentials come from the gitignored .env.development.local.
GitHub Actions Prebuilt Deploy (our setup)
This is the most complex case and where most bugs originate. The flow differs between production (main branch) and preview (all other branches):
Production (main branch):
vercel pull --yes --environment=production
→ downloads Vercel project env vars to .vercel/.env.production.local
→ contains: VERCEL_ENV="production", NEXT_PUBLIC_OIDC_AUTHORITY="https://auth.today.ai", ...
vercel build --prod
→ injects vars from .vercel/.env.production.local into the Node.js process
→ runs: next build (mode = "production")
→ next build also loads: apps/admin/.env.production (committed defaults, lower priority)
→ DefinePlugin/Turbopack inlines NEXT_PUBLIC_* into client bundles
vercel deploy --prebuilt --prod --archive=tgz
→ uploads the pre-built artifacts to Vercel productionPreview (dev branch, feature branches, PRs):
vercel pull --yes --environment=preview
→ downloads to .vercel/.env.preview.local
→ contains: VERCEL_ENV="preview", NEXT_PUBLIC_OIDC_AUTHORITY="https://auth.todayai.dev", ...
vercel build
→ same process, but mode = "production" (next build always uses production mode)
→ different env values result in different inlined URLs
vercel deploy --prebuilt --archive=tgz
→ uploads to Vercel previewThe critical subtlety: vercel build injects variables from the pulled .vercel/.env.{environment}.local file into process.env before running next build. This means process.env.NEXT_PUBLIC_OIDC_AUTHORITY is available during the build and gets inlined into client bundles.
However, VERCEL_ENV is not a NEXT_PUBLIC_* variable, so it does not get inlined automatically — this is why we bridge it via next.config.ts (see Bridging non-NEXTPUBLIC* vars).
Vercel Runtime (after deploy)
After deployment, running serverless functions have access to:
- Vercel system env vars (
VERCEL_ENV,VERCEL_URL, etc.) - Project env vars from the Vercel dashboard
- Values from committed
.env.production(baked into the deployment artifact)
Client-side code only has access to what was inlined at build time.
Three-Tier Domain Strategy
The domain tier is determined by the env values that were inlined at build time. Our URL resolution uses three tiers:
| Environment | Domain | Auth | API | Detection |
|---|---|---|---|---|
| development | t.ai | auth.t.ai | api.t.ai | NODE_ENV === "development" |
| preview | todayai.dev | auth.todayai.dev | api.todayai.dev | Default fallback |
| production | today.ai | auth.today.ai | api.today.ai | VERCEL_ENV === "production" |
In practice, detection is a fallback. Each Vercel project has explicit NEXT_PUBLIC_OIDC_AUTHORITY, NEXT_PUBLIC_API_URL, etc. The resolver functions check these first:
export function resolveAuthBaseUrl(env?: Env): string {
// ...
return (
process.env.OIDC_INTERNAL_AUTHORITY ||
process.env.NEXT_PUBLIC_OIDC_AUTHORITY ||
`https://auth.${resolveDomain()}` // computed fallback using VERCEL_ENV
)
}See packages/auth-client/src/urls.ts for the full implementation.
Variable Reference
Client-safe (NEXT_PUBLIC_*)
Inlined into client bundles at build time. Visible to anyone inspecting the site's JavaScript. Never put secrets here.
| Variable | Purpose | Used in |
|---|---|---|
NEXT_PUBLIC_OIDC_AUTHORITY | Auth server base URL | auth-client, web, admin |
NEXT_PUBLIC_OIDC_CLIENT_ID | OAuth client identifier | web, admin |
NEXT_PUBLIC_API_URL | API server base URL | auth-client, web, admin |
NEXT_PUBLIC_APP_URL | Application origin (for OAuth redirects) | auth-client, web, admin |
NEXT_PUBLIC_VERCEL_ENV | Environment tier (bridged from VERCEL_ENV) | auth-client |
NEXT_PUBLIC_BETTER_AUTH_URL | Better Auth base URL override | auth-client |
NEXT_PUBLIC_TOKEN_AUDIENCE | API token audience override | auth-client |
NEXT_PUBLIC_ADMIN_URL | Admin app origin | web |
NEXT_PUBLIC_INTEGRATION_BASE_URL | Integration service base URL | web, admin |
NEXT_PUBLIC_TRAFFIC_LANE | Request routing header for canary deploys | web, admin |
NEXT_PUBLIC_BUILD_ID | Build identifier for diagnostics | web |
NEXT_PUBLIC_OAUTH_MANAGED_CLIENT_NAME | Managed OAuth client display name | admin |
NEXT_PUBLIC_OAUTH_MANAGED_REDIRECT_URI | Managed OAuth client redirect URI | admin |
Server-only
Available only in Node.js server-side code (API routes, Server Components, middleware). Never exposed to the browser.
| Variable | Purpose |
|---|---|
OIDC_CLIENT_SECRET | OAuth client secret for confidential token exchange |
OIDC_INTERNAL_AUTHORITY | Auth server URL for server-to-server calls (see below) |
VERCEL_ENV | Vercel deployment environment (production / preview / development) |
Why OIDC_INTERNAL_AUTHORITY exists: In local development, the browser talks to the auth server through Proxyman (https://auth.t.ai), which handles TLS and domain mapping. But the Node.js BFF routes (running on localhost:3000) should talk directly to the auth service on localhost:4010 — bypassing Proxyman avoids proxy overhead and TLS certificate issues for server-to-server communication. In production, OIDC_INTERNAL_AUTHORITY can point to an internal service mesh endpoint that bypasses the public load balancer.
Vercel System Variables
Auto-injected by Vercel at build time and runtime. During GitHub Actions builds, these are only available if pulled via vercel pull. See Vercel docs for the complete list.
| Variable | Value | Notes |
|---|---|---|
VERCEL | "1" | Indicates running on Vercel |
VERCEL_ENV | "production" / "preview" / "development" | Bridged to client via next.config.ts |
VERCEL_URL | Deployment URL (no protocol) | Unique per deployment |
Debugging Checklist
When an environment variable isn't working as expected:
-
Is it a
NEXT_PUBLIC_*variable? If not, it's server-only and cannot be used in client components. -
Are you accessing it as a literal
process.env.NEXT_PUBLIC_*? If accessed through a variable, parameter, or destructuring, the bundler won't inline it. Search the built output in.next/static/chunks/for the expected value. -
Was it available at build time?
- For CI builds: check
.vercel/.env.{production,preview}.local(created byvercel pull) - For Vercel project settings:
vercel env ls - For committed defaults: check
.env.{mode}files in the app directory - Remember the priority order: OS env >
.localfiles > committed files
- For CI builds: check
-
Is the correct
.envfile being loaded? Check all five priority levels:process.env(OS / CI /vercel pull).env.{mode}.local.env.local.env.{mode}(committed).env(committed)
-
GitHub Actions specific: did
vercel buildprint the system env warning?"WARNING! Build not running on Vercel."means system vars likeVERCEL_ENVare not auto-injected, but they should still be present viavercel pull. -
Inspect the actual bundle:
vercel curl '/_next/static/chunks/CHUNK_NAME.js' --yes | grep -o 'auth\.[a-z.]*'Architecture Diagram
Source Code
|
+--------------------+--------------------+
| |
.env.development .vercel/.env.{env}.local
.env.development.local (from vercel pull in CI)
(local dev) .env.production (committed)
| |
v v
next dev (HMR) next build
mode = "development" mode = "production"
| |
| +----------+----------+
| | |
| Server bundle Client bundle
| (Node.js) (browser)
| | |
| process.env = process.env.* =
| real OS env obj inlined NEXT_PUBLIC_*
| | literals only
| | |
v v v
localhost:3000 Serverless Fn Static JS files
(all vars live) (env vars from (frozen at build;
Vercel dashboard only NEXT_PUBLIC_*
+ committed .env) values survive)Local Development with Remote Backends
Connecting a local Next.js frontend to a remote backend introduces three interrelated problems: CORS, cookie domain matching, and OAuth redirect URIs. This section explains the approaches available and why our setup uses the combination it does.
For step-by-step setup instructions, see Local development.
The core problem
OAuth session cookies are scoped to a domain. When the auth server sets Set-Cookie with Domain=.todayai.dev, the browser only sends that cookie on requests to *.todayai.dev. If the frontend runs on localhost:3000, the cookie is never sent — login breaks silently.
This is not a CORS issue and cannot be solved by CORS headers alone.
Approach comparison
Approach CORS Cookies OAuth TLS Setup
────────────────────────────────────────────────────────────────────
Backend CORS config ✓* partial ✓ ✗ low
Next.js rewrites ✓ partial† ✓ ✗ low
MitM proxy (Proxyman) ✓ ✓ ✓ ✓ medium ← our setup
Tunnel (ngrok) ✗ ✗ partial ✓ low
* Only for requests to the configured backend
† Remote Set-Cookie with mismatched Domain is rejected by the browser (RFC 6265)Why we use Proxyman
We chose the MitM proxy approach because our auth flow requires all three:
- Cookie domain alignment: Auth server sets cookies on
.todayai.dev(or.t.aifor local). The browser must see the same domain for both the auth server and the frontend. - Google OAuth redirect: Google redirects to
auth.todayai.dev, which then redirects totodayai.dev. The entire chain must use real (or consistently proxied) domains. - TLS: OAuth cookies use
Secureattribute, requiring HTTPS on the frontend.
Proxyman satisfies all three by making the browser see https://todayai.dev (or https://t.ai) while the actual traffic goes to localhost:3000.
Two development modes
| Mode | Frontend domain | Backend | Proxyman routes |
|---|---|---|---|
| Local | https://t.ai | localhost:4010 (auth), localhost:4020 (API) | All *.t.ai → local services |
| Remote Dev | https://todayai.dev | Remote todayai.dev services | Only root todayai.dev → localhost:3000 |
The mode is selected by which .env.development.local values are active:
Local mode (default — URL overrides commented out):
# .env.development provides defaults:
NEXT_PUBLIC_OIDC_AUTHORITY=https://auth.t.ai # browser-facing, through Proxyman
OIDC_INTERNAL_AUTHORITY=http://localhost:4010 # server-side, bypasses Proxyman
NEXT_PUBLIC_API_URL=https://api.t.aiRemote dev mode (URL overrides uncommented):
NEXT_PUBLIC_OIDC_AUTHORITY=https://auth.todayai.dev
OIDC_INTERNAL_AUTHORITY=https://auth.todayai.dev
NEXT_PUBLIC_API_URL=https://api.todayai.devWhy OIDC_INTERNAL_AUTHORITY bypasses Proxyman
Node.js fetch (undici) does not respect system HTTP proxy settings by default. Even when Proxyman is active as the system proxy, Node.js processes connect directly to the target host.
This is actually desirable: the BFF route handlers in Next.js should talk directly to the auth service without going through Proxyman's TLS interception. In local mode:
Browser → https://auth.t.ai → Proxyman → localhost:4010 (browser path, with TLS)
Node.js → http://localhost:4010 (server path, direct)OIDC_INTERNAL_AUTHORITY=http://localhost:4010 gives the BFF routes a direct path. If we used https://auth.t.ai for server-side calls, Node.js would try to resolve auth.t.ai DNS (which may not exist) and bypass Proxyman entirely, resulting in connection failures.
In remote dev mode, OIDC_INTERNAL_AUTHORITY=https://auth.todayai.dev works because auth.todayai.dev is a real DNS name with a real TLS certificate — no proxy needed.
OAuth flow through Proxyman
1. Browser on https://todayai.dev clicks "Login with Google"
2. Next.js BFF (/api/auth/*) → auth.todayai.dev → redirects browser to Google
3. Google authenticates user → redirects to auth.todayai.dev/callback
4. Auth server sets cookie: Set-Cookie: session=...; Domain=.todayai.dev; Secure
5. Auth server redirects browser to https://todayai.dev
6. Proxyman intercepts todayai.dev → forwards to localhost:3000
7. Browser sends .todayai.dev cookie (domain matches) ✓Without Proxyman, step 7 would fail: the browser on localhost:3000 would not send a .todayai.dev cookie.
Alternative: Next.js rewrites (no Proxyman)
For frontend-only work that doesn't need OAuth login, you can skip Proxyman and use Next.js rewrites:
// next.config.ts (dev only)
async rewrites() {
return [
{ source: '/api/backend/:path*', destination: 'https://api.todayai.dev/:path*' },
]
}This proxies API calls server-side, eliminating CORS. However:
- Cookie domain mismatch: If
api.todayai.devreturnsSet-Cookie: Domain=.todayai.dev, the browser rejects it because the request origin islocalhost. You need the BFF to re-set cookies as first-party. - OAuth login won't work: The auth redirect lands on
auth.todayai.dev, which redirects back totodayai.dev— notlocalhost:3000. - No TLS:
Securecookies won't be sent overhttp://localhost.
This approach is viable for authenticated API calls using Bearer tokens (where the token is stored in memory or localStorage), but not for cookie-based session auth with OAuth.
Google OAuth on localhost
Google OAuth has a special exemption for loopback addresses:
http://localhost:PORTis allowed as a redirect URI without HTTPShttp://127.0.0.1:PORTalso works but is a different URI — register separately- The redirect URI must exactly match the registration (scheme, host, port, path)
This means you can technically do Google OAuth on localhost if your own auth server is also on localhost and you register http://localhost:4010/callback as a redirect URI in Google Cloud Console. Our remote dev mode doesn't use this because the auth server runs remotely on auth.todayai.dev.
Local HTTPS without Proxyman
Next.js provides --experimental-https:
next dev --experimental-https
# → https://localhost:3000This gives you a Secure Context and makes Secure cookies work on localhost. However, it doesn't solve domain alignment — the browser still sees localhost, not todayai.dev, so OAuth session cookies scoped to .todayai.dev won't be sent.
Use --experimental-https when you need TLS but don't need domain-aligned cookies (e.g., testing Secure cookie attributes, browser APIs that require Secure Context, or SameSite=None behavior).
Three-tier domains
t.ai, todayai.dev, today.ai — development / preview / production. How URL resolution branches on environment in packages/auth-client.
Environment detection
getEnvironment() returns development / preview / production — a stable literal across SSR and hydration. Used to gate OnboardingRedirectGuard and debug tooling.