Environment variables

Which values reach the browser is declared, not spelled. An app lists the public ones in fluixi.config.ts; everything else stays on the server, whatever it is called.

// fluixi.config.ts
import { defineConfig } from '@fluixi/start/config';

export default defineConfig({
  env: {
    public: ['AUTH_ISSUER', 'AUTH_CLIENT_ID', 'AUTH_TOKENS', 'API_URL', 'APP_URL'],
  },
});

Vite's convention puts the decision in the name: a VITE_ prefix publishes the value. Moving a value between server and browser then renames every reference, and a secret named VITE_API_SECRET ships whether anything reads it or not. A list means a secret can only reach the bundle by being written into it.

Reading them

import { publicEnv, serverEnv } from '@fluixi/start/env';

const { API_URL } = publicEnv();                                    // anywhere
const secret = serverEnv('SESSION_SECRET', { required: true });     // server only

publicEnv() works on both sides and returns the same frozen set on each. In the browser the build has defined it as a literal. On the server it reads process.env, narrowed to the same declared names, so a loader cannot use a value the browser will not have. It also works when the server imports @fluixi/start from node_modules instead of bundling it, which is the default for a published install: there import.meta.env is undefined, and code reading it breaks in production only.

serverEnv(name) reads one server-only variable. In a browser it throws instead of returning undefined, because a secret read as undefined becomes a request sent without credentials, and the failure then points at the request instead of at the read. { required: true } also throws when the variable is unset, naming it.

One reader for both sides

const api = $env('API_URL');                                // string | undefined
const secret = $env('SESSION_SECRET', { required: true });  // string

$env compiles to env from @fluixi/start/env, with no import to write; import env yourself, under any name, if you prefer. On the server it reads any variable. In the browser only the public set exists, so the browser build refuses a secret at the line that reads it, and a name the build could not see, one computed at run time, throws when it runs. A branch only the server takes, behind import.meta.env.SSR or typeof window === 'undefined', is left alone, and so is a "use server" function or a .server file, whose code never reaches the browser.

publicEnv() and serverEnv() stay for code that wants its intent on the page: a reviewer reading serverEnv('STRIPE_KEY') knows it is a secret without looking anything up.

A config object in one declaration

// src/config/env.ts
import { pickEnv, asNumber } from '@fluixi/start/env';

export const config = pickEnv({
  apiUrl: 'API_URL',                                            // string, required
  authServiceUrl: { key: 'AUTH_SERVICE_URL', default: '' },     // string
  pageSize: { key: 'PAGE_SIZE', default: 20, parse: asNumber }, // number
  sentryDsn: { key: 'SENTRY_DSN', optional: true },             // string | undefined
});

A bare key is a required string. default fills in when the variable is unset, optional lets it be undefined, and parse decides the type; each field's type follows from its entry. Every missing or invalid variable is reported in one error, so a new deployment learns its whole list at once rather than one restart at a time. The result is frozen.

asNumber, asBoolean, asList and asUrl refuse what they cannot read: PAGE_SIZE=twelve fails at startup instead of becoming NaN in a query. Each field is read through env(), so the browser build checks these keys like any other read.

Typed keys

@fluixi/start writes the declared names into src/fluixi-env-vars.d.ts, with the server variable names from your .env files beside them, names only. publicEnv(). and $env(' then complete them in the editor, and an undeclared name is a type error before it is a build error. Each key is string | undefined, since a declared variable can still be unset. With nothing declared, publicEnv() stays a plain record.

A key known only at run time needs its type:

import { publicEnv, type PublicEnv } from '@fluixi/start/env';

const read = (key: keyof PublicEnv) => publicEnv()[key];

Where the values come from

fluixi dev and fluixi start load .env, .env.local, .env.<mode> and .env.<mode>.local into process.env on the server, every variable, prefixed or not. A variable already set in the real environment wins over the files, which is how a deployment's own secrets override a developer's .env.

The VITE_ prefix

It keeps working. A declared name is found under its bare spelling or with VITE_ in front, bare first, so .env can move one variable at a time: AUTH_ISSUER in env.public reads VITE_AUTH_ISSUER until it is renamed. Anything already prefixed stays public, as it always was.

What stops a secret shipping

The list is the protection. Four checks catch the line written in a hurry:

Check When What happens
a name in env.public reads like a secret (SECRET, PASSWORD, PRIVATE_KEY, CREDENTIALS) build refused, naming it
a VITE_ variable in any .env file reads like a secret fluixi build refused; fluixi dev warns
publicEnv().X in browser code, X not declared (a typo, or a server-only value) build refused, naming the file, line and variable
serverEnv() called in the browser run time throws

For a name that only reads like a secret, FLUIXI_ALLOW_PUBLIC_SECRETS=true lets the build through.

Next: SEO and the document head.

Released under the MIT License.

Copyright © 2026 Ibrahima Touré