SvelteKit cannot destructure $env/dynamic/private. Here is the fix.
There is a line of code that a lot of SvelteKit developers write once, get an error for, and then never think about again:
import { DATABASE_URL } from '$env/dynamic/private';
That is a build error. $env/dynamic/* does not have named exports, because SvelteKit cannot know at compile time what variables will exist in your runtime environment. The correct form is:
import { env } from '$env/dynamic/private';
const db = new Database(env.DATABASE_URL);
Getting that error is the easy part — it stops your build. The dangerous part is the mistake that does not stop your build, and I want to spend most of this post on it.
SvelteKit gives you four modules, on two axes
The names are more informative than they look. There are two independent questions:
| Question | Values |
|---|---|
| Who can read it? |
private (server only) · public (server and client) |
| When is it resolved? |
static (at build) · dynamic (at request time) |
That cross product is your four modules:
$env/static/private$env/dynamic/private$env/static/public$env/dynamic/public
public is not a suggestion — it is a prefix rule. Only variables named PUBLIC_* are exposed to the client at all, and anything you put there is shipped to the browser in the JS bundle. If your starter is genuinely server-side, the right answer may be that you have zero PUBLIC_ variables, and that is a fine design.
static and dynamic differ in more than timing
Most posts stop at "static is faster". The two differences that actually bite:
Failure mode. With static, a missing variable fails your build. With dynamic, a missing variable fails at runtime — the deploy succeeds, the health check passes, and the first request that touches that code path 500s. Dynamic moves your failure from CI into production.
Inlining. static is resolved at build time: Vite replaces the import with a literal string, which is what makes dead-code elimination possible. dynamic is a runtime lookup against the environment.
The mistake that does not fail: rotating a secret that is baked in
Here is the scenario, and it is common on platforms that promote one-click deploys.
You import your signing secret from $env/static/private because the docs said static is faster, and it is:
import { SIGNING_SECRET } from '$env/static/private';
You deploy. Later you rotate that secret in your platform's dashboard. The dashboard confirms the change. You watch for a deploy.
Nothing happens, because nothing needs to happen. The value was inlined into the JavaScript during the build that already ran. The new value sits in your platform's environment, unread. Your running artifact still contains the old secret, and it will keep containing it until you rebuild and redeploy.
The uncomfortable part is that this looks like success. No error, no failed request, no alert. The dashboard says rotated. The artifact says otherwise.
If a value is a secret that rotates, it must be dynamic. static is for things that are constant for the lifetime of a given build — an internal service URL, a feature flag baked at build time, a non-sensitive tuning constant. A rotating secret is not one of those.
This is the same class of bug as hardcoding a credential into a committed config file, just spread across a deploy pipeline where nobody reads it.
process.env on Cloudflare: most posts are out of date
If you have read that process.env does not exist on Cloudflare Workers and stopped there, that guidance has aged. The current picture:
- Workers run in a V8 isolate, not Node, so
process.envdoes not exist natively. - You can have it by enabling the
nodejs_compatcompatibility flag. - For projects with a compatibility date on or after 2025-04-01, Cloudflare automatically populates
process.envwith your environment variables and secrets when that flag is on.
So "process.env does not work on Cloudflare" was true, then became conditional, and is now mostly false for new projects. What you should actually do is not reason about process.env at all: adapter-cloudflare maps the environment for you through the $env modules, so going through $env is portable across Node, Cloudflare, and anything else, and reading process.env directly is the thing that will surprise you later when you move hosts.
Prerendering reads the build environment, not yours
If you access env.SOMETHING at the top level of a module, it gets evaluated during prerendering too — when the runtime environment may not have your production secrets yet, because at that moment the "environment" is the build machine.
Guard it:
import { env } from '$env/dynamic/private';
import { building } from '$app/environment';
const apiUrl = building ? 'http://localhost:3000' : env.API_URL;
The general rule: if a value must exist at request time, do not read it at module top level.
The decision table I actually follow
| If the value is… | Use |
|---|---|
| A secret that rotates or can be revoked | dynamic/private |
| Needed to survive a platform change without a rebuild | dynamic/private |
| Missing and you want CI to catch it | static/private |
| Constant for the lifetime of the build | static/private |
| Needed in browser code |
static/public, PUBLIC_ prefix, and accept it ships |
The default for anything secret-shaped is dynamic/private. The cost is a runtime lookup; the cost of getting it wrong is a secret you believe you rotated and did not.
Make it testable by making it injectable
The practical benefit of routing every value through env is that dependency injection becomes free. Your modules take what they need as an argument, the test passes a literal object, and no test ever depends on a real environment:
export function createRateLimiter(env: { RATE_LIMIT_MAX: string }) {
return new SlidingWindowLimiter(Number(env.RATE_LIMIT_MAX));
}
This is the same discipline we apply across our 874-test suite over three SvelteKit starters: the value is a parameter, not an ambient global. Ambient globals are the reason a test suite needs a fake environment, and a fake environment is how configuration bugs hide until production.
Where to read more
The other posts in this series cover the parts that break in production rather than the parts that work in a tutorial:
- Boot-time migrations in SvelteKit: safe at one instance, a race at two — the replica race, and why two instances change the answer
- Deploying SvelteKit to Cloudflare Pages with a real database — what actually works on the platform above
- Drizzle ORM migrations: schema-first vs code-first — the migration strategy, before the boot-time question comes up
Top comments (0)