С любовью ко всем разработчикам на свете ❤️
"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, typedThe 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.
env helper at a glanceOne import, seven 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.vars // the env variables, as a typed recordEvery field but vars and build 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 (see its section below).
env.side — server or clientis 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 narrowenv.side.namefor TypeScript. Branch onenv.side.name === 'server'when you need narrowing.
env.side.define — pick a value per sidedefine 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 serverBecause 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 asT(no| undefined), but at runtime it still returnsundefinedon the wrong side. Theunsafeis a type assertion, not a behavior change — use it only when you've already guaranteed the side some other way.
env.ssr — the SSR stateWhether 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 / testenv.mode.name // => 'production' (whatever NODE_ENV is)
env.mode.is.production // => true
env.mode.is.development // => false
env.mode.is.test // => falsename 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.*fromenv.mode.name === 'development', becausenameis a free string. Use theisbooleans directly.
env.scope — which client/rootIn 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 undefinedGotcha:
env.scope.name(andis/define) throws whenPOINT0_SCOPEisn'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.osBoth 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 OSWhen 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(readingPOINT0_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.waswould stayfalseand the engine would assume an un-built app and serve nothing.
env.vars — reading env variablesenv.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)process.env (every process variable is visible).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.
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/client-shape'
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.vars | env.consts | |
|---|---|---|
| When resolved | per request, injected into the HTML | at build/compile time, inlined as a literal |
| Changes on redeploy | yes — restart and the new value is served, no rebuild | no — baked into the bundle |
| Dead-code elimination | no | yes — 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
},
},
})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'senv.varswon't even accept a bare string or glob, only explicit objects.
Point0 always adds a small POINT0_* set to the client, regardless of your
config: NODE_ENV, POINT0_SCOPE, POINT0_SIDE ('client'), and
POINT0_SSR_ENABLED_DEFAULT. These are what power env.mode, env.scope, and
env.side in the browser. You don't declare them.
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: parse process.env against a schema once, export a typed object, and read
that everywhere instead of process.env.
Split the schema by audience so a secret never leaks into the client shape. The
basic example uses five small files — two *-shape.ts shape files plus the
shared.ts / server.ts / client.ts validation files:
1. The shared shape — keys safe on both sides. Shape only, no top-level
validation, because the engine config imports it and validation that threw at
import time would crash the config before the app starts. (The basic example's
shared-shape.ts does keep one module-scope side effect — rewriting
SERVER_URL to CLIENT_URL on the client to proxy through the client origin in
dev — but nothing that validates or throws at import.)
// lib/env/shared-shape.ts
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),
}2. The server env — 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-shape'
import '@point0/core/server-only' // build fails if this file reaches the client
import { z } from 'zod'
const result = z
.object({ ...sharedEnvShape, DATABASE_URL: z.string().min(1) /* … */ })
.safeParse(process.env)
if (!result.success) {
throw new Error('Invalid server environment variables', {
cause: result.error,
})
}
// Read server config via `serverEnv` — never process.env directly in features.
export const serverEnv = { ...result.data }3. The client shape — shared keys plus client-only ones, and the key list the engine consumes:
// lib/env/client-shape.ts
import { sharedEnvShape } from '@/lib/env/shared-shape'
// 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)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 split shape from validation:
engine.tsimportsclientEnvKeys←client-shape.ts←shared-shape.ts— that whole chain loads while building the engine config. Keep the shape files free of top-level validation (nothing that throws at import); validate in theserver.ts/client.ts/shared.tsfiles that aren't on the config path.
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 guardsThe 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 clientBoth @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.
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, 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 localconst env = …, or re-export it through another module, and the static replacement silently stops — the checks become ordinary runtime reads. Importenvstraight from@point0/core.
| Field | .name | .is.<x> | .define(...) |
|---|---|---|---|
env.mode | NODE_ENV (any string) | production / development / test | — |
env.side | 'server' / 'client' | client / server | per side (+ .unsafe) |
env.ssr | — (.active / .phase / .target) | — | — |
env.scope | active scope (throws if unset) | per scope | per scope (+ .unsafe) |
env.runtime | runtime or undefined | per runtime + unknown | per runtime (+ .unsafe) |
env.os | OS or undefined | per OS + unknown | per OS (+ .unsafe) |
env.build | — (.was: boolean) | — | { before, after } |
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.env.vars / env.constsIn the engine's client config (and server config):
| Key | Accepts | Resolved | Client guard |
|---|---|---|---|
vars | key / glob / object / array of them | per request, injected into HTML | rejects '' and '*' |
consts | key / glob / object / array of them | inlined at build time, dead-strips | rejects '' 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.
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 loadingThe 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.