Today Platform Web — Dev Docs
Architecture

Environment variables

How env vars flow through Next.js in this repo — `.env` file layout, the `NEXT_PUBLIC_*` inlining rule that breaks our shared utilities if violated, the three-tier domain values, and the variable reference.

This page documents how environment variables work in today-platform-web specifically. The generic Next.js behavior — .env precedence rules, mode resolution, how NEXT_PUBLIC_* inlining works at the bundler level — is covered in Next.js docs; this page focuses on the parts that bite in this codebase.

The inlining rule that matters for shared utilities

The one Next.js behavior that breaks repo code on a regular basis: at next build, the bundler does a static text substitution of every literal process.env.NEXT_PUBLIC_* occurrence with its value as a JSON string. Any indirection defeats it — const env = process.env; env.X won't be replaced, neither will process.env[key] or destructuring.

That matters here because several @todayai-labs/* packages export shared resolver utilities that have to work both at runtime (with a real process.env) and in unit tests (with an injected mock). The correct pattern is to split the code path so the runtime/default path uses the literal expression:

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 the literal process.env.NEXT_PUBLIC_API_URL the bundler can substitute. The override path accepts an injected env object for tests. See packages/auth-client/src/urls.ts for the canonical implementation.

Rule for any shared utility that reads NEXT_PUBLIC_*: the runtime branch must use the full literal expression. If you find yourself writing const env = process.env; ..., you've made the variable unreadable from client bundles.

Bridging non-NEXT_PUBLIC_* vars

The env field in next.config.ts lets you register a non-NEXT_PUBLIC_* variable as a compile-time constant under a NEXT_PUBLIC_* name:

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 OS env and registered with the bundler's define mechanism. Client code reads process.env.NEXT_PUBLIC_VERCEL_ENV as an inlined literal under the same rules above.

apps/web/next.config.ts uses this to bridge VERCEL_ENV (a Vercel system variable, not prefixed with NEXT_PUBLIC_) into client bundles. The admin app follows the same pattern.

Our .env file layout

apps/web/
  .env.development                 # Committed: dev backend defaults
  .env.development.local           # Gitignored: credentials (client_id, secret)
  .env.development.local.example   # Template for the above
  .env.profile.dev.example         # Template: remote dev backend (todayai.dev)

The admin app uses the same layout from todayai-labs/today-admin.

Defaults (URL targets) live in committed .env.development and .env.production. Secrets (OIDC_CLIENT_SECRET, NEXT_PUBLIC_OIDC_CLIENT_ID) live in .env.*.local files which are gitignored.

apps/web deliberately has no committed .env.production — in production builds the URLs come entirely from the Vercel project's env vars (injected by vercel pull).

The .env.profile.*.example files are dev-only convenience templates. Copy the dev profile to .env.development.local when you need a local credentials file.

For the operational side — which file to copy when, how to run vercel env pull, dev mode selection — see Local development.

CI build flow (Vercel prebuilt deploys)

Production and preview both run the same shape, with vercel pull picking up environment-specific values:

vercel pull --yes --environment={production|preview}
  → writes .vercel/.env.{environment}.local with NEXT_PUBLIC_* + secrets

vercel build [--prod]
  → injects those vars into process.env
  → runs `next build` (mode = "production" in both cases)
  → loads apps/<app>/.env.production at lower priority for committed defaults
  → DefinePlugin / Turbopack inlines NEXT_PUBLIC_* into client bundles

vercel deploy --prebuilt [--prod] --archive=tgz
  → uploads the pre-built artifact

Two subtleties to remember:

  • next build is mode=production for both preview and prod. The values differ because vercel pull pulls a different .env.{environment}.local — but the build code path is identical.
  • VERCEL_ENV is not a NEXT_PUBLIC_* variable, so it does not inline automatically into client bundles. This is why next.config.ts bridges it via the env field (see Bridging non-NEXT_PUBLIC_* vars).

Backend Domain Strategy

URL values are resolved at build time per environment:

EnvironmentDomainAuthAPIDetection
developmenttodayai.devauth.todayai.devapi.todayai.devNODE_ENV === "development"
previewtodayai.devauth.todayai.devapi.todayai.devDefault fallback
productiontoday.aiauth.today.aiapi.today.aiVERCEL_ENV === "production"

In practice the detection logic is a fallback. Each Vercel project has explicit NEXT_PUBLIC_OIDC_AUTHORITY / NEXT_PUBLIC_API_URL / etc. set, and the resolver functions check those 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
  )
}

Full resolver in packages/auth-client/src/urls.ts; domain tier overview in Three-tier domains.

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
NEXT_PUBLIC_OIDC_CLIENT_IDOAuth client identifierweb
NEXT_PUBLIC_API_URLAPI server base URLauth-client, web
NEXT_PUBLIC_APP_URLApplication origin (for OAuth redirects)auth-client, web
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
NEXT_PUBLIC_TRAFFIC_LANERequest routing header for canary deploysweb
NEXT_PUBLIC_BUILD_IDBuild identifier for diagnosticsweb

The admin app (todayai-labs/today-admin) consumes the same @todayai-labs/auth-client and therefore the same auth / API / app-URL variables, plus its own NEXT_PUBLIC_OAUTH_MANAGED_CLIENT_NAME / NEXT_PUBLIC_OAUTH_MANAGED_REDIRECT_URI for the managed-OAuth client.

Server-only

Available only to Node.js (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
LOCALHOST_BFFStrips Domain from session cookies so localhost dev works (server-only)
VERCEL_ENVVercel deployment environment (production / preview / development)

Vercel system

Auto-injected by Vercel at build time and runtime. During CI builds, only available if pulled via vercel pull. Full list in Vercel docs.

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

OIDC_INTERNAL_AUTHORITY

OIDC_INTERNAL_AUTHORITY gives server-side BFF routes an auth origin that can be configured independently from the public browser authority. Local web dev uses the deployed dev auth backend, so the value should be https://auth.todayai.dev. Production uses https://auth.today.ai.

Debugging an env var that isn't doing what you expect

  1. Is it NEXT_PUBLIC_*? If not, it's server-only — client components can't read it.
  2. Is the read site a literal process.env.NEXT_PUBLIC_*? If it's destructured, parameterised, or aliased, the bundler won't inline it. Grep .next/static/chunks/*.js for the value you expected — if it's not there, the read site is wrong.
  3. Was the value present at build time? Check .vercel/.env.{production,preview}.local (CI) or vercel env ls (Vercel dashboard). Remember the priority order: OS env > .*.local files > committed .env.{mode} > committed .env.
  4. GitHub Actions specific: the vercel build line will print "WARNING! Build not running on Vercel." — that's normal in CI. It means system vars like VERCEL_ENV are not auto-injected, but they should still come through vercel pull.

On this page