Point0by 1gr14
  • Menu
    • GitHub
    • Blog
  • Introduction
    • Overview
    • Getting Started
    • Full Overview
    • Benchmarks
    • Points
  • Points
    • Page
    • Layout
    • Component
    • Provider
    • Mountable
    • Query
    • Infinite Query
    • Mutation
    • Action
    • Subscription
    • Root
    • Base
    • Plugin
  • Methods
    • Validation
    • Loader
    • Context
    • Middleware
    • Loading & error
    • .with
    • Mapper
    • Transformer
    • Stage Methods
  • Core
    • Navigation
    • SSR
    • RSC
    • Socket
    • Request
    • Response
    • Error handling
    • Env
    • Head
    • MDX
    • Assets
    • File upload
    • OpenAPI
    • Query client
    • Events
    • Infer
  • Engine
    • Engine Config
    • Engine Runtime
    • CLI
    • Dev
    • Build
    • Compiler
    • Generator
    • Project MCP
    • Docs MCP
    • Importer
    • Public dir
    • Testing
    • Deploy
    • Bun or Vite
  • Extra
    • SsrStore
    • CookieStore
    • Basic Auth
    • CORS
    • Cache-Control
    • Compress
  • Examples
    • Basic
    • Vite
    • Better Auth
    • Socket
    • Capacitor
    • Expo
  • 1gr14
    • Home
    • Start0
    • Support
    • Education
    • Group
    • Blog
    • Author
  • Community
    • Discord
    • Telegram
  • Open Source
    • Point0
    • Route0
    • Error0
    • Flat
    • Agents Party
  • Account
    • Sign In
    • Sign Up
Point0by 1gr14
Building open-source software for the glory of the Lord Jesus Christ ☦️
With love for developers of all backgrounds around the world ❤️
Terms of ServicePrivacy PolicySergei Dmitriev 2026 😎

Env

  • Category: Core

On this page

  1. The env helper at a glance
  2. env.side — server or client
  3. env.side.define — pick a value per side
  4. env.ssr — the SSR state
  5. env.mode — production / development / test
  6. env.scope — which client/root
  7. env.runtime and env.os
  8. env.build — was this bundled?
  9. env.feature — which optional features this build carries
  10. env.vars — reading env variables
  11. What reaches the client: vars and consts
  12. Declaring which keys to expose
  13. Always-injected keys
  14. Validating env variables (the sharedEnv / serverEnv pattern)
  15. server-only and client-only guards
  16. How the compile boundary stays safe
  17. Reference
  18. Field surface
  19. Client config: env.vars / env.consts
  20. Typing env globally
  21. .env file loading

"Env" in Point0 covers two separate things. The first is the env helper from @point0/core — one object that answers where am I running: server or client, which mode, which runtime. The second is env variables: who can read them, and which ones reach the browser.

import { env } from '@point0/core'

env.mode.is.production // => true on a prod build
env.side.is.server // => true on the server, false in the browser
env.vars.NODE_ENV // => 'production' — reads process.env, typed

The env helper is also the safe boundary. Most of its fields are rewritten to literals at compile time — env.side.is.server becomes false in the client bundle, and a server-only branch behind it is deleted as dead code.

The env helper at a glance

One import, eight fields:

import { env } from '@point0/core'

env.mode // production / development / test
env.side // server / client (+ ssr flag)
env.scope // which client/root, in a multi-client app
env.runtime // browser / nodejs / bun / deno / reactNative / worker
env.os // ios / android / linux / mac / windows
env.build // was this code bundled by `point0 build`?
env.feature // which optional features this build carries
env.vars // the env variables, as a typed record

Every field but vars, build, and feature follows the same shape: .name, .is.<value>, and (except mode) a .define(...) that picks a value by the current field. env.build exposes .was (boolean) and .define instead of .name/.is, and env.feature is a plain record of booleans (see their sections below).

env.side — server or client

is is the cheap check; name is the discriminator.

env.side.is.server // => true on the server
env.side.is.client // => true in the browser

if (env.side.name === 'server') {
  // TS narrows here — name is the discriminator
}

Whether an SSR pass is underway is a separate axis — see env.ssr below.

Gotcha: if (env.side.is.client) does not narrow env.side.name for TypeScript. Branch on env.side.name === 'server' when you need narrowing.

env.side.define — pick a value per side

define returns a different value on each side. The missing side is undefined:

// isomorphic helper: real client impl in the browser, server impl on the server
export const trackEvent = env.side.define({
  client: mixpanelClientTrackEvent,
  server: mixpanelServerTrackEvent,
})

env.side.define.server(secret) // => secret on the server, undefined on the client
env.side.define.client(token) // => token on the client, undefined on the server

Because the compiler replaces the whole define(...) call with the live branch, the other side's value (and its imports) is removed from the bundle — each side ships only its own implementation.

Gotcha: env.side.define.unsafe.server(v) types the result as T (no | undefined), but at runtime it still returns undefined on the wrong side. The unsafe is a type assertion, not a behavior change — use it only when you've already guaranteed the side some other way.

env.ssr — the SSR state

Whether an SSR pass is underway right now, as a discriminated union — checking active narrows the other fields:

env.ssr.active // => true while a server render pass is in progress
env.ssr.phase // => 'none' | 'discovery' | 'render'
env.ssr.target // => 'none' | 'html' | 'data'

if (env.ssr.active) {
  env.ssr.phase // TS narrows: 'discovery' | 'render'
  env.ssr.target // TS narrows: 'html' | 'data'
}

On the client (and on the server outside a page render — middleware, plain endpoint calls) everything is inactive: active: false, phase: 'none', target: 'none'. Inside an SSR pass, phase says where the pass currently is — the 'discovery' render-to-discover passes vs the final 'render' that becomes the response — and target says what the pass is for: an 'html' page response, or 'data' (the dehydrated-state endpoint behind client-navigation prefetch, which never runs a final render). See SSR for the render loop these phases belong to.

In the client bundle the compiler folds env.ssr.active / .phase / .target to their constants (false / 'none' / 'none'), so server-only branches behind them are removed at build time — same as env.side.is.*.

env.mode — production / development / test

env.mode.name // => 'production' (whatever NODE_ENV is)
env.mode.is.production // => true
env.mode.is.development // => false
env.mode.is.test // => false

name is NODE_ENV verbatim, so it can be any string — the three booleans cover the normal values (production, development, test). There's no env.mode.define; mode only exposes name and is.

Gotcha: TypeScript can't narrow env.mode.is.* from env.mode.name === 'development', because name is a free string. Use the is booleans directly.

env.scope — which client/root

In a multi-client app (one server, several clients), scope says which one this code belongs to. It mirrors side: name, is, and define.

env.scope.name // => 'web' | 'admin' | … — the active scope
env.scope.is.web // => true when the scope is 'web'

env.scope.define({ web: webConfig, admin: adminConfig }) // value for the active scope
env.scope.define.admin(x) // => x only in the 'admin' scope, else undefined

Gotcha: env.scope.name (and is/define) throws when POINT0_SCOPE isn't set: POINT0_SCOPE is not set in env vars. In a normal Point0 app the engine always sets it; you only hit this reading scope before the engine boots.

To type the scopes, declare them once — see EnvDefinition.

env.runtime and env.os

Both detect the host and follow the same name / is / define shape.

env.runtime.name // 'browser' | 'reactNative' | 'nodejs' | 'bun' | 'deno' | 'worker'
env.runtime.is.bun // => true under Bun
env.runtime.define({ bun: x, nodejs: y }) // value for the active runtime

env.os.name // 'ios' | 'android' | 'linux' | 'mac' | 'windows'
env.os.is.ios // => true on iOS
env.os.define({ ios: a, android: b }) // value for the active OS

When the runtime or OS can't be detected, name is undefined and is.unknown is true. You can only use the 'unknown' key in is / define when the type allows an undetectable value — i.e. when the declared union includes undefined.

Detection is best-effort and reads the host directly: runtime checks POINT0_RUNTIME first, then navigator.product === 'ReactNative' (→ reactNative), window/document (→ browser), then Bun / Deno / process.versions.node globals. OS checks POINT0_OS first, then matches navigator.userAgent / navigator.platform and finally process.platform (iphone/ipad → ios, android → android, win → windows, darwin/mac → mac, linux/x11 → linux). Set POINT0_RUNTIME / POINT0_OS to pin a value when the host can't be sniffed — this is also what the per-side compiler.runtime / compiler.os build options do (see the compile boundary).

env.build — was this bundled?

env.build.was is true only inside a point0 build bundle, false everywhere else (dev, tests, source). Use define to pick a value by build state:

env.build.was // => false in dev, true in a production build

env.build.define({
  before: devOnlyValue, // when NOT built (dev)
  after: prodValue, // when built
})

Like the other fields, the compiler inlines env.build.was to a literal during the build, so the unused branch is eliminated.

Gotcha: the runtime fallback for build.was (reading POINT0_BUILT) only matters when @point0/* is left external in the bundle (bunBuildConfig: { packages: 'external' }). In a normal inlined build the getter is dead code; if it weren't replaced, build.was would stay false and the engine would assume an un-built app and serve nothing.

env.feature — which optional features this build carries

Some of Point0 is optional. env.feature is the full record of those features for the current side — one boolean each, never "unspecified":

env.feature.socket // => true when this build carries the socket runtime

It answers "is this feature's code in this build", not "is it configured". The engine resolves one record per side from the features option (which defaults to server.socket), and the compiler inlines every access in the client build as a literal — so a feature that is off turns its own methods into dead code and its module out of the bundle. The server keeps the runtime read: nothing is ever cut there, and its socket methods answer with a clear throw instead of silently doing nothing.

Reading it outside a Point0 build — a unit test on bare @point0/core, a side running compiler: false — gives true: nothing was cut, so the code is there.

env.vars — reading env variables

env.vars is a typed read of your env variables. It's a convenience, not a mandate — most apps validate their env through their own helper (see the validation pattern below) and read that.

env.vars.NODE_ENV // => 'production' — always present
env.vars.API_URL // => string | undefined (widen the type via EnvDefinition)
  • It's a live getter, not a snapshot — each access re-reads the source.
  • On the server it reads process.env (every process variable is visible).
  • On the client it reads what Point0 injected into the page (window.__POINT0_ENV_VARS__ + consts) — only the variables you whitelisted, never the full process.env. That whitelist is the next section.

By default the value type is Record<string, string | undefined>. Declare your real keys to get exact types — see typing env globally.

What reaches the client: vars and consts

The server sees every process variable. The client must not — its bundle ships to the browser and anyone can read it. (Point0 still server-renders the first load when SSR is on; "client" here means the browser bundle, the same one that drives SPA-style navigation after that first render.) So the client gets only what you list, in the engine's client config under env:

// examples/basic/src/engine.ts
import { clientEnvKeys } from '@/lib/env/shared'

export const engine = Engine.create({
  client: {
    // ...
    env: { vars: clientEnvKeys }, // exactly these keys reach the browser
  },
})

There are two ways to send a variable to the client, and they behave differently:

env.varsenv.consts
When resolvedper request, injected into the HTMLat build/compile time, inlined as a literal
Changes on redeployyes — restart and the new value is served, no rebuildno — baked into the bundle
Dead-code eliminationnoyes — if (process.env.X === '…') collapses to the live branch

Use vars for anything that can change between deploys (an API URL per environment). Use consts for build-time flags you want to inline and dead-strip. Both are declared the same way:

export const engine = Engine.create({
  client: {
    env: {
      vars: ['API_URL', 'PUBLIC_SENTRY_DSN'], // sent at request time
      consts: { FEATURE_X: 'true' }, // inlined at build time
    },
  },
})

Declaring which keys to expose

Each vars / consts entry accepts a few shapes:

export const engine = Engine.create({
  client: {
    env: {
      vars: [
        'API_URL', // a key — read its value from process.env
        'PUBLIC_*', // a glob — every matching process.env key (minimatch)
        { OVERRIDE: 'literal-value' }, // an object — use this value verbatim
      ],
    },
  },
})

Gotcha: for the client, an empty string or a bare '*' is rejected at startup — Environment variable "*" is not allowed for client env vars config. A wildcard like '*' would dump the whole environment into the browser, so you must enumerate keys or use a scoped prefix glob ('PUBLIC_*'). The server config has no such guard — and at the type level the server's env.vars won't even accept a bare string or glob, only explicit objects.

Always-injected keys

Point0 always adds a small POINT0_* set to the client, regardless of your config: NODE_ENV, POINT0_SCOPE, POINT0_SIDE ('client'), POINT0_SSR_ENABLED_DEFAULT, and one POINT0_FEATURE_* per optional feature. These are what power env.mode, env.scope, env.side, and env.feature in the browser. You don't declare them.

Validating env variables (the sharedEnv / serverEnv pattern)

Point0 ships no createEnv / serverEnv API — env validation is app code, and the pattern below is the convention examples/basic and Start0 use. The idea: declare a schema per audience, expose a typed handle, and read that everywhere instead of process.env.

The handles are lazy. Reading serverEnv.DATABASE_URL validates that one variable and caches it; serverEnv.validate() validates the whole shape at once. Declaring a variable therefore costs nothing until something reads it, which is what lets the shape and the handle live in the same file — and the app entries still fail fast, because they call validate() at startup.

Split the schema by audience so a secret never leaks into the client shape. The basic example uses four files in src/lib/env/: the createEnv helper plus one file per audience.

1. utils.ts — the helper. Point0 doesn't ship it, so it's yours: copy this into your app and edit it as you like. The scaffolder writes it for you, and the same file (with its full JSDoc) is examples/basic/src/lib/env/utils.ts.

// lib/env/utils.ts
import { z } from 'zod'

type EnvShape = Record<string, z.ZodType>
type EnvValues<TShape extends EnvShape> = {
  [K in keyof TShape]: z.infer<TShape[K]>
}
type Env<TShape extends EnvShape> = EnvValues<TShape> & {
  validate: () => void
  keys: Array<keyof TShape & string>
  value: EnvValues<TShape>
  shape: TShape
  schema: z.ZodObject<TShape>
}

export const createEnv = <TShape extends EnvShape>(
  name: string,
  shape: TShape,
): Env<TShape> => {
  const schema = z.object(shape)
  const keys = Object.keys(shape) as Array<keyof TShape & string>
  const parsed: Partial<EnvValues<TShape>> = {}
  let validated = false

  const validateOne = (key: keyof TShape & string) => {
    if (key in parsed) return
    const result = shape[key].safeParse(process.env[key])
    if (!result.success) {
      throw new Error(`Invalid "${name}.${key}" environment variable`, {
        cause: result.error,
      })
    }
    parsed[key] = result.data
  }

  const validate = () => {
    if (validated) return
    const result = schema.safeParse(process.env)
    if (!result.success) {
      throw new Error(`Invalid "${name}" environment variables`, {
        cause: result.error,
      })
    }
    Object.assign(parsed, result.data)
    validated = true
  }

  const env = {} as Env<TShape>
  Object.defineProperties(env, {
    validate: { value: validate },
    keys: { value: keys },
    shape: { value: shape },
    schema: { value: schema },
    value: { get: () => (validate(), parsed as EnvValues<TShape>) },
  })
  for (const key of keys) {
    Object.defineProperty(env, key, {
      enumerable: true,
      get: () => (validateOne(key), parsed[key]),
    })
  }
  return env
}

validate, keys, value, shape, and schema are reserved metadata — don't use them as variable names (env names are UPPER_SNAKE_CASE, so it never collides in practice). They're non-enumerable, so Object.keys(env) returns exactly your variable names.

2. shared.ts — the shared shape, its handle, and the browser allowlist. Keys safe on both sides, plus the client shape the engine reads:

// lib/env/shared.ts
import { createEnv } from '@/lib/env/utils'
import { z } from 'zod'

// Never put secrets here — every shared key is exposed to the client.
export const sharedEnvShape = {
  SERVER_URL: z.string().min(1),
  CLIENT_URL: z.string().min(1),
}

export const sharedEnv = createEnv('shared', sharedEnvShape)

// Never add secrets — every key here reaches the browser (a `vars` key is
// injected into the page HTML per request; a `consts` key is inlined into the JS).
export const clientEnvShape = {
  ...sharedEnvShape,
  // SOMETHING_PUBLIC: z.string().min(1),
}

// Consumed by engine.ts → client.env.vars, so the framework knows what to send.
export const clientEnvKeys = Object.keys(clientEnvShape)

3. server.ts — shared keys plus secrets, guarded so it can never reach the client (see import guards):

// lib/env/server.ts
import { sharedEnvShape } from '@/lib/env/shared'
import { createEnv } from '@/lib/env/utils'
import '@point0/core/server-only' // build fails if this file reaches the client
import { z } from 'zod'

// Read server config via `serverEnv` — never process.env directly in features.
export const serverEnv = createEnv('server', {
  ...sharedEnvShape,
  DATABASE_URL: z.string().min(1),
  // …
})

app.server.ts then validates the whole set before serving:

// src/app.server.ts
import { serverEnv } from '@/lib/env/server'
import { engine } from '@/engine.js'

serverEnv.validate() // throws on the first invalid variable
await engine.serve()

4. client.ts — the browser handle. Its shape lives in shared.ts; this file is the handle plus whatever browser-only setup the app needs:

// lib/env/client.ts
import { clientEnvShape } from '@/lib/env/shared'
import { createEnv } from '@/lib/env/utils'
import '@point0/core/client-only' // build fails if this file reaches the server

// Dev only: route client→server requests through the client origin so SSR
// fetches skip CORS. In prod the URLs already match, and `NODE_ENV` is an
// always-injected const, so the whole block is dead-stripped from the bundle.
if (process.env.NODE_ENV !== 'production') {
  process.env.SERVER_URL = process.env.CLIENT_URL
}

export const clientEnv = createEnv('client', clientEnvShape)

index.client.tsx imports it first, before every other import, and validates before mounting:

// src/index.client.tsx
import { clientEnv } from '@/lib/env/client'
import App from '@/app.client'
// …

clientEnv.validate()
mount(<App />, points)

The order matters because the getters cache: the rewrite above only reaches sharedEnv.SERVER_URL if it runs before src/lib/root.tsx — which reads it at module scope — is loaded.

clientEnvKeys is the bridge: it feeds client.env.vars (above), so the schema is the single source of truth for what's whitelisted — one list in one place, no scattered PUBLIC_ prefix convention.

Why the shape and the handle share a file: engine.ts imports clientEnvKeys ← shared.ts, and that chain loads while building the engine config, in a process where only the CLI has fixed the env. Eager validation there would throw before the app starts. Lazy getters have no such problem — createEnv only defines properties, reads nothing — so shared.ts stays safe on the config path and there is no separate shape file to import when you want the schema without the values (sharedEnv.shape is right there). client.ts is the exception that proves it: it carries a browser-only side effect, so the shape it validates has to live in shared.ts where engine.ts can reach it.

Use Zod, Valibot, hand-written checks, or nothing — the only contract is that client.env.vars gets the list of keys to expose.

server-only and client-only guards

The compiler strips server code from the client bundle, but you can make the boundary explicit and fail the build if a server file is ever reached from the client. Import the marker at the top of the file:

// lib/prisma.ts
import '@point0/core/server-only' // build/dev error if this reaches the client

Both @point0/core/server-only and @point0/core/client-only are empty modules — the work is the compiler's. If a server-only file ends up in the client graph (or a client-only file on the server), the import is replaced with a module that throws, and on a point0 build (which forces onDeny: 'throw') the build stops. The config default of compiler.importer.onDeny is 'log', so in dev the violation is logged rather than fatal — the replaced module still throws at runtime. This is what lets you put DATABASE_URL and Prisma calls in plain imported files. More in Importer.

How the compile boundary stays safe

The compiler statically rewrites every env.* check into a literal, then runs dead-code elimination. A server-only branch in a client build isn't conditionally skipped — it's gone.

// you write:
if (env.side.is.server) {
  await prisma.idea.findMany() // server-only
}

// in the CLIENT bundle the compiler produces:
if (false) {
  // ...  → eliminated as dead code, prisma import dropped
}

By default this rewrite covers env.side.is.*, env.scope.is.*, env.mode.is.*, env.build.was, env.feature.* (client build only — the server reads features at runtime), and their define(...) calls — and env variables declared as consts: process.env.X / env.vars.X / import.meta.env.X become literals when X is a const, which is what enables dead-stripping a feature flag. The pass runs several times so nested branches collapse.

env.runtime.is.* and env.os.is.* are the exception: their rewrites are opt-in and OFF by default. To enable them you commit to a concrete value at compile time — set compiler.runtime (e.g. 'bun') and/or compiler.os in the engine config, per side:

export const engine = Engine.create({
  server: {
    compiler: { runtime: 'bun' }, // server is built for Bun
  },
  client: {
    compiler: { runtime: 'browser' }, // client is built for the browser
  },
})

Setting these bakes a POINT0_RUNTIME / POINT0_OS const into that side's build, so the compiler can inline env.runtime.is.* / env.os.is.* to literals and dead-strip the losing branch. Until you set them, a branch behind env.runtime.is.nodejs or env.os.is.ios stays a runtime read — it evaluates false on the client, but the branch and its imports remain in the bundle.

Gotcha: this rewrite only fires for env (or its alias _point0_env) when it's imported directly from @point0/core. Shadow the name with a local const env = …, or re-export it through another module, and the static replacement silently stops — the checks become ordinary runtime reads. Import env straight from @point0/core.

Reference

Field surface

Field.name.is.<x>.define(...)
env.modeNODE_ENV (any string)production / development / test—
env.side'server' / 'client'client / serverper side (+ .unsafe)
env.ssr— (.active / .phase / .target)——
env.scopeactive scope (throws if unset)per scopeper scope (+ .unsafe)
env.runtimeruntime or undefinedper runtime + unknownper runtime (+ .unsafe)
env.osOS or undefinedper OS + unknownper OS (+ .unsafe)
env.build— (.was: boolean)—{ before, after }
env.feature— (.socket: boolean)——
env.vars——— (typed record getter)
  • EnvRuntimeName = 'browser' | 'reactNative' | 'nodejs' | 'bun' | 'deno' | 'worker'.
  • EnvOsName = 'ios' | 'android' | 'linux' | 'mac' | 'windows'.
  • .define.<x>(v) returns v only when the field equals x, else undefined.
  • .define.unsafe.<x>(v) types the result as v but still returns undefined on the wrong field at runtime — a type assertion only.

Client config: env.vars / env.consts

In the engine's client config (and server config):

KeyAcceptsResolvedClient guard
varskey / glob / object / array of themper request, injected into HTMLrejects '' and '*'
constskey / glob / object / array of theminlined at build time, dead-stripsrejects '' and '*'

Server env.vars accepts objects only (no bare string / glob at the type level); server env.consts and both client entries accept the wide forms.

Typing env globally

Point0 reads four optional keys off a global EnvDefinition interface — vars, scope, runtime, and os — to type the matching env.* fields. The interface ships empty; augment it once to declare your types:

declare module '@point0/core' {
  interface EnvDefinition {
    vars: { API_URL: string }
    scope: 'web' | 'admin'
    runtime: 'browser' | 'ios' | 'android'
    os: 'mac' | 'windows' | 'linux'
  }
}

With this, env.vars.API_URL is typed string, env.scope.name narrows to the union, and the wrong scope key in .define is a type error. Each key is independent — declare only the ones you need; an omitted key keeps its wide default (Record<string, string | undefined> for vars, string for scope, the full runtime/OS union for the others).

.env file loading

The point0 CLI loads .env files through Bun's own loader, not a hand-rolled parser. The cascade for a mode is .env, .env.<mode>, .env.local, .env.<mode>.local (Bun skips .env.local in test mode). Mode is resolved by precedence: an explicit flag (--mode / -p / -d / -t) > --env NODE_ENV=… > a shell-exported NODE_ENV > the default (production for build, development otherwise). The shell always wins over files. Full detail: CLI and Engine config.

PreviousError handlingNextHead

Enjoying Point0?

Give it a star on GitHub, ask questions, and follow what's new

Star Point0

A star on GitHub helps more developers find Point0
Star on GitHub

Start0

Point0 integrated into the Start0 SaaS boilerplate — the best way to use it
Explore Start0

YouTube

Tutorials, walkthroughs, and updates
YouTube

Discord

Questions and chat in English
Join Discord

Telegram

News channel and chat in Russian
ChannelChat

Twitter

Posts and updates
Twitter