Environment detection
getEnvironment() returns development / preview / production — a stable literal across SSR and hydration. Used to gate OnboardingRedirectGuard and debug tooling.
apps/web/src/lib/env.ts exports the runtime environment detector used by
the web app:
export type AppEnvironment = 'development' | 'preview' | 'production'
export function getEnvironment(source?: EnvSource): AppEnvironment {
// ...returns one of the three values
}
export function isDebugCapableEnvironment(source?: EnvSource): boolean {
return getEnvironment() !== 'production'
}The value is resolved at module load and stable across SSR / hydration — safe to use in module scope, not just inside React components.
Decision tree
if (NODE_ENV === 'development' || NODE_ENV === 'test') return 'development'
if (NEXT_PUBLIC_VERCEL_ENV === 'development') return 'development'
if (NEXT_PUBLIC_VERCEL_ENV === 'preview') return 'preview'
if (NEXT_PUBLIC_VERCEL_ENV === 'production') return 'production'
return 'production' // fallback — safest for unknown environmentsnext devandvitestboth setNODE_ENVtodevelopmentortest→'development'- Vercel preview deploys set
VERCEL_ENV=preview→'preview' - Vercel production deploys set
VERCEL_ENV=production→'production' - Anything unknown defaults to
'production'so debug tools never accidentally show on a misconfigured environment
Why NEXT_PUBLIC_VERCEL_ENV not VERCEL_ENV
Plain VERCEL_ENV is set by Vercel only on the server during build. To
get the value in client bundles, it has to be re-exposed under the
NEXT_PUBLIC_* prefix.
apps/web/next.config.ts's env: block does that bridging:
const config: NextConfig = {
env: {
NEXT_PUBLIC_VERCEL_ENV: process.env.VERCEL_ENV,
},
}This way getEnvironment() returns the same value on the server-rendered
HTML and on the hydrated client — no SSR mismatch.
Why direct process.env.NEXT_PUBLIC_* access
The detector reads through process.env.NEXT_PUBLIC_VERCEL_ENV directly,
not through an arbitrary source object:
const vercelEnv = source?.NEXT_PUBLIC_VERCEL_ENV ?? process.env.NEXT_PUBLIC_VERCEL_ENVThis is for the same inlining reason described in
Environment variables: Next.js only inlines
NEXT_PUBLIC_* literals that appear as direct process.env.X expressions.
Reading through source = process.env leaves the client bundle with an
empty object and makes local dev look like production.
The source? parameter is for unit tests — pass an object to stub the env,
and the fallback process.env path is the production execution path.
OnboardingRedirectGuard — gated on !== 'production'
OnboardingRedirectGuard force-redirects users to /onboarding if they
haven't completed it. It's an aggressive UX move, and we don't want it on
in prod yet until the funnel ships.
The guard is gated on two conditions in series:
if (getEnvironment() === 'production') return <>{children}</>
if (!isFlagOn('onboarding-guard')) return <>{children}</>
return <RedirectToOnboarding ... />So:
- Prod users never get redirected, even if the flag is on
- Dev/preview users get redirected only when the flag is on
This pattern (env-level kill switch + feature-flag fine grain) is the template for any feature that wants to test-fly in non-prod before going live.
Settings → About shows the environment label
Internal users and QA need to confirm "am I on dev / preview / prod" without
checking URLs. The Settings → About surface in apps/web reads
getEnvironment() and displays the label:
Today AI
Version 2.0.0 — previewSo a screenshot from QA always identifies the environment.
Tree-shaking caveats
Reading the env.ts comment header:
Only
process.env.NODE_ENV === 'production'literal comparisons are reliably eliminated by the bundler. Anything gated solely on the return value of this function is included in the bundle (it just never executes in production at runtime).
So if (getEnvironment() !== 'production') { import('./dev-tool') } will
still include dev-tool in the production bundle — it just won't run
the side-effecting await import(). For chunks that must be entirely
absent from prod, additionally guard with a literal:
if (process.env.NODE_ENV !== 'production' && !isProduction()) {
await import('./heavy-dev-only-thing')
}The process.env.NODE_ENV literal is bundler-friendly; the function call
isn't.
Related
- Three-tier domains — how the environment maps to
URLs via
packages/auth-client - Environment variables — why the
NEXT_PUBLIC_*prefix matters for inlining - Workflow → Debugging —
isDebugCapableEnvironment()controls who sees Cmd+. and the Settings → Debug surface
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.
Vercel deploy model
How Vercel turns code into a running URL — build time vs deploy time vs runtime, what `vercel.json` controls, and the four archetypes of "Vercel project linked to a Git repo".