Skip to main content

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.

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.
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:
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:
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.
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.

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:
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.
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.

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.
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.

Why runtimeEnv takes individual references

The runtimeEnv object requires you to list each variable individually:
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.
Never pass the entire process.env object to runtimeEnv. Always reference each variable individually as process.env.VAR_NAME.