Today Platform Web — Dev Docs
Architecture

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:

AspectServer (Node.js)Client (Browser)
RuntimeNode.js processBrowser JavaScript engine
process.envReal OS-level environment objectSynthetic — only NEXT_PUBLIC_* values, inlined as literals at build time
Can read env at runtime?YesNo — 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_URL

After 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's loadEnvConfig. 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 token

This 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:

PrioritySourceCommitted?Loaded when
1 (highest)OS / CI process.envAlways
2.env.{mode}.localNoMatching mode
3.env.localNoAlways, except when NODE_ENV=test
4.env.{mode}YesMatching mode
5 (lowest).envYesAlways

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 devmode = "development"
  • next buildmode = "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 Proxyman

The 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 production

Preview (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 preview

The 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:

EnvironmentDomainAuthAPIDetection
developmentt.aiauth.t.aiapi.t.aiNODE_ENV === "development"
previewtodayai.devauth.todayai.devapi.todayai.devDefault fallback
productiontoday.aiauth.today.aiapi.today.aiVERCEL_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.

VariablePurposeUsed in
NEXT_PUBLIC_OIDC_AUTHORITYAuth server base URLauth-client, web, admin
NEXT_PUBLIC_OIDC_CLIENT_IDOAuth client identifierweb, admin
NEXT_PUBLIC_API_URLAPI server base URLauth-client, web, admin
NEXT_PUBLIC_APP_URLApplication origin (for OAuth redirects)auth-client, web, admin
NEXT_PUBLIC_VERCEL_ENVEnvironment tier (bridged from VERCEL_ENV)auth-client
NEXT_PUBLIC_BETTER_AUTH_URLBetter Auth base URL overrideauth-client
NEXT_PUBLIC_TOKEN_AUDIENCEAPI token audience overrideauth-client
NEXT_PUBLIC_ADMIN_URLAdmin app originweb
NEXT_PUBLIC_INTEGRATION_BASE_URLIntegration service base URLweb, admin
NEXT_PUBLIC_TRAFFIC_LANERequest routing header for canary deploysweb, admin
NEXT_PUBLIC_BUILD_IDBuild identifier for diagnosticsweb
NEXT_PUBLIC_OAUTH_MANAGED_CLIENT_NAMEManaged OAuth client display nameadmin
NEXT_PUBLIC_OAUTH_MANAGED_REDIRECT_URIManaged OAuth client redirect URIadmin

Server-only

Available only in Node.js server-side code (API routes, Server Components, middleware). Never exposed to the browser.

VariablePurpose
OIDC_CLIENT_SECRETOAuth client secret for confidential token exchange
OIDC_INTERNAL_AUTHORITYAuth server URL for server-to-server calls (see below)
VERCEL_ENVVercel 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.

VariableValueNotes
VERCEL"1"Indicates running on Vercel
VERCEL_ENV"production" / "preview" / "development"Bridged to client via next.config.ts
VERCEL_URLDeployment URL (no protocol)Unique per deployment

Debugging Checklist

When an environment variable isn't working as expected:

  1. Is it a NEXT_PUBLIC_* variable? If not, it's server-only and cannot be used in client components.

  2. 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.

  3. Was it available at build time?

    • For CI builds: check .vercel/.env.{production,preview}.local (created by vercel 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 > .local files > committed files
  4. Is the correct .env file being loaded? Check all five priority levels:

    • process.env (OS / CI / vercel pull)
    • .env.{mode}.local
    • .env.local
    • .env.{mode} (committed)
    • .env (committed)
  5. GitHub Actions specific: did vercel build print the system env warning? "WARNING! Build not running on Vercel." means system vars like VERCEL_ENV are not auto-injected, but they should still be present via vercel pull.

  6. 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:

  1. Cookie domain alignment: Auth server sets cookies on .todayai.dev (or .t.ai for local). The browser must see the same domain for both the auth server and the frontend.
  2. Google OAuth redirect: Google redirects to auth.todayai.dev, which then redirects to todayai.dev. The entire chain must use real (or consistently proxied) domains.
  3. TLS: OAuth cookies use Secure attribute, 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

ModeFrontend domainBackendProxyman routes
Localhttps://t.ailocalhost:4010 (auth), localhost:4020 (API)All *.t.ai → local services
Remote Devhttps://todayai.devRemote todayai.dev servicesOnly root todayai.devlocalhost: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.ai

Remote 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.dev

Why 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.dev returns Set-Cookie: Domain=.todayai.dev, the browser rejects it because the request origin is localhost. 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 to todayai.dev — not localhost:3000.
  • No TLS: Secure cookies won't be sent over http://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:PORT is allowed as a redirect URI without HTTPS
  • http://127.0.0.1:PORT also 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:3000

This 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).

On this page