Client vs. server
Keep public env keys out of private secrets and off the browser bundle.
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
| Framework | Client-visible prefix |
|---|---|
| Next.js | NEXT_PUBLIC_* |
| Nuxt | NUXT_PUBLIC_* |
| Vite | VITE_* |
| Bun (bundler) | BUN_PUBLIC_* |
Keys without the prefix stay server-only. NODE_ENV is treated as shared on Next.js.
Flat layout (recommended)
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.
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.
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 bundle | Variable name + type in client | |
|---|---|---|
| Flat layout | Blocked | Visible (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.
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.
import arkenv from "@/.arkenv";
export const env = arkenv({
NEXT_PUBLIC_API_URL: "string = 'https://api.example.com'",
NODE_ENV: "'development' | 'production' | 'test' = 'development'",
});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.
import arkenv from "@arkenv/nuxt";
export const env = arkenv({
NUXT_PUBLIC_API_URL: "string = 'https://api.example.com'",
});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 condition | Next.js context | Accessible keys |
|---|---|---|
react-server | Server Components and routes | All schema keys |
default | Client 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:
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.