Environment variables in SvelteKit in Svelte & SvelteKit
Getting environment variables wrong in SvelteKit is one of the quickest ways to leak secrets or break your deployment pipeline. We've all shipped a database password in client-side code or struggled when a config value works locally but fails in production. The framework gives us clear guardrails through $env/static/private and $env/static/public, but understanding when and how to use each one—and their dynamic counterparts—is what separates a secure application from a vulnerable one.
$env/static/private for secrets
The $env/static/private module is your vault for sensitive configuration. Database connection strings, API keys, encryption tokens, and third-party service credentials belong here. When you import from this module, SvelteKit strips those values entirely from your client bundle. They're only available on the server.
Let's say we're building a checkout flow that needs to hit a Stripe API server-side. Our .env file looks like this:
STRIPE_SECRET_KEY=sk_live_abc123def456
DATABASE_URL=postgresql://user:pass@host/db
INTERNAL_API_TOKEN=secret_token_xyzIn a server-side context—a route handler, server load function, or a server action—we import these safely:
// src/routes/checkout/+page.server.ts
import { STRIPE_SECRET_KEY, DATABASE_URL } from '$env/static/private';
import Stripe from 'stripe';
const stripe = new Stripe(STRIPE_SECRET_KEY);
export const actions = {
default: async ({ request, locals }) => {
const formData = await request.formData();
const amount = parseInt(formData.get('amount') as string);
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: 'usd',
metadata: { userId: locals.user.id }
});
return { clientSecret: paymentIntent.client_secret };
}
};Notice: we never expose STRIPE_SECRET_KEY in the response. We only return the client secret, which is safe for the browser to handle. The static import means SvelteKit validates this variable exists at build time, throwing an error if it's missing. That's valuable—you catch configuration problems before deployment.
The key constraint is location. You can import $env/static/private only from:
- Server route handlers (
+server.ts) - Server load functions (
+page.server.ts,+layout.server.ts) - Server actions (
actionsexport in+page.server.ts) - Hooks (
src/hooks.server.ts) - Any other
.server.tsfile
If you try to import it from a client component, the build will fail. This is by design—it's the framework preventing the mistake before it happens.
$env/static/public for client values
Public config is for values that are safe to expose in the browser. API endpoints you want the client to call directly, feature flags, analytics IDs, or environment names belong here. These variables must be prefixed with PUBLIC_ in your .env file.
PUBLIC_API_URL=https://api.example.com
PUBLIC_ANALYTICS_KEY=analytics_123
PUBLIC_ENVIRONMENT=production
PUBLIC_FEATURE_FLAG_DARK_MODE=trueImport them anywhere in your app—client components, pages, layouts, wherever makes sense:
// src/lib/api.ts
import { PUBLIC_API_URL } from '$env/static/public';
export async function fetchUser(id: string) {
const response = await fetch(`${PUBLIC_API_URL}/users/${id}`);
return response.json();
}And in a Svelte component:
// src/routes/dashboard/+page.svelte
<script>
import { PUBLIC_ENVIRONMENT } from '$env/static/public';
$: isDark = PUBLIC_ENVIRONMENT === 'production';
</script>
<div class:dark={isDark}>
Environment: {PUBLIC_ENVIRONMENT}
</div>The static prefix means the values are baked into your build. When you run npm run build, SvelteKit reads your .env.production (or environment variables from your hosting platform) and inlines them. This is fast and straightforward, but it means your client bundle is tied to a specific environment. You build once per deployment target.
Dynamic variants for runtime config
Sometimes you need flexibility. Maybe you're running the same container image in staging and production, and you want to swap configuration without rebuilding. Or you have a browser-based tool that needs to connect to different API endpoints depending on user preference. That's where the dynamic variants come in.
$env/dynamic/private is read from environment variables at runtime on the server. It's not baked into the build:
// src/hooks.server.ts
import { DATABASE_URL } from '$env/dynamic/private';
export const handle = async ({ event, resolve }) => {
// DATABASE_URL is read fresh on each request
// If you change the env var and restart the server, it's picked up immediately
console.log('Connecting to:', DATABASE_URL);
return resolve(event);
};The trade-off: dynamic private env vars are not validated at build time. If DATABASE_URL is missing when your server starts, you'll get a runtime error. You need to handle that gracefully.
$env/dynamic/public is similar but accessible from the browser. It's still prefixed with PUBLIC_ in your env file. The value is sent to the client on each page load or request, so it can differ per user session or based on server state:
// src/routes/+layout.server.ts
import { PUBLIC_FEATURE_FLAG_DARK_MODE } from '$env/dynamic/public';
export async function load({ url }) {
// You could conditionally set this based on user preference, AB testing, etc.
return {
darkModeEnabled: PUBLIC_FEATURE_FLAG_DARK_MODE === 'true'
};
}Then in your layout or page:
// src/routes/+layout.svelte
<script>
import type { LayoutData } from './$types';
export let data: LayoutData;
$: if (data.darkModeEnabled) {
document.documentElement.classList.add('dark');
}
</script>Use dynamic variants sparingly. They're slower than static variants and harder to reason about—values can change between requests. For most applications, the static versions are the right tool.
Never import private env in client code
This is the core rule, and it's worth drilling in with a real example. Imagine a junior developer on the team writes:
// src/routes/profile/+page.svelte
<script>
import { DATABASE_URL } from '$env/static/private';
// WRONG! This will fail to build.
</script>Good news: the build fails immediately. Svelte Kit sees the import and throws an error. The framework catches the mistake before you ship.
The harder case is when you accidentally expose secrets through a data return:
// src/routes/api/config/+server.ts
import { STRIPE_SECRET_KEY } from '$env/static/private';
export async function GET() {
return new Response(JSON.stringify({
stripeKey: STRIPE_SECRET_KEY // WRONG!
}));
}This compiles fine, but now your secret is available to anyone who fetches /api/config. The static import itself is safe, but how you use it matters. If you're returning data from a server endpoint, think carefully about what's exposed. Only return what the client genuinely needs.
A safer pattern:
// src/routes/checkout/+page.server.ts
import { STRIPE_SECRET_KEY } from '$env/static/private';
import Stripe from 'stripe';
const stripe = new Stripe(STRIPE_SECRET_KEY);
export const actions = {
createPaymentIntent: async ({ request }) => {
const data = await request.formData();
const amount = parseInt(data.get('amount') as string);
// Call Stripe on the server, return only what the client needs
const intent = await stripe.paymentIntents.create({
amount,
currency: 'usd'
});
return {
clientSecret: intent.client_secret,
publishableKey: 'pk_live_...' // from PUBLIC env
};
}
};The secret stays on the server. Only the client secret and publishable key leave the boundary. The client secret is specifically designed by Stripe for client-side use.
Common mistakes
Forgetting the PUBLIC_ prefix. You set API_URL=... in your .env file and try to import it as $env/static/public. It won't work. SvelteKit only exposes variables prefixed with PUBLIC_. Rename it to PUBLIC_API_URL.
Using static imports in dynamic contexts. You read $env/static/private once at module load time, store it in a variable, and that variable doesn't update if the environment changes. For most apps this is fine, but if you're running a long-lived server process and rotating secrets, use the dynamic variant instead.
Mixing env files. Your .env file has development secrets, but your CI/CD pipeline sets environment variables at runtime. The static variants read from the environment at build time. If you build in CI without the right variables set, the build succeeds but with wrong config. Always validate that your build environment matches your runtime environment.
Exposing config through console logs. In development, logging console.log(STRIPE_SECRET_KEY) might seem harmless. But if your server logs are aggregated and searchable (which they usually are in production), you've leaked the secret. Never log private env vars. If you need to debug, log a masked version or an ID instead.
Key takeaways
- Use
$env/static/privatefor secrets in server code only. The framework enforces this at build time. - Use
$env/static/publicfor client-safe config. Always prefix withPUBLIC_in your.envfile. - Prefer static variants over dynamic variants for performance and predictability. Use dynamic variants only when you need runtime flexibility.
- The static import is safe; how you use the value is your responsibility. Never return secrets to the client.
- Validate your environment at build time and test your config in the environment where it runs.






Comments
Loading comments…