Environment modules
Environment variables come in through four virtual modules. They are not real files — the compiler resolves them — but they import like modules.
| Module | Reads | In the browser |
|---|---|---|
$env/static/public | PUBLIC_ at build time | Inlined |
$env/static/private | Everything at build time | Build error |
$env/dynamic/public | PUBLIC_ live | Values the server rendered |
$env/dynamic/private | Everything live | Build error |
A missing variable reads as "", not undefined.
PUBLIC_ is the boundary
Only variables prefixed PUBLIC_ are ever visible to the browser. Everything else is server-only, and importing a private module into client code fails the build rather than quietly shipping your secrets:
// in a .tsx that ships to the browser
import { env } from "$env/dynamic/private";
// Error: $env/dynamic/private holds server secrets and can't be
// imported into browser code.That error is the whole point of the design. A leaked DATABASE_URL is a production incident; a failed build is a Tuesday.
Static
Read once, when the bundle is built:
import { env } from "$env/static/private";
import { PUBLIC_APP_NAME } from "$env/static/public";
const db = connect(env.DATABASE_URL);Each variable is also a named export, so PUBLIC_APP_NAME is available directly. Two consequences worth knowing:
- Changing a static variable requires a rebuild. It is baked into the bundle.
- It is the right choice for anything that cannot change without a deploy anyway — a build id, a feature flag baked in at release, a public API host.
Dynamic
Read live on every access, so a restart or a changed environment is picked up without a rebuild:
import { env } from "$env/dynamic/private";
const token = await refresh(env.OAUTH_TOKEN);In the browser, $env/dynamic/public reads the values the server rendered into the page rather than a live source, so the client never talks to the environment directly. Treat it as a snapshot: it changes when the server sends new HTML, not on a timer.
Which to use
Reach for dynamic private in server code — it behaves like process.env and is the least surprising option. Use static public when you want the value inlined and tree-shakeable, and dynamic public when the value can change between requests.
In practice, the pairing that avoids surprises is: private dynamic on the server, public static in the browser.
In practice
Put the interface in one place so a typo is a type error:
import { env } from "$env/dynamic/private";
export const dbUrl = env.DATABASE_URL;
export const sessionSecret = env.SESSION_SECRET;Validate at startup rather than discovering a missing variable on the first request. init in hooks is the place for it.
Because a missing variable is "" rather than undefined, an unset secret fails as a confusing auth error instead of a clear crash. Checking in init converts that into a startup failure with a name in the message.
Next
- sigil.config.ts — build and server options
- Plugins — injecting configuration