Reference: Environment modules

Reference

Environment modules

Environment variables come in through four virtual modules. They are not real files — the compiler resolves them — but they import like modules.

ModuleReadsIn the browser
$env/static/publicPUBLIC_ at build timeInlined
$env/static/privateEverything at build timeBuild error
$env/dynamic/publicPUBLIC_ liveValues the server rendered
$env/dynamic/privateEverything liveBuild 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:

src/lib/server/env.ts
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

Edit this page