With love for developers of all backgrounds around the world ❤️
"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, 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 recordEvery 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 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.feature — which optional features this build carriesSome 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 runtimeIt 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 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/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.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'),
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.
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.tsimportsclientEnvKeys←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 —createEnvonly defines properties, reads nothing — soshared.tsstays safe on the config path and there is no separate shape file to import when you want the schema without the values (sharedEnv.shapeis right there).client.tsis the exception that proves it: it carries a browser-only side effect, so the shape it validates has to live inshared.tswhereengine.tscan 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 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, 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 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.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.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.