Skip to content
LogoLogo

server / client / shared

Every schema — passed to createEnvironment or generateEnvironmentExample — can declare up to three buckets:

BucketReadable on serverReadable on clientPrefix required
sharedYesYesNo
serverYesNo — throwsNo
clientYesYesYes, clientPrefix
import { createEnvironment } from "@zap-studio/env";
import { z } from "zod";
 
export const env = createEnvironment({
  shared: {
    NODE_ENV: z.enum(["development", "production", "test"]),
  },
  server: {
    DATABASE_URL: z.string().url(),
  },
  client: {
    NEXT_PUBLIC_API_URL: z.string().url(),
  },
  clientPrefix: "NEXT_PUBLIC_",
  runtimeEnvStrict: {
    NODE_ENV: process.env.NODE_ENV,
    DATABASE_URL: process.env.DATABASE_URL,
    NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
  },
});

client vars need runtimeEnvStrict, with each key on its own process.env.X line. See Advanced Options for why a plain runtimeEnv: process.env does not survive bundling.

shared vars are validated once and carry no prefix requirement — use them for values that aren't secrets and don't need the client/server split at all, such as NODE_ENV.

clientPrefix Is Required With client

As soon as a schema declares any client key, clientPrefix becomes required. Every client key must start with it, checked both at the type level and at runtime:

createEnvironment({
  client: { API_URL: z.string().url() }, // ❌ type error: does not start with "NEXT_PUBLIC_"
  clientPrefix: "NEXT_PUBLIC_",
  runtimeEnv: process.env,
});

At runtime, a mismatched key or a missing clientPrefix throws an EnvironmentError — this is a setup mistake, not a validation failure.

The Access Guard

Off the server — isServer: false, or the default typeof window === "undefined" check evaluating to false — the object createEnvironment returns is wrapped in a Proxy. Reading a server-only key through that proxy throws an EnvironmentAccessError instead of silently returning the value (or undefined):

// In a browser bundle, isServer defaults to false:
env.DATABASE_URL; // throws EnvironmentAccessError
env.NEXT_PUBLIC_API_URL; // fine — client and shared vars are always readable

This mirrors, at runtime, the same split that build-time tools (bundler dead-code elimination, NEXT_PUBLIC_ inlining, and so on) already try to enforce — except it holds even when a bundler doesn't catch it.

Detecting "Server"

isServer defaults to typeof window === "undefined". Override it for contexts where that default doesn't hold, such as some edge runtimes or custom SSR setups:

createEnvironment({
  server: { DATABASE_URL: z.string().url() },
  runtimeEnv: process.env,
  isServer: process.env["RUNTIME"] === "edge-with-server-access",
});

Custom Access Handling

Pass onInvalidAccess to run your own logic instead of throwing EnvironmentAccessError — for example, to log the offending key before crashing, or to integrate with an error-reporting service. It must throw or otherwise never return:

createEnvironment({
  server: { DATABASE_URL: z.string().url() },
  runtimeEnv: process.env,
  onInvalidAccess: (key) => {
    reportError(`Server-only env var "${key}" was read from the client.`);
    throw new Error(`Blocked client access to "${key}".`);
  },
});

See Errors for the full error and callback reference.

See Also

  • extends — each composed source carries its own clientPrefix
  • ErrorsEnvironmentError and EnvironmentAccessError in detail