Environment

An app reads values from the environment in five ways. They differ in when the value is read, and that is the choice to make. Configuration covers runtimeConfig; this page covers app/env.ts, import.meta.env, modes and the config blocks that change per mode.

You wantWriteRead atPer deployValidated and typed
a secret or a setting, server onlyimport { API_KEY } from "#silgi/env/private"server startyesyes
a value the browser needs, set per deploymentimport { GA_ID } from "#silgi/env/public"server start, sent with the pageyesyes
a value fixed by the build, dead code removedif (import.meta.env.SHOW_DEBUG) (a static entry)buildnoyes
a build-time value with no schema, or the build's MODEimport.meta.env.SILGI_PUBLIC_X, import.meta.env.MODEbuildnono
structured settings with a default in the config fileuseRuntimeConfig().publicserver start, SILGI_PUBLIC_<KEY>yestyped by the config

app/env.ts

app/env.ts default-exports defineEnv({ … }). It lists the variables your app reads, with a schema for each:

import { defineEnv } from "silgi";

const port = (raw: string | undefined): number => {
  const value = Number(raw);
  if (!Number.isInteger(value)) throw new Error("must be a number");
  return value;
};

export default defineEnv({
  API_KEY: {},
  PORT: { schema: port, default: "3000", description: "Port of the HTTP server" },
  GA_ID: { public: true, description: "Analytics id" },
  SHOW_DEBUG: { public: true, static: true, schema: (raw) => raw === "true", default: "false" },
});

An entry is { public?, static?, schema?, default?, description? }.

  • schema is a Standard Schema (Zod, Valibot, ArkType) or a function from the raw string to the value that throws on a bad one. Without it the value is the string. It must validate synchronously.
  • The raw value is process.env[NAME], or the platform env on Workers: a string, or undefined when unset. default replaces an unset value, not an empty one.
  • public: true sends the value to the browser. Without it the value never leaves the server.
  • static: true reads the value at build time and writes it into the bundles (see below). A static entry must be public: app/env.ts: SHOW_DEBUG has static without public, since a private static value would put a secret into .output/server. A static value must be JSON-serializable.
  • description becomes the JSDoc of the type and a line of the example file.
  • A name is a variable name that does not start with SILGI_, which runtimeConfig overrides and Silgi's own settings use. NODE_ENV is refused, since silgi dev and silgi build set it.
  • The app's app/env.ts and each layer's merge by name, the app's entry replacing a layer's whole.

The build reads the names, public and static from the object literal in the file's text, so the client bundle knows which names are public without importing a schema. Write the entries as object literals. Anything else fails with a message such as app/env.ts: API_KEY must be an object literal.

Private and public values

import { API_KEY, PORT } from "#silgi/env/private";
import { defineHandler } from "silgi/server";

export const info = defineHandler({
  method: "GET",
  route: "/api/info",
  handler: () => ({ port: PORT + 1, keyLength: API_KEY.length, prerendering: import.meta.prerender === true }),
});

#silgi/env/private exports every name, evaluated once. The server entry imports it first, so an invalid value stops the start. It is server only: importing it from client code fails the build and names the file and line, "#silgi/env/private" is server-only; read a public variable from "#silgi/env/public".

#silgi/env/public exports the public names that are not static. On the server it is the validated value. In the browser it reads a <script type="application/json" id="__silgi_env"> that the server writes after the payload, with the same serializer, so a Date or a Map out of a schema survives. It is read once, when the page loads; client navigations do not read it again. A value the serializer refuses stops the server at start. Importing a static name from it fails the build.

import { GA_ID } from "#silgi/env/public";

export function startAnalytics() {
  if (import.meta.env.SHOW_DEBUG) console.log(`analytics ${GA_ID} in ${import.meta.env.MODE}`);
  return GA_ID;
}

Both modules are typed from the schema: PORT is a number here, GA_ID a string.

Static values

import.meta.env.SHOW_DEBUG is replaced at build time by the value, so if (import.meta.env.SHOW_DEBUG) … and what is inside it are removed from the bundle when it is false. A named import cannot do this with Bun's bundler, which is why a static value is read from import.meta.env and not from a module.

When the value is read

The non-static names are checked when the server starts, before it listens: on Bun and Node, in a Vercel or Netlify function when it loads, and on Cloudflare when the worker module loads, from the platform env. A static name is checked at build. A failure stops with every bad name and exit code 1:

Invalid environment variables:
  API_KEY is required
  PORT must be a number (got a value of 1 character)
  GA_ID must start with "G-" (got "UA-1")

A private value is never printed, only its length, so a malformed secret does not reach the CI log. A public value is printed.

  • silgi dev validates at start and restarts when app/env.ts changes.
  • A route that is prerendered (prerender.routes, a route rule's prerender, the static preset) makes the build run the built server, which validates app/env.ts with the build's env. A build that prerenders therefore needs every non-static variable set, the private ones too. A prerendered page keeps the build's public values, because it is not rendered again per deploy; read a value that differs per deploy from a page that is not prerendered.
  • On the static preset (silgi generate) there is no server start, so the public values are read when the build runs and are build-time values.
  • On Cloudflare the entry reads env from cloudflare:workers when the module loads, so an invalid variable stops the worker at load and useRuntimeConfig() works in a plugin's setup. Only string values are read.

The example file

silgi typegen writes .silgi/.env.example, one block for each entry in the order you declared them. It is built from the text of app/env.ts alone, never from your real variables.

# string, required
API_KEY=
# port (default 3000). Port of the HTTP server
PORT=
# string, public, required. Analytics id
GA_ID=
# (raw) => raw === "true", public, static (default false)
SHOW_DEBUG=

The comment holds the type (the text of schema, or string), public and static, required or the default, and the description. The file is rewritten on every run, so it sits in .silgi/ and does not touch your repository. Copy it when you want one:

silgi build --mode staging
silgi preview --mode staging
cp .silgi/.env.example .env.example

import.meta.env and import.meta.prerender

Both bundles, the server's and the browser's, replace import.meta.env with an object that has exactly these keys:

KeyValue
MODEthe mode
DEVtrue under silgi dev
PRODtrue for every other command, also silgi build --mode staging
SSRtrue in the server bundle, false in the browser's
BASE_URLapp.baseURL
every variable that starts with an entry of build.envPrefixits value, read when the build starts
every static entry of app/env.tsits validated value
import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  build: { envPrefix: ["SILGI_PUBLIC_", "APP_"] },
  experimental: { envExpansion: true },
  runtimeConfig: { public: { apiUrl: "${API_HOST}/v1" } },
});
  • build.envPrefix is a string or a list of strings; the default is ["SILGI_PUBLIC_"]. An entry must be a start of a variable name. The empty string is refused, since it would expose every variable. An entry that would expose Silgi's private SILGI_ variables is refused too: SILGI_ itself, or SILGI_ followed by anything but PUBLIC_.
  • A name that app/env.ts declares is never read as a prefixed value, so envPrefix: "APP_" does not inline a private APP_DB_PASSWORD.
  • A key that is not in the object reads undefined. A server read of import.meta.env.SECRET_X is undefined, not the secret, and the server and the browser see the same keys.
  • The object is a literal at each read, not a frozen shared object.
  • Typed keys are MODE, DEV, PROD, SSR, BASE_URL and the static names. Bun's types give ImportMetaEnv a string index signature, so import.meta.env.NOPE is string | undefined and compiles; a misspelt name is not caught by tsc.
  • A static entry cannot be named MODE, DEV, PROD, SSR or BASE_URL.
  • SILGI_PUBLIC_<KEY> also overrides runtimeConfig.public.<key> when the server starts. When a name matches both, the build logs that the two values can differ per deploy.

import.meta.env is read at build time and does not change after, where useRuntimeConfig().public is read when the server starts. Use import.meta.env for a value that is the same for every deploy of one build, and runtimeConfig or #silgi/env/public for a value that is not.

Client code reads process.env neither way: process.env.API_KEY in a client module fails the build with use useRuntimeConfig().public, import.meta.env or #silgi/env/public.

import.meta.prerender is true while silgi generate renders, and undefined in silgi preview and in a deployed server. In the browser it is false. A load can use it to behave differently when it writes a page at build time. It is typed boolean | undefined, as import.meta.client, server and dev are.

Expanding variables in runtimeConfig

With experimental.envExpansion: true, every string in runtimeConfig, private and public, has ${NAME} replaced from the env when the server starts. In the sample above apiUrl becomes https://api.example.com/v1 when API_HOST=https://api.example.com.

  • An unset name gives an empty string and one warning for each name: runtimeConfig.apiUrl uses ${API_HOST}, which is not set. $${ writes a literal ${.
  • Expansion runs before the SILGI_* overrides, so an override replaces the whole string. It does not touch defineEnv values, and it is one pass: a value taken from the env is not expanded again.
  • A ${NAME} under runtimeConfig.public that names a private entry of app/env.ts fails the build, one line for each reference, since a public value goes to the browser.
  • A prerendered page keeps the values of the build.

Modes

A mode is a name for a build: development, production, staging. --mode <name> is on silgi dev, build, generate, preview, typegen and typecheck. The default is development for dev and production for the others. A mode is letters, digits, - and _, and does not start with -.

silgi build --mode staging
silgi preview --mode staging
cp .silgi/.env.example .env.example

The mode is import.meta.env.MODE, and it picks the $env block of the config. It does not change NODE_ENV, which stays development for dev and production for the others.

With --mode staging, Silgi reads .env.staging and then .env.staging.local from the app root, before it loads silgi.config.ts, so the config file sees their values in process.env:

API_KEY=staging-key
GA_ID=G-STAGING
SILGI_PUBLIC_SITE_NAME=Corner shop (staging)

Without --mode no mode file is read; Bun already reads .env.production or .env.development as .env.<NODE_ENV>. There is no key for another directory: the mode files must be read before the config file that would name it. Use --dotenv <file> for a file shared by several packages.

The order, highest first:

Source
1the shella variable set before silgi started is never replaced
2.env.<mode>.localwith --mode only
3.env.<mode>with --mode only
4.env.<NODE_ENV>.local, then .env.localread by Bun at start. .env.local is not read under NODE_ENV=test
5.env.<NODE_ENV>, then .envread by Bun at start
6--dotenv <file>fills only names no source above set
7SILGI_<KEY>, SILGI_PUBLIC_<KEY>at run time, over runtimeConfig only

A mode file replaces a value that Bun read from a file, never a value set in the shell. All files are read from the app root, wherever the command is run from.

  • silgi dev restarts once when a mode file changes.
  • silgi preview serves the build with the mode it was built with, read from silgi.json. --mode with another name fails: .output was built with --mode staging; run silgi preview --mode staging or rebuild with --mode qa.
  • A built server does not read .env files. In production set real variables on the host. silgi preview --mode staging reads the mode files for the process it starts, so defineEnv validates what a deployed server would get from its platform.
  • The .gitignore of every starter ignores .env.* except .env.example, which covers the .local mode files.

Blocks per mode

A config file, the app's or a layer's, may hold $development, $production, $test and $env. They change keys for one kind of run or one mode:

import { defineConfig } from "silgi";

export default defineConfig({
  framework: "react",
  build: { minify: true },
  runtimeConfig: { public: { apiBase: "/api" } },
  $development: { runtimeConfig: { public: { apiBase: "http://localhost:4000/api" } } },
  $test: { storage: { default: { driver: "memory" } } },
  $production: { build: { sourcemap: "linked" } },
  $env: {
    staging: { runtimeConfig: { public: { apiBase: "https://staging.example.com/api" } } },
  },
});
  • $development applies to silgi dev, $test when NODE_ENV is test, and $production to every other command.
  • $env.<name> applies when --mode <name> is given. Under silgi dev, whose mode is development, $env.development applies.
  • A block is merged over the keys of the file and wins over them: $env[mode] over $<kind> over the file's own keys. Arrays are replaced, and undefined does not override.
  • A layer's blocks apply to that layer's keys only, before the layers merge.
  • A block cannot hold another block or the keys rootDir, extends and dev, which are fixed before a block applies. outputDir cannot be set in $env.<name>: silgi preview reads it before it knows the mode. Set it at the top level or in $production.
  • The layer-only keys (name, modules, srcDir, serverDir, publicDir, buildDir, outputDir) of a layer, in a block or not, do not reach the app.
  • A key that starts with $ and is not one of the four fails with Unknown config key "$prod"; use $development, $production, $test or $env.
  • The resolved config never holds a $ key.

There is no --envName. --mode is the one flag, and it picks the $env block.