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.

next-safe-env runs in four different execution environments - Next.js App Router, plain Node.js, Vercel Edge Runtime, and Vite - and each one has different rules about which environment variables are accessible. Adapters encapsulate those rules in two hooks: beforeValidate, which runs before any field is checked and can throw immediately on schema violations, and afterValidate, which runs after all fields pass and can strip keys from the result.

Auto-detection

When you omit the adapter option, next-safe-env selects an adapter automatically by inspecting the runtime environment in the following order:
ConditionAdapter selected
typeof process === 'undefined' or !process.versionedge
process.env.NEXT_RUNTIME is setnextjs
Any client schema key starts with VITE_vite (with a console warning)
Otherwisenode
The NEXT_RUNTIME variable is set automatically by the Next.js App Router, so you typically get the right adapter without any configuration. The VITE_ heuristic emits a warning prompting you to set adapter: 'vite' explicitly to suppress it.
Set adapter explicitly in every createEnv() call to make your intent unambiguous and to silence the auto-detection console warning for Vite projects.

The node adapter

The node adapter is designed for Express, Fastify, CLI scripts, and any plain Node.js application that has no client bundle to protect against. Both hooks - beforeValidate and afterValidate - are no-ops. Raw values go in, validated values come out unchanged. All vars from both the server and client schemas are present in the returned object.
import { createEnv, str, url, port, bool } from 'next-safe-env'

export const env = createEnv({
  server: {
    DATABASE_URL: url(),
    PORT:         port().default(3000),
    LOG_LEVEL:    str().enum(['debug', 'info', 'warn', 'error']).default('info'),
    ENABLE_CACHE: bool().default(true),
  },
  client: {},
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    PORT:         process.env.PORT,
    LOG_LEVEL:    process.env.LOG_LEVEL,
    ENABLE_CACHE: process.env.ENABLE_CACHE,
  },
  adapter: 'node',
})
Because there is no browser context in a Node.js application, the client schema is typically empty. You can still use it to group vars that you might share with a frontend, but no prefix enforcement is applied.

The nextjs adapter

The nextjs adapter targets Next.js App Router and Pages Router applications, where the same module graph is compiled for both the server and the browser. beforeValidate iterates every key in the client schema and throws synchronously if any key does not start with NEXT_PUBLIC_:
Error: [next-safe-env] Client env var "API_KEY" must be prefixed with NEXT_PUBLIC_.
This is a hard fail-fast. Next.js silently omits client vars that lack the NEXT_PUBLIC_ prefix from the browser bundle, so a missing prefix would give you an undefined value in production with no error message. The adapter makes this mistake impossible. afterValidate checks typeof window !== 'undefined'. When true (browser context), every key that does not start with NEXT_PUBLIC_ is stripped from the result before the object is returned and frozen. On the server, all keys are returned unchanged.
import { createEnv, str, url, port, bool } from 'next-safe-env'

export const env = createEnv({
  server: {
    DATABASE_URL:             url(),
    JWT_SECRET:               str().min(32),
    SMTP_PORT:                port().default(587),
    NODE_ENV:                 str().enum(['development', 'production', 'test']),
  },
  client: {
    NEXT_PUBLIC_API_URL:      url(),
    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,
    SMTP_PORT:                process.env.SMTP_PORT,
    NODE_ENV:                 process.env.NODE_ENV,
    NEXT_PUBLIC_API_URL:      process.env.NEXT_PUBLIC_API_URL,
    NEXT_PUBLIC_APP_NAME:     process.env.NEXT_PUBLIC_APP_NAME,
    NEXT_PUBLIC_ENABLE_DEBUG: process.env.NEXT_PUBLIC_ENABLE_DEBUG,
  },
  adapter: 'nextjs',
})
Runtime stripping in afterValidate is a defense-in-depth measure on top of the TypeScript enforcement. The TypeScript ServerOnly<T> brand and import 'server-only' are your primary tools. See Server/Client Split for the full picture.

The edge adapter

The Edge Runtime - used for Vercel Edge Runtime and Next.js Middleware (middleware.ts) - is neither a full Node.js process nor a browser. It only exposes NEXT_PUBLIC_ variables at runtime. Server-only secrets are not available. beforeValidate does not block validation, but emits a console.warn if the server schema contains any keys:
[next-safe-env] Edge adapter: server vars (API_SIGNING_KEY) will be validated
but stripped from the result - they are not accessible in Edge Runtime.
Server vars are still validated - a missing or malformed value still causes a startup failure. They just will not appear in the returned object. The recommended pattern is to leave the server schema empty in Edge files. afterValidate strips every key that does not start with NEXT_PUBLIC_ unconditionally, regardless of typeof window. There is no window in the Edge Runtime, so the browser-context check used by the nextjs adapter would never trigger.
import { createEnv, str, url } from 'next-safe-env'

export const env = createEnv({
  server: {},  // prefer empty - server vars are validated but stripped from the result
  client: {
    NEXT_PUBLIC_API_URL:  url(),
    NEXT_PUBLIC_APP_NAME: str().default('My App'),
  },
  runtimeEnv: {
    NEXT_PUBLIC_API_URL:  process.env.NEXT_PUBLIC_API_URL,
    NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
  },
  adapter: 'edge',
})
The key difference between edge and nextjs is when stripping occurs:
AdapterWhen server vars are stripped
nextjsOnly in browser context (typeof window !== 'undefined')
edgeAlways, unconditionally

The vite adapter

The vite adapter is for non-Next.js React applications that use import.meta.env. Vite only exposes variables prefixed with VITE_ to the client bundle, so the adapter enforces that prefix on every client schema key - the same way the nextjs adapter enforces NEXT_PUBLIC_. beforeValidate throws if any client schema key does not start with VITE_. afterValidate strips server vars from the returned object when running in a browser context.
import { createEnv, str, url, port, bool } from 'next-safe-env'

export const env = createEnv({
  server: {
    DATABASE_URL: url(),
    PORT:         port().default(3000),
  },
  client: {
    VITE_API_URL:  url(),
    VITE_APP_NAME: str().default('My App'),
    VITE_DEBUG:    bool().default(false),
  },
  runtimeEnv: {
    DATABASE_URL:  process.env.DATABASE_URL,
    PORT:          process.env.PORT,
    VITE_API_URL:  import.meta.env.VITE_API_URL,
    VITE_APP_NAME: import.meta.env.VITE_APP_NAME,
    VITE_DEBUG:    import.meta.env.VITE_DEBUG,
  },
  adapter: 'vite',
})
In Vite projects, use import.meta.env.VITE_* for client vars in runtimeEnv, not process.env. Server vars that are only available during the build step (such as from a .env file loaded by Vite’s server process) can still use process.env.
The vite adapter is auto-detected when any client schema key starts with VITE_, but it emits a console warning asking you to set adapter: 'vite' explicitly. Set it explicitly to suppress the warning and make your configuration self-documenting.

When to set adapter explicitly

Auto-detection covers the common cases, but there are situations where you should always set adapter explicitly:
  • Vite projects - auto-detection emits a warning; always set adapter: 'vite'.
  • Next.js Pages Router - NEXT_RUNTIME may not be set during the initial page render in older Next.js versions; set adapter: 'nextjs' to be safe.
  • Monorepos with shared env files - if env.ts is imported from both a Next.js app and a Node.js script, each entry point should call createEnv() independently with the correct adapter set explicitly.
  • Any environment where runtime detection is ambiguous - explicit configuration is always more reliable than heuristics.