Configuration — typed environment variables
Every app needs to read its surroundings: a database URL, an API key, the public
site address. The lazy way is process.env.DATABASE_URL scattered wherever it's
needed — a string | undefined that's only ever wrong at 3 a.m. in production, never
at startup. This stack does the opposite: it declares one typed, validated schema
for the whole environment, parses it once at boot, and hands the rest of the app a
plain object whose fields have real types. That package is
fusion-env, a thin wrapper over @t3-oss/env-core.
Coming from React + Django? This is your
settings.pyplusdjango-environ, collapsed into one file.createAppEnvis the moment Django readsos.environand coerces it (env.db(),env.bool(...)) — except the coercion is a Zod schema, the result is fully typed, and a bad value fails the boot the way a missingSECRET_KEYdoes, not aKeyErrordeep in a request. TheVITE_split below is the equivalent of Django's "never leak a secret to the template" rule, enforced by the bundler instead of by discipline.
The whole package is one function
fusion-env exports exactly one thing worth calling, createAppEnv, plus the type it
infers. It is short enough to read in full — this is the package:
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export function createAppEnv(runtimeEnv: Record<string, string | undefined>) {
return createEnv({
server: {
SERVER_URL: z.string().url().optional(),
DATABASE_URL: z.string().url().optional(),
// Optional: required when the in-app Carola chat is exercised. The
// existing demo routes also read process.env.ANTHROPIC_API_KEY
// directly, so leaving this optional lets the rest of the app boot
// without it.
ANTHROPIC_API_KEY: z.string().min(1).optional(),
// Powers the dashboard Carola chat — `/api/ai/chat` uses the
// OpenRouter adapter so the model is swappable via this one key.
OPENROUTER_API_KEY: z.string().min(1).optional(),
// Connects the web app + worker to the self-hosted Hatchet engine
// (background jobs). Optional: when unset, uploads are NOT enqueued and
// the app boots normally — same graceful-degradation contract as the AI
// keys above. The worker process requires it. Generate it from the
// Hatchet dashboard (Settings → API Tokens) or the admin CLI.
HATCHET_CLIENT_TOKEN: z.string().min(1).optional(),
},
clientPrefix: "VITE_",
client: {
VITE_APP_TITLE: z.string().min(1).optional(),
},
runtimeEnv,
emptyStringAsUndefined: true,
});
}
export type AppEnv = ReturnType<typeof createAppEnv>;Three things are happening here, and each one earns its place:
servervsclient. Two separate record of Zod schemas.servervars (DATABASE_URL,ANTHROPIC_API_KEY,HATCHET_CLIENT_TOKEN, …) are secrets — they exist only in the Node/Bun process.clientvars must carry theclientPrefix(VITE_) and are the only ones a browser bundle is ever allowed to inline. The split is not a convention you have to remember; it's enforced (see below).- Every var is a Zod schema.
z.string().url()rejects a malformed URL,z.string().min(1)rejects an empty key.runtimeEnv(the rawprocess.env) is fed in and validated against these. A value that doesn't parse throws — at the call site, at startup. emptyStringAsUndefined: true. A shell that exportsDATABASE_URL=(empty) is treated as unset, not as the string"". This is what makes.optional()mean what you'd expect, and it's why the graceful-degradation gates elsewhere in the stack (isCacheEnabled,isJobsEnabled) can simply check truthiness.
The exported AppEnv type — ReturnType<typeof createAppEnv> — is the typed object the
rest of the codebase imports. Autocomplete on env. lists every declared var; env.SERVER_URL
is string | undefined (because it's .optional()), and a typo like env.SERVER_RUL is a
compile error, not a silent undefined.
Why a wrapper at all?
fusion-envis a leaf package with no internal dependencies — the schema is the only app-specific part, so it lives in one place the whole monorepo and every package can agree on. The package itself is generic; the schema above is what a product app owns and edits.
The app declares its schema once
The example calls createAppEnv exactly once, in src/env.ts, passing the live
process.env:
import { createAppEnv } from "@tikab-interactive/fusion-env";
// Typed, validated runtime env (fusion-env). Server vars (DATABASE_URL, …) come
// from process.env; client vars are VITE_-prefixed. Imported for its validation
// side effect from the server-only auth module.
export const env = createAppEnv(process.env);That's the entire integration. env is now a typed, frozen object. But the more important
detail is the import, not the export — bringing this module in has a validation side
effect. The three server entry points that touch the outside world all import it on their
very first line, before anything reads a variable:
// example/src/lib/db.ts, src/lib/auth.ts, src/worker.ts — all start with:
import "#/env";import "#/env" runs createAppEnv(process.env) at module-evaluation time. If
DATABASE_URL is a malformed URL, the process dies here, while it's booting, with a
Zod error naming the offending variable — not 200ms later inside the first request to hit
the database. The web server, the worker, and the auth layer each pull it in so that
whichever one starts first does the validation. This is the fail-fast contract: a
misconfigured deploy refuses to start instead of limping.
Coming from Django?
import "#/env"isdjango.setup()reading and validating settings before any view runs. A badDATABASE_URLhere is a startup crash, exactly like a malformedDATABASESdict — you find out at deploy time, in the logs, not from a user.
Server vs client — the VITE_ wall
The single most important line in the schema is clientPrefix: "VITE_". It draws a hard
boundary between secrets that stay on the server and config that's safe to ship to the
browser.
The wall is enforced from both sides:
@t3-oss/env-corerejects a misplaced declaration. Put a non-prefixed key in theclientblock (or aVITE_-prefixed one inserver) andcreateEnvthrows — you cannot accidentally classify a secret as client-safe.- Vite only inlines
VITE_-prefixed vars. This is Vite's own rule:import.meta.envexposes nothing else to client code. So even if you tried to readenv.DATABASE_URLfrom a component, the value isn't in the browser bundle to read.
The practical upshot: a server var is undefined if you reach for it in client code, and
a client var is the one thing that's allowed across. VITE_APP_TITLE can render in the
<title>; ANTHROPIC_API_KEY physically cannot leave the server. This is the same
secret-containment instinct as the realtime and
jobs browser stubs, just one layer earlier — at the config boundary rather than
the module boundary.
How packages read config — they don't import env
Here's the subtlety that surprises people coming from a single-app codebase. The typed
env object lives in the consumer (example/src/env.ts). The fusion-* packages
do not import it — they can't, because they're published independently and don't know
the app's schema. So how does fusion-cache know whether Valkey is configured?
It reads process.env directly, but only behind a named predicate:
// fusion-cache/src/index.ts
export function isCacheEnabled(): boolean {
return Boolean(process.env.VALKEY_URL);
}This is the deliberate division of labour, and it's worth stating plainly:
| Layer | Reads process.env? | Why |
|---|---|---|
The app (example) | once, via createAppEnv | owns the schema; gets a typed env and fail-fast validation |
A fusion-* package | yes, but only inside an is…Enabled() gate | can't know the app's schema; exposes a typed boolean instead of a raw string | undefined |
| App feature code | almost never | imports env (typed) or calls a package's is…Enabled() (typed) |
So "no process.env sprinkled everywhere" doesn't mean the string process.env appears
zero times — it means no raw, unchecked process.env.SOMETHING reads leak into feature
code. The app reads it once into a typed object; each package reads its own var once,
behind a boolean gate that callers branch on. A handful of process.env reads remain in
the example for vars that are genuinely package-local (e.g. COLLAB_WS_URL,
FLATBOX_API_URL) — config that belongs to one feature and one call site, where adding a
schema entry would be ceremony for no payoff.
Coming from Django? A reusable Django app reads
settings.SOMETHINGand you wire it in your project'ssettings.py. Here a publishedfusion-*package can't reach into the consumer'senv, so it readsprocess.env.ITS_VARitself and hands you a boolean (isCacheEnabled()) — the moral equivalent of an app exposingapp_settings.IS_ENABLEDwith a sane default rather than making you set it.
Validation is at the call site, not on a timer
One non-obvious property of @t3-oss/env-core: validation happens when createEnv is
called and the values are accessed, not on some background schedule. The package's own
test pins this down — a malformed URL only throws once you read the field inside the
returned object:
import { expect, test } from "bun:test";
import { createAppEnv } from "../src/index";
test("parses a valid runtimeEnv and exposes the values", () => {
const env = createAppEnv({
SERVER_URL: "https://example.com",
VITE_APP_TITLE: "Fusion",
});
expect(env.SERVER_URL).toBe("https://example.com");
expect(env.VITE_APP_TITLE).toBe("Fusion");
});
test("optional vars are undefined when absent", () => {
const env = createAppEnv({});
expect(env.SERVER_URL).toBeUndefined();
expect(env.DATABASE_URL).toBeUndefined();
});
test("empty strings are treated as undefined", () => {
const env = createAppEnv({ SERVER_URL: "" });
expect(env.SERVER_URL).toBeUndefined();
});
test("rejects a malformed URL", () => {
// Validation runs at call time; access inside the closure covers both eager
// and lazy validation in @t3-oss/env-core.
expect(() => createAppEnv({ SERVER_URL: "not-a-url" }).SERVER_URL).toThrow();
});That test file is also the clearest spec of the contract: valid input round-trips
(SERVER_URL comes back as the string you put in), absent optionals are undefined, an
empty string becomes undefined (the emptyStringAsUndefined rule), and a malformed
URL throws. Because the example imports #/env at the top of its server entry points, that
"access" happens at startup — which is exactly why a bad value fails the boot rather than a
request.
Adding a new variable
The flow is mechanical and type-safe end to end:
- Add it to the schema in
fusion-env/src/index.ts— underserverfor a secret, underclient(with theVITE_prefix) for something the browser may see. Pick the Zod type that actually validates it:z.string().url(),z.string().min(1),z.coerce.number(),.optional()if the app must boot without it. - Set it in
.env.local(dev) or your deployment's env (prod). Leaving an optional one unset is fine; leaving a required one unset now fails the boot loudly. - Read it —
env.MY_VARfrom app code (typed, autocompleted), or, if it gates an optional capability, wrap it in anisMyFeatureEnabled()predicate the way the packages do.
Optional by default is a feature. Notice that every var in the example schema is
.optional(). That's the graceful-degradation contract: the AI keys, the Hatchet token, the database URL — the app is designed to boot with any optional backend absent and light that feature up only when its var appears. Make a var required only when the app genuinely cannot run without it.
Where to go next
- Caching — the
isCacheEnabled()gate this page references, in full: how a singleVALKEY_URLturns the cache on and how it degrades when absent. - Conventions — the graceful-degradation contract that makes "every var
optional" the default, shared by every
fusion-*package. - Architecture — where
fusion-envsits as a dependency-free leaf, and why the schema is the one thing the whole monorepo agrees on. - Deploy — supplying the real environment in production (and the air-gapped case, where most optional vars stay unset on purpose).