Getting started

Server vs Client

Keep server secrets out of your client bundle, enforced by types, runtime, and the bundler.

The rule

Server secrets must never end up in a client bundle. Envless enforces this with four independent layers (types, runtime, bundler, and lint), so any one of them failing still leaves the others standing.

Two imports

ts
import { env } from '@goenvless/env/server';  // all variables
import { env } from '@goenvless/env/client';  // client-flagged only

Each entrypoint also exports optional, the same proxy returning undefined for a name that is not set rather than throwing. It carries the identical visibility filter, so optional from /client is not a way around the split: a server-only name still throws there.

  • /server exposes every variable. Use in API routes, server components, server actions, scripts, and background jobs. It reaches node:fs to read your .envless file and the on-disk cache, so it needs a Node runtime and cannot run on an edge runtime, including Next middleware on its default runtime.
  • /client exposes only variables you've marked client in the dashboard. Use in browser code, 'use client' files, and any module that ships to the browser.

Where there is no filesystem at all, reach for @goenvless/env/edge instead of /server. Its load(options) takes the machine key, the project and environment, and the passphrase or key to decrypt with, since there is no .envless file and no config to read any of that from, and it hands back a plain record rather than a proxy. It reads nothing from disk and writes nothing to disk, so every call fetches.

The root . entry also exports an env proxy, but it is not typed by envless types and not visibility filtered, so it is not a substitute for either of these two.

Neither the /server proxy nor the root one can be serialised. JSON.stringify(env) on either throws, because it would copy every decrypted secret into the output, so call load() when you want a plain record of your own. The /client proxy does serialise, and returns the client-flagged values, which already sit in the chunk anyway.

Visibility flag

Every variable in the Envless dashboard has a visibility setting, either server (the default) or client:

VisibilityAvailable in /serverAvailable in /clientBundling
server (default)✅❌ (type error and runtime throw)Never enters client chunks
client✅✅Inlined as a string literal at build time

That single flag drives everything: type generation, the runtime proxy, and bundler behavior.

How values reach the browser

Client variables can't be fetched at runtime in the browser; that would require shipping the decryption key. Instead the framework adapters inline them at build time, as a payload of client-flagged values that /client reads back:

ts
// what you wrote
import { env } from '@goenvless/env/client';
fetch(env.PUBLIC_API_URL);

// what the build inlines into the client chunk, client-flagged values only
'{"values":{"PUBLIC_API_URL":"https://api.acme.com"},"meta":{"PUBLIC_API_URL":{"type":"string","visibility":"client"}}}';

The bundler does not rewrite env.PUBLIC_API_URL into a literal. Importing /client parses that inlined payload into the store as an import side effect, so the read hits values that already sit in the chunk. Nothing is fetched and no key ships.

withEnvless merges client variables into Next's config.env and puts the payload there under ENVLESS_CLIENT_ENV; the envless() Vite plugin defines each one as import.meta.env.<NAME> and the payload as __ENVLESS_CLIENT_ENV__. Both inline by flag and not by name, so a client variable reaches the browser whatever you call it. This is the same trick behind Next's NEXT_PUBLIC_* and Vite's VITE_*, with Envless owning the decision in one place.

The NEXT_PUBLIC_ name is still reserved

Next inlines every NEXT_PUBLIC_ name it finds in process.env into client chunks by itself, whatever Envless marks it. So the flag decides what ships, but the name can still force a value out: withEnvless deletes any NEXT_PUBLIC_ variable flagged server from process.env before the build reads it, and warns with each name it removed. Under Next, flag a NEXT_PUBLIC_ variable client or rename it.

The Vite plugin runs the same strip for VITE_ (or for whatever envPrefix your config declares), with the same warning.

What ships where

Server chunkClient chunk
Loader code✅ runs on /register or await load()❌ never imported
Decryption key✅ from ENVLESS_KEY / ENVLESS_PASSPHRASE❌ never
Server-flagged values✅ live, fetched when the loader runs❌ not present
Client-flagged values✅ live✅ inlined as literals

The four layers of enforcement

Type errors

envless types, from the @goenvless/cli package, writes envless-env.d.ts with a separate interface EnvlessClientEnv that contains only client-flagged variables. Both interfaces ship empty and are filled in by that file, so reading env.STRIPE_SECRET from /client doesn't autocomplete and fails tsc.

Runtime throw

The /client proxy throws on a non-client key even if you bypass the types. Which message you get depends on what is in the store at the time.

In the browser, the build injects a payload of client-flagged variables only, so a server key was never hydrated and reading it throws Missing env var: STRIPE_SECRET. That stays true when you have no client variables at all, because the adapters still inject an empty payload and an empty payload still marks the store loaded. The other message, Envless env is not loaded yet. Import '@goenvless/env/register' or await load() first., appears only when no payload was injected, so when the build never ran withEnvless or the Vite plugin.

During server rendering the store is shared with /server, so the full set is loaded in the same process and the visibility filter is what rejects the read: STRIPE_SECRET is server only. Import it from '@goenvless/env/server'.

Bundler strip

The build adapters only inline client-flagged keys into client chunks. Server-flagged values are never emitted into the browser bundle, and the loader that would fetch them never enters the client graph, since /client imports the store and the payload seeder and nothing else.

ESLint rule

@goenvless/env/eslint ships the rule no-server-import-in-client, which errors when a file imports @goenvless/env/server. Enable it with configs.recommended.

Configuring the ESLint rule

The zero-config path spreads the recommended preset, which registers the plugin and turns the rule on as an error:

eslint.config.js
import envless from '@goenvless/env/eslint';

export default [
    envless.configs.recommended,
];

By default the rule flags every import of /server. Disable it per-file (with // eslint-disable-next-line @goenvless/env/no-server-import-in-client) in your legitimate server entrypoints. Or register the plugin yourself and scope it to just your client files with the clientGlobs option, leaving server files untouched:

eslint.config.js
import envless from '@goenvless/env/eslint';

export default [
    {
        plugins: { '@goenvless/env': envless },
        rules: {
            '@goenvless/env/no-server-import-in-client': [
                'error',
                { clientGlobs: ['**/*.client.ts', 'src/client/**'] },
            ],
        },
    },
];

Next.js example

ts
// app/api/checkout/route.ts, server
import '@goenvless/env/register';
import { env } from '@goenvless/env/server';

export const POST = async (): Promise<Response> => {
    const created = await fetch('https://api.stripe.com/v1/checkout/sessions', {
        method: 'POST',
        headers: { Authorization: `Bearer ${env.STRIPE_SECRET}` }
    });

    return new Response(await created.text(), { status: created.status });
};

Nothing loads on its own. /register awaits load() before the importing module finishes evaluating, and preloading it once for the whole process with node --import @goenvless/env/register covers every route. Reading env.STRIPE_SECRET at module scope with neither in place throws Envless env is not loaded yet. Import '@goenvless/env/register' or await load() first.

ts
// app/checkout/page.tsx, client component
'use client';
import { env } from '@goenvless/env/client';
fetch(env.NEXT_PUBLIC_API_URL);

The client side needs no preload, since importing /client parses the inlined payload itself, and NEXT_PUBLIC_API_URL has to be flagged client to be in it. With no-server-import-in-client enabled, a client file importing /server fails lint. Even with lint disabled, reading a server variable from /client throws immediately at runtime: a loud failure, not a silent leak.

Tradeoff to know

Client variables are frozen at build time (like every other framework). Rotating one requires a new build.

Server variables are live, though not instantly. The loader reuses the on-disk ciphertext cache in ~/.envless/cache while it is under 15 minutes old, so a restart inside that window can still hand you the old value. To pick up a rotation right away, call load({ revalidate: true }), which skips both the cached file and the in-process memo, or delete the cached file for that project and environment before restarting.

Going deeper

The wider picture around the visibility flag.