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 want | Write | Read at | Per deploy | Validated and typed |
|---|---|---|---|---|
| a secret or a setting, server only | import { API_KEY } from "#silgi/env/private" | server start | yes | yes |
| a value the browser needs, set per deployment | import { GA_ID } from "#silgi/env/public" | server start, sent with the page | yes | yes |
| a value fixed by the build, dead code removed | if (import.meta.env.SHOW_DEBUG) (a static entry) | build | no | yes |
a build-time value with no schema, or the build's MODE | import.meta.env.SILGI_PUBLIC_X, import.meta.env.MODE | build | no | no |
| structured settings with a default in the config file | useRuntimeConfig().public | server start, SILGI_PUBLIC_<KEY> | yes | typed 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? }.
schemais 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 thestring. It must validate synchronously.- The raw value is
process.env[NAME], or the platform env on Workers: astring, orundefinedwhen unset.defaultreplaces an unset value, not an empty one. public: truesends the value to the browser. Without it the value never leaves the server.static: truereads the value at build time and writes it into the bundles (see below). A static entry must bepublic: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.descriptionbecomes the JSDoc of the type and a line of the example file.- A name is a variable name that does not start with
SILGI_, whichruntimeConfigoverrides and Silgi's own settings use.NODE_ENVis refused, sincesilgi devandsilgi buildset it. - The app's
app/env.tsand 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 devvalidates at start and restarts whenapp/env.tschanges.- A route that is prerendered (
prerender.routes, a route rule'sprerender, the static preset) makes the build run the built server, which validatesapp/env.tswith 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
envfromcloudflare:workerswhen the module loads, so an invalid variable stops the worker at load anduseRuntimeConfig()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:
| Key | Value |
|---|---|
MODE | the mode |
DEV | true under silgi dev |
PROD | true for every other command, also silgi build --mode staging |
SSR | true in the server bundle, false in the browser's |
BASE_URL | app.baseURL |
every variable that starts with an entry of build.envPrefix | its value, read when the build starts |
every static entry of app/env.ts | its 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.envPrefixis 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 privateSILGI_variables is refused too:SILGI_itself, orSILGI_followed by anything butPUBLIC_.- A name that
app/env.tsdeclares is never read as a prefixed value, soenvPrefix: "APP_"does not inline a privateAPP_DB_PASSWORD. - A key that is not in the object reads
undefined. A server read ofimport.meta.env.SECRET_Xisundefined, 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_URLand the static names. Bun's types giveImportMetaEnva string index signature, soimport.meta.env.NOPEisstring | undefinedand compiles; a misspelt name is not caught bytsc. - A static entry cannot be named
MODE,DEV,PROD,SSRorBASE_URL. SILGI_PUBLIC_<KEY>also overridesruntimeConfig.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 touchdefineEnvvalues, and it is one pass: a value taken from the env is not expanded again. - A
${NAME}underruntimeConfig.publicthat names a private entry ofapp/env.tsfails 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 | ||
|---|---|---|
| 1 | the shell | a variable set before silgi started is never replaced |
| 2 | .env.<mode>.local | with --mode only |
| 3 | .env.<mode> | with --mode only |
| 4 | .env.<NODE_ENV>.local, then .env.local | read by Bun at start. .env.local is not read under NODE_ENV=test |
| 5 | .env.<NODE_ENV>, then .env | read by Bun at start |
| 6 | --dotenv <file> | fills only names no source above set |
| 7 | SILGI_<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 devrestarts once when a mode file changes.silgi previewserves the build with the mode it was built with, read fromsilgi.json.--modewith another name fails:.output was built with --mode staging; runsilgi preview --mode stagingor rebuild with --mode qa.- A built server does not read
.envfiles. In production set real variables on the host.silgi preview --mode stagingreads the mode files for the process it starts, sodefineEnvvalidates what a deployed server would get from its platform. - The
.gitignoreof every starter ignores.env.*except.env.example, which covers the.localmode 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" } } },
},
});
$developmentapplies tosilgi dev,$testwhenNODE_ENVistest, and$productionto every other command.$env.<name>applies when--mode <name>is given. Undersilgi dev, whose mode isdevelopment,$env.developmentapplies.- 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, andundefineddoes 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,extendsanddev, which are fixed before a block applies.outputDircannot be set in$env.<name>:silgi previewreads 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 withUnknown 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.