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 artifactTwo subtleties to remember:
next buildismode=productionfor both preview and prod. The values differ becausevercel pullpulls a different.env.{environment}.local— but the build code path is identical.VERCEL_ENVis not aNEXT_PUBLIC_*variable, so it does not inline automatically into client bundles. This is whynext.config.tsbridges it via theenvfield (see Bridging non-NEXT_PUBLIC_*vars).
Backend Domain Strategy
URL values are resolved at build time per environment:
| Environment | Domain | Auth | API | Detection |
|---|---|---|---|---|
| development | todayai.dev | auth.todayai.dev | api.todayai.dev | 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 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.
| Variable | Purpose | Used in |
|---|---|---|
NEXT_PUBLIC_OIDC_AUTHORITY | Auth server base URL | auth-client, web |
NEXT_PUBLIC_OIDC_CLIENT_ID | OAuth client identifier | web |
NEXT_PUBLIC_API_URL | API server base URL | auth-client, web |
NEXT_PUBLIC_APP_URL | Application origin (for OAuth redirects) | auth-client, web |
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 |
NEXT_PUBLIC_TRAFFIC_LANE | Request routing header for canary deploys | web |
NEXT_PUBLIC_BUILD_ID | Build identifier for diagnostics | web |
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.
| Variable | Purpose |
|---|---|
OIDC_CLIENT_SECRET | OAuth client secret for confidential token exchange |
OIDC_INTERNAL_AUTHORITY | Auth server URL for server-to-server calls |
LOCALHOST_BFF | Strips Domain from session cookies so localhost dev works (server-only) |
VERCEL_ENV | Vercel 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.
| 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 |
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
- Is it
NEXT_PUBLIC_*? If not, it's server-only — client components can't read it. - 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/*.jsfor the value you expected — if it's not there, the read site is wrong. - Was the value present at build time? Check
.vercel/.env.{production,preview}.local(CI) orvercel env ls(Vercel dashboard). Remember the priority order: OS env >.*.localfiles > committed.env.{mode}> committed.env. - GitHub Actions specific: the
vercel buildline will print "WARNING! Build not running on Vercel." — that's normal in CI. It means system vars likeVERCEL_ENVare not auto-injected, but they should still come throughvercel pull.