Today Platform Web — Dev Docs
Architecture

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 environments
  • next dev and vitest both set NODE_ENV to development or test'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_ENV

This 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 — preview

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

On this page