> ## Documentation Index
> Fetch the complete documentation index at: https://next-safe-env.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Server and client environment variable split

> next-safe-env enforces a strict server/client split to prevent secrets from leaking into browser bundles, backed by TypeScript branding and runtime stripping.

When you build a Next.js application, the same JavaScript module system serves both the server and the browser - which means a secret like `DATABASE_URL` can end up in the client bundle if nothing prevents it. `next-safe-env` closes that gap with two complementary layers: TypeScript branding that makes server vars visible in your IDE, and runtime stripping that removes them from the object before the browser ever sees it.

## The `server` and `client` schema keys

Every call to `createEnv()` requires two top-level schema keys: `server` and `client`. Splitting the schema at this level is intentional - the separation signals intent to the reader, to TypeScript, and to the adapter layer.

```typescript theme={null}
import { createEnv, str, url, bool } from 'next-safe-env'

export const env = createEnv({
  server: {
    DATABASE_URL: url(),
    JWT_SECRET:   str().min(32),
  },
  client: {
    NEXT_PUBLIC_APP_NAME:     str().default('My App'),
    NEXT_PUBLIC_ENABLE_DEBUG: bool().default(false),
  },
  runtimeEnv: {
    DATABASE_URL:             process.env.DATABASE_URL,
    JWT_SECRET:               process.env.JWT_SECRET,
    NEXT_PUBLIC_APP_NAME:     process.env.NEXT_PUBLIC_APP_NAME,
    NEXT_PUBLIC_ENABLE_DEBUG: process.env.NEXT_PUBLIC_ENABLE_DEBUG,
  },
})
```

Anything in `server` is treated as a secret that must never reach the browser. Anything in `client` is explicitly safe to expose. The adapter layer reads this distinction and uses it to enforce prefix rules and strip vars at runtime.

## The `ServerOnly<T>` brand

Every key in the `server` schema is returned with the `ServerOnly<T>` brand applied:

```typescript theme={null}
export type ServerOnly<T> = T & { readonly [SERVER_ONLY_BRAND]: void }
```

The brand is a unique symbol intersection. In practice, this means:

* Your IDE tooltips show the type as `ServerOnly<string>` instead of plain `string`, so the origin of a value is always visible at a glance.
* Because `ServerOnly<T>` extends `T`, it remains **assignment-compatible** with `T` at the TypeScript level. Passing `env.DATABASE_URL` where a `string` is expected will not produce a compile error on its own.

This is a deliberate trade-off: the brand is informational by default, not a hard block. The hard block requires an additional step.

## Getting a hard compile error with `import 'server-only'`

To make TypeScript throw a build-time error if your `env.ts` is ever imported in a client bundle, add `import 'server-only'` at the top of the file:

```typescript theme={null}
import 'server-only'
import { createEnv, str, url } from 'next-safe-env'

export const env = createEnv({
  server: {
    DATABASE_URL: url(),
    JWT_SECRET:   str().min(32),
  },
  client: {
    NEXT_PUBLIC_APP_NAME: str().default('My App'),
  },
  runtimeEnv: {
    DATABASE_URL:         process.env.DATABASE_URL,
    JWT_SECRET:           process.env.JWT_SECRET,
    NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
  },
})
```

Next.js's `server-only` package causes a build error the moment the module is included in a client bundle. Combined with the `ServerOnly<T>` brand, this gives you two layers of compile-time protection.

<Note>
  `server-only` is a Next.js package. Install it with `npm install server-only` and make sure you are on Next.js 13 or later. For plain Node.js projects this import is unnecessary - there is no client bundle.
</Note>

## The `ClientEnv<T>` utility type

`ServerOnly<T>` makes server vars visible but doesn't block access to them by key name. If you want a type that **excludes server vars entirely** - so that a function accepting it cannot even reference a server key - use the `ClientEnv<T>` utility type:

```typescript theme={null}
import type { ClientEnv } from 'next-safe-env'
import { env } from '@/env'

// Only client vars - server vars are excluded at the type level
type ClientVars = ClientEnv<typeof env>

// Usage in a client-facing function:
function renderHeader(vars: ClientVars) {
  return vars.NEXT_PUBLIC_APP_NAME  // ✅ fine
  // vars.DATABASE_URL              // ✗ TypeScript error: property does not exist
}
```

`ClientEnv<T>` uses a mapped type with a key filter: any property whose type extends `{ readonly [SERVER_ONLY_BRAND]: void }` is dropped from the resulting type. The function signature `(vars: ClientVars)` then acts as a compile-time boundary - TypeScript will error if you try to pass a server-keyed value into it.

<Tip>
  Use `ClientEnv<T>` when writing React Server Components that call into shared utility functions, or when you want to document at the type level that a module boundary accepts only public env vars.
</Tip>

## Runtime stripping: defense-in-depth

TypeScript enforcement disappears at runtime. The adapter layer provides a second line of defense by physically removing server var keys from the returned object when running in a browser context.

For the `nextjs` adapter, `afterValidate` checks `typeof window !== 'undefined'`. If true (browser context), every non-`NEXT_PUBLIC_` key is deleted from the result before it is returned and frozen. Server-side, all keys are returned unchanged.

For the `edge` adapter, stripping is unconditional - server vars are always removed because the Edge Runtime exposes neither a full Node.js process nor a browser `window`.

<Warning>
  Runtime stripping does not replace TypeScript enforcement - it supplements it. The correct sequence is: TypeScript branding to catch mistakes at development time, `import 'server-only'` or `ClientEnv<T>` for hard compile errors, and runtime stripping as a final safety net.
</Warning>

## Why `runtimeEnv` takes individual references

The `runtimeEnv` object requires you to list each variable individually:

```typescript theme={null}
runtimeEnv: {
  DATABASE_URL:             process.env.DATABASE_URL,
  NEXT_PUBLIC_APP_NAME:     process.env.NEXT_PUBLIC_APP_NAME,
}
```

This pattern exists specifically for bundler tree-shaking. When the Next.js or Vite bundler sees `process.env.DATABASE_URL`, it can inline the value statically at build time and exclude it from bundles where it has no value. If you spread the entire `process.env` object instead, the bundler cannot determine which keys are used, and the whole object - including secrets - can end up in the client bundle.

<Warning>
  Never pass the entire `process.env` object to `runtimeEnv`. Always reference each variable individually as `process.env.VAR_NAME`.
</Warning>
