Frameworks

Next.js

Drop-in setup for the App and Pages routers.

Install

@goenvless/env is the runtime this page imports. It ships no terminal command.

bash
npm install @goenvless/env

Every envless … command on this page comes from a second, independent package, @goenvless/cli. That package is the binary and nothing else, its only export is ./package.json, and installing @goenvless/env does not put an envless command anywhere. Install it too if you want envless link, which writes the .envless file the loader resolves its target from, or envless cache clear under Dev mode:

bash
npm i -g @goenvless/cli@latest

Neither package is required by the other. A CI build that passes ENVLESS_PROJECT and ENVLESS_ENV in the environment never needs the binary at all, as Production builds below shows. See Installation for the per-project alternative and CLI, runtime and SDK for the full comparison.

Wire up the config plugin

next.config.mjs
import { withEnvless } from '@goenvless/env/next';

export default withEnvless({
    // your existing Next config
});

withEnvless(nextConfig?) returns an async Next config function. When Next evaluates the config (at the start of next build, next dev, and next start), it:

  1. Awaits load(), fetching the encrypted bundle and decrypting it locally. Every decrypted value lands in the in-process store that backs env, and in process.env for server-side and build-time code, without clobbering variables already set in the ambient environment.
  2. Deletes from process.env every loaded variable whose name starts with NEXT_PUBLIC_ and whose visibility is not client, then prints a warning naming each one.
  3. Inlines every client-visibility variable whose name is a valid JavaScript identifier into config.env, so Next replaces those references with string literals in client chunks at build time. A client name that is not an identifier, one carrying a dash, a dot or a space, cannot be written into source, so it is skipped and named in a warning instead. It adds one more key, ENVLESS_CLIENT_ENV, holding those values and their metadata as JSON. That payload is what @goenvless/env/client parses when it is imported.

A name that would change how a process executes code never gets a slot in process.env, however you flag it. NODE_OPTIONS, NODE_PATH, NODE_REPL_EXTERNAL_MODULE, ELECTRON_RUN_AS_NODE, LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH, DYLD_FRAMEWORK_PATH, PATH, BUN_INSPECT, BUN_INSPECT_CONNECT_TO, RUBYOPT, RUBYLIB, BUNDLE_GEMFILE, GEM_HOME, GEM_PATH, PYTHONPATH, PYTHONHOME, PYTHONSTARTUP, PYTHONUSERBASE, PHPRC, PHP_INI_SCAN_DIR, ENVLESS_CLIENT_ENV and ENVLESS_INJECTED are still fetched, decrypted and readable through env, but they are refused the write and a warning names each one that was held back.

Server variables stay in the store and in process.env (server + build only). One carrying the NEXT_PUBLIC_ prefix loses its process.env slot at step 2 and stays readable through env, since the strip touches process.env and not the store. Client-visibility variables are the only ones that reach the browser. If load() fails, withEnvless rethrows it under [envless] withEnvless could not load variables, and the Next command stops there.

Use it

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

export async function POST(req: Request) {
    await load();
    const stripe = new Stripe(env.STRIPE_SECRET);
    // ...
}

withEnvless loads in the process that evaluates next.config. A deployment target that evaluates the config at build time only, such as standalone output or a serverless function, starts its request process with an empty store, so await load() before the first read. It caches its promise per process, so the first call does the fetch and decrypt and every later call resolves against that same promise. Read without it in an unloaded process and the proxy throws Envless env is not loaded yet. Import '@goenvless/env/register' or await load() first.

Read the names you need, one at a time. The env from @goenvless/env/server, and the one from the root @goenvless/env, both refuse to be serialised: reach for its toJSON and it throws Refusing to serialise the Envless env, because it holds decrypted secrets and this would copy every one of them into the output. Read the names you need, or call load() for a plain record you own. So Response.json(env) from a Route Handler, or any other JSON.stringify over the whole object, fails loudly rather than shipping every decrypted secret in a response body. When you genuinely want a plain object, use the record load() resolves with, which is yours and carries no such guard. Only @goenvless/env/client still serialises, and it holds client-visibility variables alone.

app/page.tsx
'use client';
import { env } from '@goenvless/env/client';

export default function Page() {
    return <img src={`${env.NEXT_PUBLIC_CDN_URL}/hero.png`} />;
}

On a variable flagged client, the NEXT_PUBLIC_ prefix is optional. config.env inlines by flag, so the value reaches the browser whatever the name. On a variable flagged server, the prefix is a mistake: Next inlines every NEXT_PUBLIC_ name it finds in process.env into client chunks whatever Envless marks it, so withEnvless deletes those names from process.env before the build reads them and warns with each one it removed. Flag the variable client, or rename it without the prefix.

App Router rules

File / directiveImport from
Server Components (default)@goenvless/env/server
Route Handlers@goenvless/env/server
Server Actions@goenvless/env/server
middleware.ts@goenvless/env/client, or @goenvless/env/edge
'use client' files@goenvless/env/client

middleware.ts runs on the Edge runtime. @goenvless/env/server statically imports node:fs, node:path and node:os to read the .envless file, the CLI config and the on-disk cipher cache, so it cannot be imported there. withEnvless inlines client-visibility variables into config.env along with the ENVLESS_CLIENT_ENV payload, and Next replaces those references with literals in the middleware bundle too. /client seeds itself from that payload at import. Variables flagged client are the ones middleware reads with no network call. A server-visibility value is still reachable there through @goenvless/env/edge, which the build refuses to ship with any node: import or Buffer reference in it, at the cost of a fetch and a decrypt per request.

Importing /server from a 'use client' file is reported by the @goenvless/env/eslint plugin, rule @goenvless/env/no-server-import-in-client, which flags every file it lints unless you scope it with the clientGlobs option. It also fails the browser build outright, since /server drags in node builtins the browser cannot resolve. See Server vs Client.

Edge runtime

config.env inlines client-visibility variables as literals into every runtime, including Edge (export const runtime = 'edge'). Reading one of those costs no runtime fetch.

Server-visibility variables are not reachable through /server at the edge, which pulls node:fs, node:path and node:os, and the values load() wrote into process.env belong to the Node process, not the edge sandbox. Mark the variable client in the dashboard so it is inlined via config.env:

app/api/hello/route.ts
import { env } from '@goenvless/env/client';

export const runtime = 'edge';

export async function GET() {
    return Response.json({ msg: env.NEXT_PUBLIC_GREETING });
}

When the value must stay server-visibility, fetch it at request time with @goenvless/env/edge. Its load() is fetch and Web Crypto only, and takes its target explicitly: token, project, environment, an optional product, and either key or passphrase. Three more options are accepted: workspaceId, endpoint and fetch. Pass workspaceId alongside a passphrase to save a round trip, because the PBKDF2 salt is SHA-256('envless:workspace:' + workspaceId) truncated to 16 bytes, so load() otherwise has to fetch the workspace before it can derive the key. It returns a plain record of decrypted strings, so it neither fills the env proxy nor process.env, and it fetches on every call, with no cache at the edge.

app/api/secret/route.ts
import { load } from '@goenvless/env/edge';

export const runtime = 'edge';

export async function GET() {
    const values = await load({
        token: process.env.ENVLESS_TOKEN!,
        project: 'my-app',
        environment: 'production',
        passphrase: process.env.ENVLESS_PASSPHRASE!
    });

    return Response.json({ ok: Boolean(values.STRIPE_SECRET) });
}

Dev mode

withEnvless awaits load() when Next evaluates the config at next dev startup, on the same credentials a build needs. The token it resolves, from ENVLESS_TOKEN or from the CLI config file, has to be a machine key: anything else is refused with ENVLESS_TOKEN must be a machine key (ev_sk_…). Create one in the Envless dashboard. A cached CLI login does not qualify, since envless login stores an env_cli_ token.

It does not hold a live connection to the API, so to pick up a variable you changed in the dashboard, restart the dev server (which re-evaluates the config and re-runs load()).

That restart may not be enough on its own. The 15 minute ciphertext cache under ~/.envless/cache belongs to @goenvless/env: the loader writes a file there after every successful fetch and reads it back instead of fetching while it is under 15 minutes old. The CLI never fills that cache, it only clears it, so reach for the @goenvless/cli binary when you need the change immediately:

bash
envless cache clear          # this project and its environments
envless cache clear --all    # the whole cache directory

Without --all it clears the project and environments it resolves from --project / --env, then ENVLESS_PROJECT / ENVLESS_ENV, then the .envless file, and fails if either the project or the environments come back empty. --all needs none of that. Both only remove files, so the next load() fetches fresh. From inside the process, load({ revalidate: true }) skips the memo and the cache with no binary involved, though withEnvless calls plain load(), so in dev the binary is the practical lever.

Production builds

CI runners have no .envless file and no CLI config to fall back on. Provide the full machine credential set so withEnvless can fetch and decrypt during next build:

bash
ENVLESS_TOKEN=ev_sk_xxxxxxxxxxxx \
ENVLESS_KEY=xxxxxxxxxxxx \
ENVLESS_PROJECT=my-app \
ENVLESS_ENV=production \
next build

ENVLESS_TOKEN fetches the ciphertext and has to start with ev_sk_. ENVLESS_KEY, a base64url encoded 256-bit workspace key, or ENVLESS_PASSPHRASE decrypts it locally. Set one, not both: ENVLESS_KEY wins whenever it is present and non-empty, and ENVLESS_PASSPHRASE is then never read. Prefer the passphrase when the workspace re-encrypted at a non-default PBKDF2 cost, because only a passphrase can re-derive the key at the cost recorded in the ciphertext. A raw key carries no way to re-derive, so a v2: value pinned to another cost fails with This value pins … PBKDF2 iterations, which the supplied key was not derived with. ENVLESS_PROJECT and ENVLESS_ENV name the target, and each also overrides the .envless file when one is present; add ENVLESS_PRODUCT when the project sits under a product. See Machine & CI access for the full env-var reference and Production & CI for Vercel, Docker, and Kubernetes recipes.