Client vs. server

Keep public env keys out of private secrets and off the browser bundle.

Edit on GitHub

Browser bundles and server processes do not share the same trust boundary. A secret that reaches the client is gone: anyone can read it from the network tab or a published chunk. That is why frameworks invented public prefixes (NEXT_PUBLIC_*, VITE_*, and friends), and why env libraries treat "which keys may ship to the browser" as a first-class problem.

ArkEnv validates that split. The core arkenv() API stays a flat schema call; the Next.js, Nuxt, Vite, and Bun packages enforce prefixes, strip server values from client graphs, and throw when application code reaches across the boundary. You still write one schema style. The integration owns the fence.

Public keys belong in the client-visible set. Secrets stay server-only. Mixing them is how DATABASE_URL ends up in a browser bundle.

ArkEnv treats a server secret read from client code as a hard failure. The runtime proxy throws. Do not soften that into a warning.

Public prefixes

FrameworkClient-visible prefix
Next.jsNEXT_PUBLIC_*
NuxtNUXT_PUBLIC_*
ViteVITE_*
Bun (bundler)BUN_PUBLIC_*

Keys without the prefix stay server-only. NODE_ENV is treated as shared on Next.js.

Flat layout uses one env.ts. Prefixes decide which keys reach the client. This is the default when you run the CLI, and the right choice for most apps.

./env.ts
import arkenv from "@/.arkenv";

export const env = arkenv({
  DATABASE_URL: "string",
  NEXT_PUBLIC_API_URL: "string = 'https://api.example.com'",
  NODE_ENV: "'development' | 'production' | 'test' = 'development'",
});

Wire the Next.js config plugin so codegen and build-time exclusion run. The generated client factory lives in gitignored .arkenv/, imported as @/.arkenv.

./next.config.ts
import type { NextConfig } from "next";
import { withArkEnv } from "@arkenv/nextjs/config";

const nextConfig: NextConfig = {};

export default withArkEnv(nextConfig);

On Nuxt, import from @arkenv/nuxt and register @arkenv/nuxt/module. Vite and Bun plugins read the same schema and only inline public keys into the client.

What flat layout protects

Secret value in client bundleVariable name + type in client
Flat layoutBlockedVisible (schema ships with client types)

Values stay off the wire. Flat layout still types the full schema in one file, so TypeScript may autocomplete server keys in Client Components and the names and types of those keys can appear in the client type graph. A runtime proxy throws if you read a server key on the client.

@t3-oss/env-nextjs uses the same single-file pattern for DX. When you also need names and types out of the client graph, use the two-module recipe below — the same shape T3 Env uses with two createEnv calls.

Advanced: two-module recipe

Most apps never need this. Use it only when secret names or types reveal infrastructure you refuse to ship in client types (internal hostnames, vendor-shaped keys, and similar).

Split into two modules and two imports. The client module holds public keys. The server module holds secrets and optionally extends the client env so server code sees one object.

client.ts
server.ts

Next.js

Client module via codegen (@/.arkenv). Server module via @arkenv/core plus optional import "server-only" so Next fails the build if that file enters a client graph.

client.ts
server.ts
./env/client.ts
import arkenv from "@/.arkenv";

export const env = arkenv({
  NEXT_PUBLIC_API_URL: "string = 'https://api.example.com'",
  NODE_ENV: "'development' | 'production' | 'test' = 'development'",
});
./env/server.ts
import "server-only";
import arkenv from "@arkenv/core";
import { env as clientEnv } from "./client";

export const env = arkenv(
  {
    DATABASE_URL: "string",
  },
  { extends: [clientEnv] },
);

Import from ./env/client in Client Components and from ./env/server on the server. Point withArkEnv / your schema path at the client module (or keep a flat root env.ts if you do not need this split).

Nuxt

In Nuxt, place the server module inside server/utils/env.ts. Nitro automatically auto-imports env across server routes (server/api/*), while Nuxt's build pipeline ensures server/ files are never bundled into the client Vue application.

client.ts
env.ts
./env/client.ts
import arkenv from "@arkenv/nuxt";

export const env = arkenv({
  NUXT_PUBLIC_API_URL: "string = 'https://api.example.com'",
});
./server/utils/env.ts
import arkenv from "@arkenv/core";
import { env as clientEnv } from "~~/env/client";

// Auto-imported by Nitro across server/api/* and server/routes/*
export const env = arkenv(
  {
    DATABASE_URL: "string",
  },
  { extends: [clientEnv] },
);

Point @arkenv/nuxt/module at your client schema (./env/client.ts), and import { env } from "~/env/client" inside Vue components.

Next.js runtime protection

@arkenv/nextjs uses package export conditions plus a runtime proxy:

Export conditionNext.js contextAccessible keys
react-serverServer Components and routesAll schema keys
defaultClient Components (SSR/browser)Public and shared keys

Reading a server-only key from client code throws. The two-module recipe can also use import "server-only" so the server schema cannot enter the client graph at compile time.

Public keys (NEXT_PUBLIC_*, NUXT_PUBLIC_*, and the other prefixes) are meant to be readable everywhere, including on the server. The proxy only blocks the reverse: a server-only key on the client.

Next.js withArkEnv generates the client runtimeEnv mapping in env.gen.ts. You do not maintain that map by hand unless you pass { codegen: false } — then you own runtimeEnv and must keep it in sync. Details: @arkenv/nextjs reference.

Why is only NODE_ENV shared besides NEXT_PUBLIC_*? Next.js inlines process.env.NODE_ENV on both sides. Other non-prefixed keys are stripped from the client bundle. Keep non-public keys off exposeToClient or the server and client can disagree after hydration. See the Next.js env docs.

Dead-code elimination and constant folding

Bundlers (like Next.js, Vite, and Bun) replace direct identifier expressions such as process.env.NEXT_PUBLIC_FEATURE_FLAG or import.meta.env.VITE_FEATURE_FLAG with literal strings during compilation. If a condition evaluates to if (false) or if (""), minifiers drop the dead branch and omit modules imported inside it.

Accessing properties on an imported env object (if (env.NEXT_PUBLIC_FEATURE_FLAG)) behaves as normal JavaScript object property access at runtime:

./src/feature.ts
import { env } from "./env";

if (env.NEXT_PUBLIC_ADMIN_PREVIEW) {
  mountAdminPreview();
}

Because env is an exported object reference, minifiers cannot guarantee that properties remain immutable across module boundaries without whole-program analysis. Code inside the if block may remain in the client bundle even when the flag is off.

If you have client-only chunks or admin tools that must stay out of production bundles when a flag is off, use direct bundler identifiers (such as process.env.NEXT_PUBLIC_FEATURE_FLAG or import.meta.env.VITE_FEATURE_FLAG) in the condition, or load modules with dynamic import(). Use env for Typesafe runtime validation across your application.

Next steps