Filesystem (loadConfig)
Wire @vlandoss/env in a long-running server. Schema, per-environment config files on disk, and a typed env loaded at boot via loadConfig.
When to use this
- You're running a long-lived process with filesystem access (Node, Bun, Deno — HTTP server, worker, CLI).
- Per-environment config lives as
.ts/.jsonfiles in the repo. - Secrets come from real
process.envat boot.
Reference example
examples/backend-node/ — Node HTTP server (Hono via @hono/node-server) using Zod, the @vlandoss/env/zod primitives, and the short form of loadConfig.
Wiring
import { type Config, schema } from "@vlandoss/env";
import * as e from "@vlandoss/env/zod";
import * as z from "zod";
export const Env = schema({
log: { LEVEL: e.logLevel },
server: { PORT: e.port, HOST: e.host },
db: {
URL: z.url(),
LOGGING: e.bool.default(false),
},
});
export type EnvConfig = Config<typeof Env>;import type { EnvConfig } from "../env/schema.ts";
export default {
log: { LEVEL: "debug" },
server: { PORT: 3001, HOST: "127.0.0.1" },
db: { URL: "postgres://localhost/dev", LOGGING: true },
} satisfies EnvConfig;import { defineEnv } from "@vlandoss/env";
import { loadConfig } from "@vlandoss/env/fs";
import { Env } from "./schema.ts";
const config = loadConfig(Env);
export const env = defineEnv({
schema: Env,
config,
vars: {
db: { URL: "DATABASE_URL" },
},
});import { env } from "./env/index.ts";
server.listen(env.server.PORT, env.server.HOST);What each piece does
schema()is the contract. Leaves use Standard Schema validators (Zod here, but any Standard Schema lib works). The@vlandoss/env/zodprimitives (e.port,e.host,e.logLevel,e.bool) are opinionated single-purpose schemas — see the Zod primitives reference.config/<envName>.tsis the typed, versioned default per environment.satisfies EnvConfigis what gives you compile-time errors on typos. Secrets do not go here — leave them out ofconfig/*and put them inprocess.env.loadConfig(Env)is the short form: it synchronously auto-discovers[src/]config/<envName>.{ts,mts,cts,js,mjs,cjs,json}underprocess.cwd()and returns the first match (or{}if none) — noawait.defineEnvmergesdefaults(none here) →config→process.envand validates the result. See Resolution order.vars: { db: { URL: "DATABASE_URL" } }overrides the convention for one leaf.db.URLwould default toDB_URL; here we map it to the conventionalDATABASE_URL. See Env-var naming.
Choosing the explicit pattern
If your config directory doesn't follow the convention, use the long form:
const config = loadConfig({ schema: Env, pattern: "config/{env}.ts" });The pattern must contain {env}, and it throws if the resolved file doesn't exist (the short form silently falls back to {}).
Loading a different env
loadConfig always reads envName(). To load a non-current env, set ENV=… in the process env before calling — there's no env override on the function itself.
Resolving from a custom cwd
Both auto-discovery and the {env} template resolve paths against process.cwd() by default. If the process working directory isn't the project root (monorepo task runners, orchestrators, SSR workers launched from elsewhere), pass cwd explicitly:
const config = loadConfig({ schema: Env, cwd: appRoot }); // auto-discovery
const config = loadConfig({ schema: Env, pattern: "config/{env}.ts", cwd: appRoot }); // templateConfig files loaded synchronously (require() / CJS)
Because loadConfig is synchronous, it also works in config files that tooling loads synchronously — files pulled in via require() or bundled to CJS, where a top-level await would be rejected (ERR_REQUIRE_ASYNC_MODULE, or a build-time "top-level await is not supported with the cjs output format"). The wiring is exactly the same as above; there's no await to trip over.
See the runnable backend-node-cjs, backend-bun-cjs, and backend-deno-cjs examples — each boots its server from a CommonJS require() entry.
Tradeoffs
- Requires a runtime with a filesystem — works on Node, Bun, and Deno; not on Workers/Edge. Use the Vite plugin for those instead.
loadConfigloads files withrequire(). Runtime requirements by extension:.ts/.mts/.cts— native TypeScript stripping (native in Bun/Deno, Node ≥22.18)..mjs/.js/.cjs—require(esm)(native in Bun/Deno, Node ≥22.12)..json— works on any supported Node.
- Module loads are cached by Node/Bun/Deno's module system. Editing a
.ts/.mjs/.cjsconfig in a long-running process isn't picked up until the process restarts;.jsonfiles are re-read on every call.
Overview
Recipes for wiring @vlandoss/env in each runtime — Node, SPA, SSR — and for the patterns that come up once you compose schemas across files.
SPA — dynamic import
Recommended SPA wiring. Vite splits each per-environment config into its own chunk; the browser downloads only the one matching the current mode.