SvelteKit environment variables

Aug 22, 2026 SvelteKit

An environment variable is a value the process reads from outside the source tree. Database URLs and API tokens belong there. The shop name in the nav can too, even though it is not a secret.

You keep the real values in .env on your laptop and in the host’s dashboard in production. Git gets .env.example with empty keys.

SvelteKit does not put every variable in the browser. Names that start with PUBLIC_ can be imported in a .svelte file. Everything else stays on the server. The environment variables docs list the four $env modules.

We will put a shop name in the layout, hours on the home page from a load function, and a /api/ping route that checks a token. The SvelteKit tutorial is enough of a project if you already have one. Routing already covered +server.ts.

We will end up with these files:

.env
.env.example
src/routes/
├── +layout.svelte
├── +page.svelte
├── +page.server.ts
└── api/
    └── ping/
        └── +server.ts

Step 1: create the project

If you already have an app from the tutorial or the routing post, reuse it. Otherwise install Node 18 or newer and run the Svelte CLI:

npx sv create shop
cd shop
npm install
npm run dev

Pick TypeScript. Skip the add-ons.

Open http://localhost:5173. Vite is the dev server.

This post uses Svelte 5 runes and SvelteKit 2. If sv create gave you SvelteKit 3, $lib may be #lib. The $env/* modules below become $app/env/private and $app/env/public. The SvelteKit 3 migration guide lists those renames. There is a Kit 3 example at the end.

Step 2: the .env file

Create .env at the project root. Vite loads this file in dev and at build time:

PUBLIC_SHOP_NAME="Northside Hardware"
SHOP_HOURS="Mon-Sat 9-18"
API_TOKEN=dev-token-change-me

PUBLIC_SHOP_NAME starts with PUBLIC_, so SvelteKit will let the client import it. SHOP_HOURS and API_TOKEN have no prefix. They stay private.

Copy the same keys into .env.example with empty values. Commit that file so the next person knows which names exist. Keep .env out of git. sv create already ignores .env and .env.*, with an exception for .env.example.

.env.local overrides .env if both exist. Use it for a token that is only on your machine. Vite reads .env.[mode].local first, then .env.[mode], then .env.local, then .env. A value on the command line wins for that process:

API_TOKEN=other npm run dev

Restart npm run dev after you edit .env. Vite reads the file when it starts.

Step 3: a public name in the layout

Replace src/routes/+layout.svelte. Import the shop name from $env/static/public:

<script lang="ts">
  import { PUBLIC_SHOP_NAME } from '$env/static/public';

  let { children } = $props();
</script>

<nav>
  <a href="/">{PUBLIC_SHOP_NAME}</a>
</nav>

{@render children()}

$env/static/public only exports names that start with PUBLIC_. Import API_TOKEN from that module and the build fails.

Open /. The nav should say Northside Hardware.

That string is now in the JavaScript the browser downloads. View source will show Northside Hardware, and every other PUBLIC_ value too. A Google Analytics measurement id can live there. Leave the database password in $env/static/private.

Step 4: private hours from the server

Hours can be private even though they are not a secret. Read them in load with $env/static/private and return only the string the page should print.

Create src/routes/+page.server.ts:

import { SHOP_HOURS } from '$env/static/private';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = () => {
  return { hours: SHOP_HOURS };
};

Replace src/routes/+page.svelte:

<script lang="ts">
  let { data } = $props();
</script>

<h1>Hours</h1>
<p>{data.hours}</p>

Reload /. You should see Mon-Sat 9-18. The text is in the HTML. The $env/static/private import is not in the client JavaScript.

A .svelte file or +page.ts that imports $env/static/private fails the build. Those files run in the browser on navigation. Put the import in +page.server.ts, +server.ts, hooks.server.ts, or a module under $lib/server. That is the full-stack split.

If you return { token: API_TOKEN } from load, the token is no longer private. SvelteKit serializes load data into the page. Return the hours string. Do not return the token.

$env/static/private fails vite dev and vite build if you import a name that is not in the environment at all. An empty value in .env still counts as declared, so SHOP_HOURS= will start the app and give you "". Throw if an empty string would be a bad config:

if (!SHOP_HOURS) {
  throw new Error('SHOP_HOURS is not set');
}

The Drizzle Postgres and SQLite posts do that with DATABASE_URL. The S3 upload post does it with the AWS keys.

Step 5: a ping route with a token

Create src/routes/api/ping/+server.ts. Routing already used +server.ts for JSON. Here the handler checks a header against API_TOKEN:

import { json } from '@sveltejs/kit';
import { API_TOKEN } from '$env/static/private';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = ({ request }) => {
  const header = request.headers.get('authorization');
  if (header !== `Bearer ${API_TOKEN}`) {
    return json({ error: 'unauthorized' }, { status: 401 });
  }

  return json({ ok: true });
};

json() sets the content type. Layouts do not wrap +server.ts, so the check has to live in this file.

curl -i http://localhost:5173/api/ping
curl -i -H "Authorization: Bearer dev-token-change-me" http://localhost:5173/api/ping

The first call should be 401. The second should be {"ok":true}.

This checks a shared token. For a logged-in user, use a session cookie. HttpOnly cookie auth is that flow.

Step 6: static vs dynamic

The PUBLIC_ prefix decides who can import the name. Static vs dynamic decides when SvelteKit reads the value.

$env/static/private and $env/static/public copy the value into the bundle at build time. Change API_TOKEN on the server later and the running app still has dev-token-change-me until you rebuild. Named imports also let the compiler drop unused branches. If a public flag is "false" at build, that branch can vanish from the client bundle.

$env/dynamic/private and $env/dynamic/public read the live environment. On adapter-node that is process.env. One build, different tokens per machine:

import { env } from '$env/dynamic/private';

const token = env.API_TOKEN;

env.API_TOKEN can be undefined. There is no named export that fails the build. Put the key in .env even if the value is empty, so TypeScript knows the name.

This post uses static because the values are in .env when Vite starts. Switch to dynamic if staging and production run the same build folder with different tokens. Cloudflare bindings also show up at runtime, so $env/dynamic/private is the one that sees them.

Vercel sets env for the build and for the function. The adapter-vercel docs suggest $env/static/private for Vercel system variables.

Do not use Vite’s import.meta.env.VITE_* in a SvelteKit app. Vite’s prefix is VITE_, not PUBLIC_, and Vite compiles every VITE_ name into the client. import.meta.env.VITE_API_TOKEN is how you leak the token.

process.env.API_TOKEN in a .svelte file is undefined in the browser. In server code it skips the public/private check, and it is missing on runtimes that are not Node. Import $env.

A drizzle.config.ts at the project root is not a Vite module. $env/static/private does not exist there. Load .env with dotenv, the same way the Drizzle posts do.

Step 7: production

vite dev and vite preview load .env. node build does not.

With adapter-node, put the variables on the process. On Node 20.6 or newer:

node --env-file=.env build

Or install dotenv and run node -r dotenv/config build.

PORT and HOST belong to adapter-node. They are not $env names. PORT=4000 node build changes the listen port.

On Vercel, open the project, then Settings, then Environment Variables. Add PUBLIC_SHOP_NAME, SHOP_HOURS, and API_TOKEN. Redeploy. Committing .env does not copy it to Vercel. The Vercel CLI command vercel env pull .env.local copies the dashboard values onto your laptop.

adapter-static has no server at runtime. The build still inlines PUBLIC_ values into the HTML and JS. /api/ping will not exist after deploy. A load that needs a live token will not run on request. Use a Node or serverless adapter for those.

kit.env.publicPrefix defaults to PUBLIC_. kit.env.privatePrefix defaults to "", so every name without the public prefix is private. If you set privatePrefix to SECRET_, SvelteKit discards a bare DATABASE_URL. Rename the variable or leave the prefix empty.

SvelteKit 3

SvelteKit 2.63 added explicit environment variables behind kit.experimental.explicitEnvironmentVariables. In SvelteKit 3 that is the default. $env/static/* and $env/dynamic/* go away. You declare names in src/env.ts and import them from $app/env/private and $app/env/public. Drop the PUBLIC_ prefix. Set public: true on the names the browser may see:

import { defineEnvVars } from '@sveltejs/kit/env';

export const variables = defineEnvVars({
  API_TOKEN: {},
  SHOP_HOURS: {},
  SHOP_NAME: {
    public: true
  }
});
import { API_TOKEN } from '$app/env/private';
import { SHOP_NAME } from '$app/env/public';

A Standard Schema validator (Zod, Valibot) can reject a bad value at startup. static: true on a public flag is the tree-shaking replacement for $env/static/public. You can also drop a public value into src/app.html as %sveltekit.env.SHOP_NAME%.

This series still uses $env. Turn the flag on when you want src/env.ts, or when you move to Kit 3.

Things that trip people up

Putting API_TOKEN in a PUBLIC_ name, or importing it from $env/static/public. The client bundle then contains the token. Use $env/static/private or $env/dynamic/private.

Returning a secret from load. The HTML and the client-side navigation payload both get it.

Importing $env/static/private from +page.ts. That file runs in the browser after the first load.

Editing .env and waiting for HMR. Restart the dev server.

Expecting .env to load after node build. adapter-node will not read it unless you pass --env-file or dotenv.

Using import.meta.env.VITE_API_TOKEN. Vite will ship it to the browser.

A drizzle.config.ts or any other CLI outside Vite. It cannot import $env.

Prerendering a page whose load reads a private variable. The value is whatever the build machine had. A live /api/ping still needs a server.

What to add next

Point DATABASE_URL at Postgres or SQLite. Same $env/static/private import, then a load that selects rows instead of returning SHOP_HOURS.

AWS keys for a presigned S3 upload belong in the private module too. Keep those names out of the PUBLIC_ prefix.