# Community (/docs/community) ArkEnv is an open-source project for typesafe environment variable validation in JavaScript and TypeScript. Here's how you can **get involved**. ## Contributing [#contributing] You can help by improving the docs, examples, or the libraries themselves. * [Documentation](https://github.com/yamcodes/arkenv/tree/v1/apps/www/content/docs): Suggest improvements or write new sections. * [Examples](https://github.com/yamcodes/arkenv/tree/v1/examples): Help others wire ArkEnv into a stack by improving an example. * [Code](https://github.com/yamcodes/arkenv/blob/v1/docs/CONTRIBUTING.md): Bug fixes, framework integrations, and new features. Human and agent-assisted contributions are equally welcome. A **human maintainer reviews every pull request** before it lands. ## Discussions [#discussions] If you have a question about ArkEnv or want to help others, join the conversation: * [GitHub Discussions](https://github.com/yamcodes/arkenv/discussions) * [GitHub Issues](https://github.com/yamcodes/arkenv/issues) for bugs and feature requests * [X](https://x.com/_yamcodes) ## Acknowledgements [#acknowledgements] ArkEnv exists thanks to the people and projects that inspired it. See [Acknowledgements](https://github.com/yamcodes/arkenv/blob/v1/docs/ACKNOWLEDGEMENTS.md). # Client vs. server (/docs/core-concepts/client-vs-server) 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 [#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-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. ```ts title="./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'", }); ``` ```ts title="./env.ts" import arkenv from "@/.arkenv"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), NEXT_PUBLIC_API_URL: z.url().default("https://api.example.com"), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); ``` ```ts title="./env.ts" import arkenv from "@/.arkenv"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), NEXT_PUBLIC_API_URL: v.optional( v.pipe(v.string(), v.url()), "https://api.example.com", ), NODE_ENV: v.optional( v.picklist(["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`. ```ts title="./next.config.ts" import type { NextConfig } from "next"; import { withArkEnv } from "@arkenv/nextjs/config"; const nextConfig: NextConfig = {}; export default withArkEnv(nextConfig); ``` Import `withArkEnv` from `@arkenv/nextjs/standard/config`. Codegen emits the Standard Schema factory only from that entry (or when the schema file text contains `@arkenv/standard`). The ArkType import above cannot parse `z.url()` or `v.pipe(...)`. On Nuxt, ArkType imports from `@arkenv/nuxt` and registers `@arkenv/nuxt/module`. Zod and Valibot import from `@arkenv/nuxt/standard` and register `@arkenv/nuxt/standard/module`. Vite and Bun plugins read the same schema and only inline public keys into the client. ### What flat layout protects [#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`](https://env.t3.gg) 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 [#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 [#nextjs] Client module via codegen (`@/.arkenv`). Server module via `@arkenv/core` (ArkType), `@arkenv/standard` (Zod), or `@arkenv/standard/valibot` (Valibot), plus optional `import "server-only"` so Next fails the build if that file enters a client graph. ```ts title="./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'", }); ``` ```ts title="./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] }, ); ``` ```ts title="./env/client.ts" import arkenv from "@/.arkenv"; import * as z from "zod"; export const env = arkenv({ NEXT_PUBLIC_API_URL: z.url().default("https://api.example.com"), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); ``` ```ts title="./env/server.ts" import "server-only"; import arkenv from "@arkenv/standard"; import * as z from "zod"; import { env as clientEnv } from "./client"; export const env = arkenv( { DATABASE_URL: z.url() }, { extends: [clientEnv] }, ); ``` ```ts title="./env/client.ts" import arkenv from "@/.arkenv"; import * as v from "valibot"; export const env = arkenv({ NEXT_PUBLIC_API_URL: v.optional( v.pipe(v.string(), v.url()), "https://api.example.com", ), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), }); ``` ```ts title="./env/server.ts" import "server-only"; import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; import { env as clientEnv } from "./client"; export const env = arkenv( { DATABASE_URL: v.pipe(v.string(), v.url()) }, { 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 [#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. ```ts title="./env/client.ts" import arkenv from "@arkenv/nuxt"; export const env = arkenv({ NUXT_PUBLIC_API_URL: "string = 'https://api.example.com'", }); ``` ```ts title="./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] }, ); ``` ```ts title="./env/client.ts" import arkenv from "@arkenv/nuxt/standard"; import * as z from "zod"; export const env = arkenv({ NUXT_PUBLIC_API_URL: z.url().default("https://api.example.com"), }); ``` ```ts title="./server/utils/env.ts" import arkenv from "@arkenv/standard"; import * as z from "zod"; import { env as clientEnv } from "~~/env/client"; // Auto-imported by Nitro across server/api/* and server/routes/* export const env = arkenv( { DATABASE_URL: z.url() }, { extends: [clientEnv] }, ); ``` ```ts title="./env/client.ts" import arkenv from "@arkenv/nuxt/standard"; import * as v from "valibot"; export const env = arkenv({ NUXT_PUBLIC_API_URL: v.optional( v.pipe(v.string(), v.url()), "https://api.example.com", ), }); ``` ```ts title="./server/utils/env.ts" import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; import { env as clientEnv } from "~~/env/client"; // Auto-imported by Nitro across server/api/* and server/routes/* export const env = arkenv( { DATABASE_URL: v.pipe(v.string(), v.url()) }, { extends: [clientEnv] }, ); ``` Point `@arkenv/nuxt/module` (or `@arkenv/nuxt/standard/module`) at your client schema (`./env/client.ts`), and import `{ env } from "~/env/client"` inside Vue components. ## Next.js runtime protection [#nextjs-runtime-protection] `@arkenv/nextjs` and `@arkenv/nextjs/standard` use the same 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](/docs/reference/nextjs). 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](https://nextjs.org/docs/app/guides/environment-variables). ## Dead-code elimination and constant folding [#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: ```ts title="./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 [#next-steps] # Coercion and parsing (/docs/core-concepts/coercion-and-parsing) Environment variables arrive as strings. ArkEnv coerces those strings toward the types your schema declares so application code can treat `PORT` as a `number` and `DEBUG` as a `boolean`. The same `coerce` option and pre-coercion pipeline apply to [`@arkenv/core`](/docs/reference/core) and [`@arkenv/standard`](/docs/reference/standard), including Zod and Valibot. ## Defaults [#defaults] Coercion is **on** by default (`coerce: true`). Zod 4.2+ uses `z.number()` and `z.boolean()`, and Valibot uses `v.number()` and `v.boolean()`; ArkEnv converts `"8080"` and `"true"` before the schema runs. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { PORT: "number.port", DEBUG: "boolean = false", TAGS: "string[]", }, { env: { PORT: "8080", DEBUG: "true", TAGS: "web, api", }, }, ); env.PORT; // number env.DEBUG; // boolean env.TAGS; // string[] ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv( { PORT: z.int().min(0).max(65535), DEBUG: z.boolean().default(false), TAGS: z.array(z.string()), }, { env: { PORT: "8080", DEBUG: "true", TAGS: "web, api", }, }, ); env.PORT; // number env.DEBUG; // boolean env.TAGS; // string[] ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv( { PORT: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), DEBUG: v.optional(v.boolean(), false), TAGS: v.array(v.string()), }, { env: { PORT: "8080", DEBUG: "true", TAGS: "web, api", }, }, ); env.PORT; // number env.DEBUG; // boolean env.TAGS; // string[] ``` ## Numbers [#numbers] `PORT=3000` arrives as the string `"3000"`. Parsing it here is what makes `port + 1` equal `3001`. Any field typed as `number` or a numeric subtype (including ArkType [`number.port`](/docs/reference/keywords), Zod `z.int()`, or Valibot `v.number()`) is parsed from the string form: ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { PORT: "number.port", RETRY_COUNT: "number.integer = 3", AGE: "0 <= number.integer <= 120", }, { env: { PORT: "8080", RETRY_COUNT: "5", AGE: "30", }, }, ); env.PORT; // ^? ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv( { PORT: z.int().min(0).max(65535), RETRY_COUNT: z.int().default(3), AGE: z.int().min(0).max(120), }, { env: { PORT: "8080", RETRY_COUNT: "5", AGE: "30", }, }, ); env.PORT; // number ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv( { PORT: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), RETRY_COUNT: v.optional(v.pipe(v.number(), v.integer()), 3), AGE: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(120)), }, { env: { PORT: "8080", RETRY_COUNT: "5", AGE: "30", }, }, ); env.PORT; // number ``` ## Booleans [#booleans] `boolean` accepts the lowercase strings `"true"` and `"false"` only. Values like `"0"`, `"1"`, `"True"`, `"YES"`, or `"on"` are **not** coerced and fail validation. The same rule applies to ArkType `boolean`, Zod `z.boolean()`, and Valibot `v.boolean()`. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { DEBUG: "boolean = false" }, { env: { DEBUG: "true" } }, ); env.DEBUG; // ^? ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv( { DEBUG: z.boolean().default(false) }, { env: { DEBUG: "true" } }, ); env.DEBUG; // boolean ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv( { DEBUG: v.optional(v.boolean(), false) }, { env: { DEBUG: "true" } }, ); env.DEBUG; // boolean ``` Need a wider truthy set? Normalize with a [transform](#transforms) (an ArkType morph, Zod `.transform`, or Valibot `v.transform`), or keep the field as `string` and map it in application code. ## Arrays [#arrays] A list in an env file is still one string. Splitting on commas is how `TAGS=web, api` becomes `["web", "api"]`. By default ArkEnv splits on commas and trims each entry: ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { TAGS: "string[]", PORTS: "number[]", }, { env: { TAGS: "web, app, api", PORTS: "3000, 8080", }, }, ); env.TAGS; // ^? env.PORTS; // ^? ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv( { TAGS: z.array(z.string()), PORTS: z.array(z.number()), }, { env: { TAGS: "web, app, api", PORTS: "3000, 8080", }, }, ); env.TAGS; // string[] env.PORTS; // number[] ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv( { TAGS: v.array(v.string()), PORTS: v.array(v.number()), }, { env: { TAGS: "web, app, api", PORTS: "3000, 8080", }, }, ); env.TAGS; // string[] env.PORTS; // number[] ``` Switch to JSON arrays with `arrayFormat: "json"`: ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { TAGS: "string[]" }, { arrayFormat: "json", env: { TAGS: '["web", "app"]' }, }, ); env.TAGS; // ^? ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv( { TAGS: z.array(z.string()) }, { arrayFormat: "json", env: { TAGS: '["web", "app"]' }, }, ); env.TAGS; // string[] ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv( { TAGS: v.array(v.string()) }, { arrayFormat: "json", env: { TAGS: '["web", "app"]' }, }, ); env.TAGS; // string[] ``` ## Objects [#objects] A nested config has to fit in one environment variable, so the usual shape is a JSON string. ArkEnv parses that string, then coerces nested fields the same way it coerces top-level ones. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { DATABASE: { HOST: "string", PORT: "number", }, }, { env: { DATABASE: '{"HOST": "localhost", "PORT": "5432"}', }, }, ); env.DATABASE; // ^? ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv( { DATABASE: z.object({ HOST: z.string(), PORT: z.number(), }), }, { env: { DATABASE: '{"HOST": "localhost", "PORT": "5432"}', }, }, ); env.DATABASE; // { HOST: string; PORT: number } ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv( { DATABASE: v.object({ HOST: v.string(), PORT: v.number(), }), }, { env: { DATABASE: '{"HOST": "localhost", "PORT": "5432"}', }, }, ); env.DATABASE; // { HOST: string; PORT: number } ``` ## Pre-coercion model [#pre-coercion-model] ArkEnv does not wrap your schema in a coercion pipe. It uses a shared pre-coercion pipeline for both engines: 1. **Introspect** the schema's JSON Schema (ArkType `.in.toJsonSchema`, or Standard Schema metadata / optional [`toJsonSchema`](#valibot-tojsonschema) fallback) 2. **Find paths** that need conversion (numbers, booleans, arrays, objects) 3. **Copy** the input record and coerce only those paths on the copy 4. **Validate** the copy with the original, unmodified schema `process.env` is never mutated. Schema transforms and refinements still see the coerced values. Automatic coercion with `@arkenv/standard` needs [Standard JSON Schema v1](https://standardschema.dev/json-schema) on the value **or** a converter. Those specs are orthogonal to Standard Schema validation. Zod 4.2+ and VineJS 4.3+ put the converter on the schema. Valibot and Zod Mini use first-class subpaths (`@arkenv/standard/valibot`, `@arkenv/standard/zod-mini`). Zod v3 typically uses `zod-to-json-schema` through the [`toJsonSchema`](#valibot-tojsonschema) escape hatch. See [Valibot](/docs/validators/valibot) and [Zod](/docs/validators/zod). ## Transforms [#transforms] A transform reshapes a value after it is a valid input: trim a string, clamp a number, map an enum. Schema libraries call these transforms, morphs, or pipes. ArkEnv runs them as part of parsing. `PORT` still uses built-in coercion, not a custom pipe: ```ts title="./env.ts" twoslash import arkenv, { type } from "@arkenv/core"; export const env = arkenv( { BUILD_ID: type("string").pipe((value) => value.trim().toUpperCase()), PORT: "number.port = 3000", }, { env: { BUILD_ID: " abc " } }, ); env.BUILD_ID; // "ABC" ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv( { BUILD_ID: z .string() .transform((value) => value.trim().toUpperCase()), PORT: z.int().min(0).max(65535).default(3000), }, { env: { BUILD_ID: " abc " } }, ); env.BUILD_ID; // "ABC" ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv( { BUILD_ID: v.pipe( v.string(), v.transform((value) => value.trim().toUpperCase()), ), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), }, { env: { BUILD_ID: " abc " } }, ); env.BUILD_ID; // "ABC" ``` Coercion can feed a transformation. The string `"3"` becomes a number before your pipe runs: ```ts title="./env.ts" twoslash import arkenv, { type } from "@arkenv/core"; export const env = arkenv( { RETRY_COUNT: type("number").pipe((n) => Math.max(0, n)), }, { env: { RETRY_COUNT: "3" } }, ); env.RETRY_COUNT; // 3 ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv( { RETRY_COUNT: z.number().transform((n) => Math.max(0, n)), }, { env: { RETRY_COUNT: "3" } }, ); env.RETRY_COUNT; // 3 ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv( { RETRY_COUNT: v.pipe( v.number(), v.transform((n) => Math.max(0, n)), ), }, { env: { RETRY_COUNT: "3" } }, ); env.RETRY_COUNT; // 3 ``` | Need | Prefer | | ----------------------------------------- | ---------------------------------------------- | | `"3000"` → `number`, `"true"` → `boolean` | Coercion (on by default) | | Trim, normalize, clamp, map enums | Transform on the field schema | | Multi-step pipelines | ArkType `.pipe` / Zod `.pipe` / Valibot `pipe` | ## Valibot (`toJsonSchema`) [#valibot-tojsonschema] Prefer `@arkenv/standard/valibot` so `@valibot/to-json-schema` is bound for you (`typeMode: "input"`, `target: "draft-07"`): ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ PORT: v.number(), DEBUG: v.boolean() }); ``` Install `@valibot/to-json-schema` yourself; it is an optional peer of `@arkenv/standard`. Recipe: [Valibot](/docs/validators/valibot). The optional `toJsonSchema` callback on root `@arkenv/standard` remains the escape hatch for custom converters. ArkEnv calls it when it can't read JSON Schema from that key: ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as v from "valibot"; import { toJsonSchema } from "@valibot/to-json-schema"; export const env = arkenv( { PORT: v.number(), DEBUG: v.boolean() }, { toJsonSchema: (schema) => toJsonSchema(schema as v.GenericSchema, { typeMode: "input", target: "draft-07", }), }, ); ``` * `target: "draft-07"` matches the JSON Schema draft ArkEnv expects. * `typeMode: "input"` coerces env strings toward the schema's input type. A bare `{ toJsonSchema }` function reference uses Valibot's default `typeMode: "ignore"`. * Valibot's converter does not accept Standard Schema, so assert `as v.GenericSchema` at the call. ### Zod v3 [#zod-v3] Same escape hatch: Zod v3 validates via Standard Schema but omits Standard JSON Schema on the value. Convert with [`zod-to-json-schema`](https://github.com/StefanTerdell/zod-to-json-schema) (works with `zod@3` or Zod 4's `zod/v3` export): ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import { z } from "zod/v3"; import { zodToJsonSchema } from "zod-to-json-schema"; export const env = arkenv( { PORT: z.number(), DEBUG: z.boolean() }, { toJsonSchema: (schema) => zodToJsonSchema(schema as z.ZodTypeAny, { $refStrategy: "none", }), }, ); ``` Recipe: [Zod](/docs/validators/zod#zod-v3). ### Mixing with Zod Mini [#mixing-with-zod-mini] Prefer `@arkenv/standard/zod-mini` for Mini-only maps. Mini has no `~standard.jsonSchema` and no instance `.toJSONSchema()`. When you mix Valibot and Mini on root `@arkenv/standard`, switch on `schema["~standard"].vendor` and return `undefined` for anything else. Mini reports vendor `"zod"`. Classic Zod keys never reach the callback at runtime. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import { toJsonSchema } from "@valibot/to-json-schema"; import * as v from "valibot"; import * as z from "zod/mini"; export const env = arkenv( { PORT: v.number(), DEBUG: z.boolean(), }, { toJsonSchema: (schema) => { switch (schema["~standard"].vendor) { case "valibot": return toJsonSchema(schema as v.GenericSchema, { typeMode: "input", target: "draft-07", }); case "zod": return z.toJSONSchema(schema as z.ZodMiniType, { io: "input", target: "draft-07", }); default: return undefined; } }, }, ); ``` ## Disabling coercion [#disabling-coercion] Turn the pipeline off when a field must stay a string, or when you want the schema library to parse it. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { PORT: "string" }, { coerce: false, env: { PORT: "3000" } }, ); env.PORT; // string ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv( { PORT: z.string() }, { coerce: false, env: { PORT: "3000" } }, ); env.PORT; // string ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv( { PORT: v.string() }, { coerce: false, env: { PORT: "3000" } }, ); env.PORT; // string ``` With `coerce: false`, a numeric schema rejects `"3000"` unless you transform the string yourself (or use a helper such as `z.coerce.number()` or Valibot `v.pipe(v.string(), v.transform(Number))`). ## Next steps [#next-steps] # Defining your schema (/docs/core-concepts/defining-your-schema) ArkEnv is designed around one schema file and one typed `env` object. ArkEnv proves the process has the configuration it claims before application code runs, whether you use ArkType, Zod, or Valibot. ## Fail fast [#fail-fast] Calling `arkenv()` parses the configured source immediately. Missing required keys or values that fail the schema throw [`ArkEnvError`](/docs/core-concepts/error-reporting) at the call site. | Moment | What runs | | -------------------- | --------------------------------------------------------------------------------------------------- | | Module load | `arkenv()` validates the current `env` source (default `process.env`) | | Before the entry | Optional: `arkenv check`, or Nub `preload` — see [Using ArkEnv with Nub](/docs/guides/use-with-nub) | | Next.js / Nuxt build | Framework wrappers validate during config or module setup | | Vite / Bun build | Plugins validate in the bundler pipeline | | Client bundle | Public keys are already validated at build time; server secrets are stripped or guarded | Framework adapters validate in builds by default. Only keys you declare appear on the returned object (`onUndeclaredKey` defaults to `"delete"`). `env` is a closed set of known fields, not leftover process variables. ## Where `env.ts` lives [#where-envts-lives] Install either engine, then create `env.ts` next to the code that boots first. Root of the app or package is common; `src/env.ts` is fine when the rest of your entrypoints already live under `src/`. npm pnpm yarn bun nub ```bash npm install @arkenv/core arktype ``` ```bash pnpm add @arkenv/core arktype ``` ```bash yarn add @arkenv/core arktype ``` ```bash bun install @arkenv/core arktype ``` ```bash nub add @arkenv/core arktype ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ HOST: "string.host = 'localhost'", PORT: "number.port = 3000", NODE_ENV: "'development' | 'production' | 'test' = 'development'", }); ``` npm pnpm yarn bun nub ```bash npm install @arkenv/standard zod ``` ```bash pnpm add @arkenv/standard zod ``` ```bash yarn add @arkenv/standard zod ``` ```bash bun install @arkenv/standard zod ``` ```bash nub add @arkenv/standard zod ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ HOST: z.string().default("localhost"), PORT: z.int().min(0).max(65535).default(3000), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); ``` npm pnpm yarn bun nub ```bash npm install @arkenv/standard valibot @valibot/to-json-schema ``` ```bash pnpm add @arkenv/standard valibot @valibot/to-json-schema ``` ```bash yarn add @arkenv/standard valibot @valibot/to-json-schema ``` ```bash bun install @arkenv/standard valibot @valibot/to-json-schema ``` ```bash nub add @arkenv/standard valibot @valibot/to-json-schema ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ HOST: v.optional(v.string(), "localhost"), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), }); ``` Import that object from application code: ```ts title="./src/server.ts" import { env } from "../env"; console.log(`listening on ${env.HOST}:${env.PORT}`); ``` Do not read `process.env` or `import.meta.env` directly Reading `process.env` or `import.meta.env` skips ArkEnv. Import `{ env }` so public keys stay typed and coerced and server secrets stay out of the client bundle. That surface is canonical across Node, Next.js, Nuxt, Vite, and Bun. ArkType implements Standard Schema, but if you already depend on ArkType stay on `@arkenv/core` rather than wrapping it through `@arkenv/standard`. See [Validators](/docs/validators). ## What belongs in the schema [#what-belongs-in-the-schema] Put every variable your process reads into the schema: secrets, public keys, tunables, and platform vars you rely on. * Required secrets (`DATABASE_URL`, `API_KEY`) * Tunables with defaults (`PORT`, `LOG_LEVEL`) * Framework public keys (`NEXT_PUBLIC_*`, `VITE_*`, `NUXT_PUBLIC_*`, `BUN_PUBLIC_*`) * Hosting provider system variables (`VERCEL_ENV`, `CONTEXT`, …) when you use them The [ArkEnv CLI](/docs/getting-started/installation) and [hosting presets](/docs/core-concepts/hosting-presets) can inject the provider fields for you. You can still declare them by hand. A single `env.ts` is the default layout for Node apps and [flat layout](/docs/core-concepts/client-vs-server#flat-layout-recommended) frameworks: Keep `.env.example` in sync with the schema so new contributors know which keys to set. ## Strings, numbers, booleans, enums [#strings-numbers-booleans-enums] Your schema is a map from env key names to validators. `@arkenv/core` uses the ArkType DSL (and ArkEnv keywords). `@arkenv/standard` accepts Zod, Valibot, and other [Standard Schema](https://standardschema.dev/) validators. Pick one stack per project; both paths produce a typed `env` object. [Coercion and parsing](/docs/core-concepts/coercion-and-parsing) covers how strings become numbers and booleans. This section is about declaring the shape. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ API_URL: "string", APP_NAME: "string = 'My App'", OPTIONAL_NOTE: "string | undefined", PORT: "number.port = 3000", RETRY_COUNT: "number.integer = 3", DEBUG: "boolean = false", NODE_ENV: "'development' | 'production' | 'test' = 'development'", LOG_LEVEL: "'debug' | 'info' | 'warn' | 'error' = 'info'", }); ``` A bare `string` requires the key. Append `= 'value'` for a default. `string | undefined` accepts a missing key. An empty string (`NOTE=`) still counts as present unless you set [`emptyAsUndefined`](/docs/reference/options). Empty collections are defaults in the same string. ArkType 2.2.5 accepts `= []` and `= {}`, and each missing value gets a fresh array or object: ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ FEATURE_FLAGS: "string[] = []", METADATA: "object = {}", }); ``` On ArkType older than 2.2.5, that string fails to parse. Use `type("string[]").default(() => [])` there. Non-empty lists still need that function form, because `= ["localhost"]` is not valid string syntax. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ API_URL: z.string(), APP_NAME: z.string().default("My App"), OPTIONAL_NOTE: z.string().optional(), PORT: z.int().min(0).max(65535).default(3000), RETRY_COUNT: z.int().default(3), DEBUG: z.boolean().default(false), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), }); ``` Package: [`@arkenv/standard`](/docs/reference/standard). ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ API_URL: v.pipe(v.string(), v.url()), APP_NAME: v.optional(v.string(), "My App"), OPTIONAL_NOTE: v.optional(v.string()), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), RETRY_COUNT: v.optional(v.pipe(v.number(), v.integer()), 3), DEBUG: v.optional(v.boolean(), false), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), LOG_LEVEL: v.optional( v.picklist(["debug", "info", "warn", "error"]), "info", ), }); ``` A bare schema requires the key. `v.optional(schema, fallback)` supplies a default when the key is missing. `v.optional(schema)` accepts a missing key. An empty string (`NOTE=`) still counts as present unless you set [`emptyAsUndefined`](/docs/reference/options). Empty collections are defaults on the same field. Each missing value gets the fallback you pass: ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ FEATURE_FLAGS: v.optional(v.array(v.string()), []), METADATA: v.optional(v.record(v.string(), v.unknown()), {}), }); ``` Import from [`@arkenv/standard/valibot`](/docs/validators/valibot) so coercion is wired. The root `@arkenv/standard` import does not bind Valibot's JSON Schema converter. ## ArkEnv keywords (ArkType) [#arkenv-keywords-arktype] `@arkenv/core` adds env-oriented keywords on top of [ArkType's primitives](https://arktype.io/docs/primitives). They are not available on `@arkenv/standard`. | Keyword | Meaning | | ------------- | ------------------------------ | | `string.host` | An IP address or `"localhost"` | | `number.port` | An integer from `0` to `65535` | ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ HOST: "string.host = 'localhost'", PORT: "number.port = 3000", }); ``` Full details: [keywords](/docs/reference/keywords). ## Typesafety [#typesafety] The schema is the single source of truth for runtime checks and TypeScript types. ```ts title="./env.ts" twoslash import arkenv, { type Infer } from "@arkenv/core"; const schema = { DATABASE_URL: "string", PORT: "number.port = 3000", } as const; export const env = arkenv(schema); export type Env = Infer; env.PORT; // number ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; const schema = { DATABASE_URL: z.url(), PORT: z.int().min(0).max(65535).default(3000), }; export const env = arkenv(schema); export type Env = typeof env; env.PORT; // number ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; const schema = { DATABASE_URL: v.pipe(v.string(), v.url()), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), }; export const env = arkenv(schema); export type Env = typeof env; env.PORT; // number ``` | Input | Result | | ---------------------------------------------- | ------------------------------------------------------------------------- | | ArkType declarative map (`{ PORT: "number" }`) | `Infer` from `@arkenv/core` | | Compiled `type({ ... })` | `typeof Env.infer` / `Infer` | | Standard Schema field map (Zod, Valibot, …) | `typeof env` after `arkenv(schema)`, or each validator's own infer helper | Typesafety holds when application code imports `env` and validation runs at boot or build. Casting `process.env`, or skipping the import, bypasses it. Types do not load `.env` files. Flat layouts may expose server **key names** to client TypeScript while blocking values. Strict layouts keep server modules out of the client graph. See [Client vs. server](/docs/core-concepts/client-vs-server). ## Richer field definitions [#richer-field-definitions] For transforms, function defaults, or reuse across files: ```ts title="./env.ts" twoslash import arkenv, { type } from "@arkenv/core"; export const env = arkenv({ PORT: "number.port = 3000", ALLOWED_HOSTS: type("string[]").default(() => ["localhost"]), BUILD_ID: type("string").pipe((value) => value.trim()), }); ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ PORT: z.int().min(0).max(65535).default(3000), BUILD_ID: z.string().transform((value) => value.trim()), }); ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), BUILD_ID: v.pipe( v.string(), v.transform((value) => value.trim()), ), }); ``` See [Transforms](/docs/core-concepts/coercion-and-parsing#transforms) for ArkType morphs, Zod `.transform`, and Valibot `v.pipe`, and [reusing schemas](/docs/core-concepts/reusing-schemas) when the whole schema should be shared. ## Custom env source [#custom-env-source] By default `arkenv()` reads `process.env`. Pass a custom record for tests, Workers bindings, or any host that injects env outside `process.env`: ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { PORT: "number.port" }, { env: { PORT: "3000" } }, ); ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv( { PORT: z.int().min(0).max(65535) }, { env: { PORT: "3000" } }, ); ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv( { PORT: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), }, { env: { PORT: "3000" } }, ); ``` Framework plugins (Next.js, Nuxt, Vite, Bun) wire the usual sources for you. See [options](/docs/reference/options) for `coerce`, `arrayFormat`, and `emptyAsUndefined`. ## What ArkEnv owns vs your schema library [#what-arkenv-owns-vs-your-schema-library] ArkEnv loads the input record, optionally [coerces](/docs/core-concepts/coercion-and-parsing) strings, then hands the result to your schema library. Field rules always come from that library: | Package | You supply | Shared with both engines | | ---------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------- | | [`@arkenv/core`](/docs/reference/core) | ArkType DSL strings or `type()` definitions | `arkenv()` options, `ArkEnvError`, fail-fast boot, framework plugins | | [`@arkenv/standard`](/docs/reference/standard) | A map of Standard Schema validators (Zod, Valibot, …) | Same, plus `toJsonSchema` | See [Validators](/docs/validators) for packaging and peer-dependency differences. ## Next steps [#next-steps] # Error reporting (/docs/core-concepts/error-reporting) ArkEnv throws at the call site when a required variable is missing or a value fails the schema. Bad config stays out of running processes, builds, and plugin runs. ## What you see in the console [#what-you-see-in-the-console] Invalid input raises `ArkEnvError`. The message lists every failing path: ```txt Errors found while validating environment variables PORT must be an integer between 0 and 65535 (was "abc") DATABASE_URL is required ``` Paths are highlighted; the header is red when ANSI color is available. Most apps let that throw stop the process. You do not need a `try/catch` around every `arkenv()` call. Catch only when you want to inspect `ArkEnvError` yourself (custom boot UI, tests, or a wrapper): ```ts title="./boot.ts" import arkenv, { ArkEnvError } from "@arkenv/core"; try { arkenv( { PORT: "number.port", DATABASE_URL: "string", }, { env: { PORT: "abc" } }, ); } catch (error) { if (error instanceof ArkEnvError) { console.error(error.message); console.error(error.issues); } throw error; } ``` ## Issue codes [#issue-codes] Each entry in `error.issues` includes a machine-readable `code`: | Code | Typical cause | | ------------------------------------- | --------------------------------------------- | | `MISSING_VARIABLE` | Required key absent | | `INVALID_TYPE` | Value could not match the declared type | | `VALUE_TOO_SMALL` / `VALUE_TOO_LARGE` | Numeric or length bounds | | `PATTERN_MISMATCH` | String failed a pattern | | `INVALID_FORMAT` | Format keyword failed (for example host/port) | | `UNDECLARED_KEY` | Extra key with `onUndeclaredKey: "reject"` | | `INVALID_SCHEMA` | Schema itself is invalid | | `CUSTOM` | Validator-specific failure | Use these codes in CI scripts and health checks instead of scraping message text. ## Secret redaction [#secret-redaction] Values for sensitive keys are redacted in error output by default. Leave that alone in CI and production. `debugSecrets: true` (or `ARKENV_DEBUG_SECRETS=1`) prints the raw value. Treat it as a local escape hatch only. Do not enable secret debugging in shared logs. Redaction exists so a failed boot does not print credentials. ## Safe mode [#safe-mode] Fail-fast `arkenv()` from `@arkenv/core` (and `@arkenv/standard`) always throws. Import `arkenv` from the `/safe` subpath when you want a result object instead of a throw (tests, custom boot UI): ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core/safe"; const result = arkenv( { PORT: "number.port" }, { env: { PORT: "nope" } }, ); if (!result.success) { console.error(result.issues); } ``` `@arkenv/standard` mirrors the same shape via `@arkenv/standard/safe`. Framework plugins (Next.js, Nuxt, Vite, Bun, Rsbuild) do not ship a `/safe` subpath, so a failed validation cannot soft-fail into the client bundle — invalid env still fails the build or server start on purpose. Call `arkenv` from `@arkenv/core/safe` or `@arkenv/standard/safe` yourself if you need programmatic failure handling outside a plugin. ## Framework boundary errors [#framework-boundary-errors] Reading a server-only key from client code is a misuse, not a schema failure. The throw is a native `Error` (so Vite/Bun client modules never import `ArkEnvError`). `name` stays `"Error"`. The message is an instruction, same shape as Next.js taint copy, with ArkEnv at the end so agents can attribute it: ```txt Error: Do not access server-only key 'DATABASE_URL' on the client since it will leak sensitive data (prevented by ArkEnv) ``` Do not catch this. Fix the access. `instanceof ArkEnvError` is for schema failures only — it is `false` here because there are no `issues`. Next.js, Nuxt, Vite, and Bun each enforce the boundary (runtime proxy and/or build transforms). See [client vs. server](/docs/core-concepts/client-vs-server). ## Next steps [#next-steps] # Hosting presets (/docs/core-concepts/hosting-presets) Vercel, Netlify, Cloudflare, and the other hosts inject system variables you would otherwise copy out of their docs. ArkEnv presets write those variables into your schema, pre-typed and optional, while you scaffold. Presets work with ArkType, Zod, and Valibot on a flat `env.ts` schema. ArkEnv is code-first. A preset writes readable TypeScript into `./env.ts`. Nothing loads that preset at runtime. ## Select a preset during init [#select-a-preset-during-init] Pick the host while you scaffold. Those system variables land in the schema as optional, pre-typed fields. You can still paste them in later from the snippets below. Select the provider in the wizard, or pass `--preset`: npm pnpm yarn bun nub ```bash npx arkenv init --preset vercel ``` ```bash pnpm dlx arkenv init --preset vercel ``` ```bash yarn dlx arkenv init --preset vercel ``` ```bash bunx arkenv init --preset vercel ``` ```bash nubx arkenv init --preset vercel ``` Accepted values: `none`, `vercel`, `netlify`, `cloudflare`, `railway`, `render`, `fly`. The short form is `-P`. Passing `none` scaffolds standard schema templates without provider fields. ## Adding presets to an existing schema [#adding-presets-to-an-existing-schema] Init is not the only moment you can name a host. The snippets below are the same TypeScript the preset would have written. Paste them into an existing `./env.ts` when the project is already running. ### Vercel [#vercel] ```ts title="env.ts" import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", // Vercel system environment variables VERCEL: "string?", VERCEL_ENV: "'production' | 'preview' | 'development'?", VERCEL_URL: "string?", }); ``` ```ts title="env.ts" import arkenv from "@arkenv/standard"; import { z } from "zod"; export const env = arkenv({ DATABASE_URL: z.string(), // Vercel system environment variables VERCEL: z.string().optional(), VERCEL_ENV: z.enum(["production", "preview", "development"]).optional(), VERCEL_URL: z.string().optional(), }); ``` ```ts title="env.ts" import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.string(), // Vercel system environment variables VERCEL: v.optional(v.string()), VERCEL_ENV: v.optional(v.picklist(["production", "preview", "development"])), VERCEL_URL: v.optional(v.string()), }); ``` ### Netlify [#netlify] ```ts title="env.ts" import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", // Netlify system environment variables NETLIFY: "string?", DEPLOY_URL: "string?", CONTEXT: "'production' | 'deploy-preview' | 'branch-deploy'?", URL: "string?", }); ``` ```ts title="env.ts" import arkenv from "@arkenv/standard"; import { z } from "zod"; export const env = arkenv({ DATABASE_URL: z.string(), // Netlify system environment variables NETLIFY: z.string().optional(), DEPLOY_URL: z.string().optional(), CONTEXT: z.enum(["production", "deploy-preview", "branch-deploy"]).optional(), URL: z.string().optional(), }); ``` ```ts title="env.ts" import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.string(), // Netlify system environment variables NETLIFY: v.optional(v.string()), DEPLOY_URL: v.optional(v.string()), CONTEXT: v.optional(v.picklist(["production", "deploy-preview", "branch-deploy"])), URL: v.optional(v.string()), }); ``` ### Cloudflare Pages / Workers [#cloudflare-pages--workers] ```ts title="env.ts" import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", // Cloudflare system environment variables CF_PAGES: "string?", CF_PAGES_COMMIT_SHA: "string?", CF_PAGES_BRANCH: "string?", CF_PAGES_URL: "string?", }); ``` ```ts title="env.ts" import arkenv from "@arkenv/standard"; import { z } from "zod"; export const env = arkenv({ DATABASE_URL: z.string(), // Cloudflare system environment variables CF_PAGES: z.string().optional(), CF_PAGES_COMMIT_SHA: z.string().optional(), CF_PAGES_BRANCH: z.string().optional(), CF_PAGES_URL: z.string().optional(), }); ``` ```ts title="env.ts" import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.string(), // Cloudflare system environment variables CF_PAGES: v.optional(v.string()), CF_PAGES_COMMIT_SHA: v.optional(v.string()), CF_PAGES_BRANCH: v.optional(v.string()), CF_PAGES_URL: v.optional(v.string()), }); ``` ### Railway [#railway] ```ts title="env.ts" import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", // Railway system environment variables RAILWAY_ENVIRONMENT_NAME: "string?", RAILWAY_PUBLIC_DOMAIN: "string?", RAILWAY_SERVICE_NAME: "string?", RAILWAY_GIT_COMMIT_SHA: "string?", }); ``` ```ts title="env.ts" import arkenv from "@arkenv/standard"; import { z } from "zod"; export const env = arkenv({ DATABASE_URL: z.string(), // Railway system environment variables RAILWAY_ENVIRONMENT_NAME: z.string().optional(), RAILWAY_PUBLIC_DOMAIN: z.string().optional(), RAILWAY_SERVICE_NAME: z.string().optional(), RAILWAY_GIT_COMMIT_SHA: z.string().optional(), }); ``` ```ts title="env.ts" import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.string(), // Railway system environment variables RAILWAY_ENVIRONMENT_NAME: v.optional(v.string()), RAILWAY_PUBLIC_DOMAIN: v.optional(v.string()), RAILWAY_SERVICE_NAME: v.optional(v.string()), RAILWAY_GIT_COMMIT_SHA: v.optional(v.string()), }); ``` ### Render [#render] ```ts title="env.ts" import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", // Render system environment variables RENDER: "string?", RENDER_SERVICE_ID: "string?", RENDER_SERVICE_TYPE: "string?", RENDER_EXTERNAL_URL: "string?", }); ``` ```ts title="env.ts" import arkenv from "@arkenv/standard"; import { z } from "zod"; export const env = arkenv({ DATABASE_URL: z.string(), // Render system environment variables RENDER: z.string().optional(), RENDER_SERVICE_ID: z.string().optional(), RENDER_SERVICE_TYPE: z.string().optional(), RENDER_EXTERNAL_URL: z.string().optional(), }); ``` ```ts title="env.ts" import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.string(), // Render system environment variables RENDER: v.optional(v.string()), RENDER_SERVICE_ID: v.optional(v.string()), RENDER_SERVICE_TYPE: v.optional(v.string()), RENDER_EXTERNAL_URL: v.optional(v.string()), }); ``` ### Fly.io [#flyio] ```ts title="env.ts" import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", // Fly.io system environment variables FLY_APP_NAME: "string?", FLY_REGION: "string?", FLY_ALLOC_ID: "string?", }); ``` ```ts title="env.ts" import arkenv from "@arkenv/standard"; import { z } from "zod"; export const env = arkenv({ DATABASE_URL: z.string(), // Fly.io system environment variables FLY_APP_NAME: z.string().optional(), FLY_REGION: z.string().optional(), FLY_ALLOC_ID: z.string().optional(), }); ``` ```ts title="env.ts" import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.string(), // Fly.io system environment variables FLY_APP_NAME: v.optional(v.string()), FLY_REGION: v.optional(v.string()), FLY_ALLOC_ID: v.optional(v.string()), }); ``` ## Customizing and refining variables [#customizing-and-refining-variables] Because ArkEnv gives full code ownership to your repository, you have complete control over how platform variables are validated:
### Adding missing platform variables [#1-adding-missing-platform-variables] If a hosting provider introduces a new environment variable, add the variable directly to your schema: ```ts title="./env.ts" import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", // Add new or unlisted provider variables directly: VERCEL_NEW_FEATURE: "string?", VERCEL_ENV: "'production' | 'preview' | 'development'?", VERCEL_URL: "string?", }); ``` ```ts title="./env.ts" import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.string(), // Add new or unlisted provider variables directly: VERCEL_NEW_FEATURE: z.string().optional(), VERCEL_ENV: z.enum(["production", "preview", "development"]).optional(), VERCEL_URL: z.string().optional(), }); ``` ```ts title="./env.ts" import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.string(), // Add new or unlisted provider variables directly: VERCEL_NEW_FEATURE: v.optional(v.string()), VERCEL_ENV: v.optional(v.picklist(["production", "preview", "development"])), VERCEL_URL: v.optional(v.string()), }); ```
### Refining types and transformations [#2-refining-types-and-transformations] You can modify field definitions to add stricter validations, regex constraints, default values, or runtime transformations (such as prefixing URLs with `https://`): ```ts title="./env.ts" import arkenv, { type } from "@arkenv/core"; export const env = arkenv({ // Custom transformed Vercel URL VERCEL_URL: type("string?").pipe((url) => url ? `https://${url}` : undefined, ), // Stricter custom environment enum VERCEL_ENV: "'production' | 'preview' | 'development' | 'staging'?", }); ``` ```ts title="./env.ts" import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ // Custom transformed Vercel URL VERCEL_URL: z .string() .optional() .transform((url) => (url ? `https://${url}` : undefined)), // Stricter custom environment enum VERCEL_ENV: z .enum(["production", "preview", "development", "staging"]) .optional(), }); ``` ```ts title="./env.ts" import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ // Custom transformed Vercel URL VERCEL_URL: v.optional( v.pipe( v.string(), v.transform((url) => `https://${url}`), ), ), // Stricter custom environment enum VERCEL_ENV: v.optional( v.picklist(["production", "preview", "development", "staging"]), ), }); ```
## What each preset adds [#what-each-preset-adds] Every preset field is **optional** (present only when deployed on that provider). Fields split into: * **Server-only**: Kept off the client bundle. * **Client-exposed**: Safe on the client; the CLI also generates a framework-prefixed copy when a client prefix applies (`NEXT_PUBLIC_`, `NUXT_PUBLIC_`, `VITE_`). ### Vercel [#vercel-1] | Variable | Type | Exposure | | ------------ | ------------------------------------------------------- | -------------- | | `VERCEL` | `string` (optional) | Server-only | | `VERCEL_ENV` | `"production" \| "preview" \| "development"` (optional) | Client-exposed | | `VERCEL_URL` | `string` (optional) | Client-exposed | ### Netlify [#netlify-1] | Variable | Type | Exposure | | ------------ | ---------------------------------------------------------------- | -------------- | | `NETLIFY` | `string` (optional) | Server-only | | `DEPLOY_URL` | `string` (optional) | Server-only | | `CONTEXT` | `"production" \| "deploy-preview" \| "branch-deploy"` (optional) | Client-exposed | | `URL` | `string` (optional) | Client-exposed | ### Cloudflare Pages/Workers [#cloudflare-pagesworkers] | Variable | Type | Exposure | | --------------------- | ------------------- | -------------- | | `CF_PAGES` | `string` (optional) | Server-only | | `CF_PAGES_COMMIT_SHA` | `string` (optional) | Server-only | | `CF_PAGES_BRANCH` | `string` (optional) | Client-exposed | | `CF_PAGES_URL` | `string` (optional) | Client-exposed | ### Railway [#railway-1] | Variable | Type | Exposure | | -------------------------- | ------------------- | ----------- | | `RAILWAY_ENVIRONMENT_NAME` | `string` (optional) | Server-only | | `RAILWAY_PUBLIC_DOMAIN` | `string` (optional) | Server-only | | `RAILWAY_SERVICE_NAME` | `string` (optional) | Server-only | | `RAILWAY_GIT_COMMIT_SHA` | `string` (optional) | Server-only | ### Render [#render-1] | Variable | Type | Exposure | | --------------------- | ------------------- | ----------- | | `RENDER` | `string` (optional) | Server-only | | `RENDER_SERVICE_ID` | `string` (optional) | Server-only | | `RENDER_SERVICE_TYPE` | `string` (optional) | Server-only | | `RENDER_EXTERNAL_URL` | `string` (optional) | Server-only | ### Fly.io [#flyio-1] | Variable | Type | Exposure | | -------------- | ------------------- | ----------- | | `FLY_APP_NAME` | `string` (optional) | Server-only | | `FLY_REGION` | `string` (optional) | Server-only | | `FLY_ALLOC_ID` | `string` (optional) | Server-only | ## Next steps [#next-steps] # Core concepts (/docs/core-concepts) Missing or malformed environment variables fail as early as ArkEnv can see them: in `next build`, during Vite or Bun plugin runs, on server boot, and anywhere else you call `arkenv()`. These guides take you through designing a schema, parsing values, and keeping secrets off the client. They are meant to be read in order the first time. Skip ahead once you know which slice you need. By the time you've read this section, you'll have a typed `env` object and a clear client/server split. ArkEnv validates and types your environment. Loading `.env` files is still your runtime or framework's job. See [Loading `.env` files](/docs/frameworks#loading-env-files). ## From schema to `env` [#from-schema-to-env] Start here if you're writing a schema for the first time. ## Next steps [#next-steps] Wire a runtime with the [framework](/docs/frameworks) guides, pick an engine under [validators](/docs/validators), or skim the thinner [Guides](/docs/guides) section for AI and migrations. # Reusing schemas (/docs/core-concepts/reusing-schemas) Calling `arkenv({ ... })` both defines the schema and parses the current environment. That is the happy path for a single process. The moment the same keys must be validated in more than one place, extract the schema once and pass it into each `arkenv()` call. Common cases: * **Two runtimes in one app.** Vite's `vite.config.ts` runs in Node while the browser client reads `VITE_*` keys. Both need the same shape; only the env source differs. * **Monorepos.** Several packages share `DATABASE_URL` / public API URLs but each app still exports its own `env`. * **Tests.** Feed `{ env: { ... } }` fixtures without redefining the schema. * **Custom integrations.** Workers bindings, CLI tools, and advanced boot paths that re-parse the same schema against a different record. ## Shared schema definition [#shared-schema-definition] Export the schema once, then call `arkenv()` (or the framework/plugin entry) wherever you need a validated object. ```ts title="./env-schema.ts" twoslash import arkenv, { type } from "@arkenv/core"; export const Env = type({ HOST: "string.host = 'localhost'", PORT: "number.port = 3000", VITE_API_URL: "string", DATABASE_URL: "string", }); // Reuse the same compiled schema in every runtime export const env = arkenv(Env); ``` ```ts title="./vite.config.ts" import { defineConfig, loadEnv } from "vite"; import arkenvPlugin from "@arkenv/vite-plugin"; import { Env } from "./env-schema"; import arkenv from "@arkenv/core"; const rootEnv = arkenv(Env, { env: loadEnv("development", process.cwd(), ""), }); export default defineConfig({ // Plugin is transform-only — no schema argument plugins: [arkenvPlugin()], server: { port: rootEnv.PORT }, }); ``` Keywords such as `string.host` and `number.port` work inside `type()` the same way they do inside `arkenv()`. ```ts title="./env-schema.ts" import * as z from "zod"; export const schema = { HOST: z.string().default("localhost"), PORT: z.int().min(0).max(65535).default(3000), VITE_API_URL: z.string(), DATABASE_URL: z.url(), } as const; ``` ```ts title="./env.ts" import arkenv from "@arkenv/standard"; import { schema } from "./env-schema"; export const env = arkenv(schema); ``` ```ts title="./vite.config.ts" import { defineConfig, loadEnv } from "vite"; import arkenvPlugin from "@arkenv/vite-plugin/standard"; import arkenv from "@arkenv/standard"; import { schema } from "./env-schema"; const rootEnv = arkenv(schema, { env: loadEnv("development", process.cwd(), ""), }); export default defineConfig({ // Plugin is transform-only — no schema argument plugins: [arkenvPlugin()], server: { port: rootEnv.PORT }, }); ``` ```ts title="./env-schema.ts" import * as v from "valibot"; export const schema = { HOST: v.optional(v.string(), "localhost"), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), VITE_API_URL: v.string(), DATABASE_URL: v.pipe(v.string(), v.url()), }; ``` ```ts title="./env.ts" import { arkenv } from "@arkenv/standard/valibot"; import { schema } from "./env-schema"; export const env = arkenv(schema); ``` ```ts title="./vite.config.ts" import { defineConfig, loadEnv } from "vite"; import arkenvPlugin from "@arkenv/vite-plugin/standard"; import { arkenv } from "@arkenv/standard/valibot"; import { schema } from "./env-schema"; const rootEnv = arkenv(schema, { env: loadEnv("development", process.cwd(), ""), }); export default defineConfig({ // Plugin is transform-only — no schema argument plugins: [arkenvPlugin()], server: { port: rootEnv.PORT }, }); ``` TypeScript keeps full inference on every call site. ## Extending schemas in frameworks [#extending-schemas-in-frameworks] Compose two modules with `extends` when you need the optional [two-module recipe](/docs/core-concepts/client-vs-server#advanced-two-module-recipe): ```ts title="./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] }, ); ``` ```ts title="./env/server.ts" import "server-only"; import arkenv from "@arkenv/standard"; import * as z from "zod"; import { env as clientEnv } from "./client"; export const env = arkenv( { DATABASE_URL: z.url() }, { extends: [clientEnv] }, ); ``` ```ts title="./env/server.ts" import "server-only"; import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; import { env as clientEnv } from "./client"; export const env = arkenv( { DATABASE_URL: v.pipe(v.string(), v.url()) }, { extends: [clientEnv] }, ); ``` `extends` merges the client env into the server env object. On Nuxt, use `@arkenv/nuxt` for the client module and never import the server module from client code. See [client vs. server](/docs/core-concepts/client-vs-server#advanced-two-module-recipe). ## Monorepo packages [#monorepo-packages] Put the shared schema in an internal package, then call the host `arkenv` (or plugin) from each app: ```ts title="./packages/env/src/schema.ts" import { type } from "@arkenv/core"; export const AppEnv = type({ DATABASE_URL: "string", NEXT_PUBLIC_API_URL: "string", }); ``` ```ts title="./apps/web/env.ts" import arkenv from "@/.arkenv"; import { AppEnv } from "@repo/env"; export const env = arkenv(AppEnv); ``` ```ts title="./packages/env/src/schema.ts" import * as z from "zod"; export const appSchema = { DATABASE_URL: z.url(), NEXT_PUBLIC_API_URL: z.url(), } as const; ``` ```ts title="./apps/web/env.ts" import arkenv from "@/.arkenv"; import { appSchema } from "@repo/env"; export const env = arkenv(appSchema); ``` ```ts title="./packages/env/src/schema.ts" import * as v from "valibot"; export const appSchema = { DATABASE_URL: v.pipe(v.string(), v.url()), NEXT_PUBLIC_API_URL: v.pipe(v.string(), v.url()), }; ``` ```ts title="./apps/web/env.ts" import arkenv from "@/.arkenv"; import { appSchema } from "@repo/env"; export const env = arkenv(appSchema); ``` Each app still owns its `env` export. Shared packages export schemas, not a process-wide singleton, so tests can pass a custom `{ env: ... }` per case. ## Next steps [#next-steps] # Bun fullstack dev server (/docs/frameworks/bun) A plain Bun script can call `arkenv()` with no extra plugin. Apps that bundle a client with [Bun's fullstack dev server](https://bun.com/docs/bundler/fullstack) (`Bun.serve()`) or [Bun's bundler](https://bun.com/docs/bundler) (`Bun.build()`) need `@arkenv/bun-plugin` so the client bundle cannot see server secrets. Stop at [Getting started](/docs/getting-started). You do not need this plugin when nothing ships to a browser. ## Quickstart [#quickstart] `arkenv init` is the default when the app uses `Bun.serve()` or `Bun.build()`. It installs `@arkenv/bun-plugin` and writes `env.ts`. npm pnpm yarn bun nub ```bash npx arkenv init ``` ```bash pnpm dlx arkenv init ``` ```bash yarn dlx arkenv init ``` ```bash bunx arkenv init ``` ```bash nubx arkenv init ``` ## Manual installation [#manual-installation] Skip this section. The CLI already installed the plugin and wrote the schema. Install `@arkenv/bun-plugin` next to the engine you write schemas with, when `bunfig.toml` or the `Bun.build` script is already yours. ### ArkType engine [#arktype-engine] Install `@arkenv/core`, `arktype`, and the Bun plugin: npm pnpm yarn bun nub ```bash npm install @arkenv/core arktype npm install -D @arkenv/bun-plugin ``` ```bash pnpm add @arkenv/core arktype pnpm add -D @arkenv/bun-plugin ``` ```bash yarn add @arkenv/core arktype yarn add --dev @arkenv/bun-plugin ``` ```bash bun install @arkenv/core arktype bun install --dev @arkenv/bun-plugin ``` ```bash nub add @arkenv/core arktype nub add -D @arkenv/bun-plugin ``` ### Standard Schema engine [#standard-schema-engine] If you aren't using ArkType, install `@arkenv/standard` with Zod or Valibot, then import the plugin from `@arkenv/bun-plugin/standard`: npm pnpm yarn bun nub ```bash npm install @arkenv/standard zod npm install -D @arkenv/bun-plugin ``` ```bash pnpm add @arkenv/standard zod pnpm add -D @arkenv/bun-plugin ``` ```bash yarn add @arkenv/standard zod yarn add --dev @arkenv/bun-plugin ``` ```bash bun install @arkenv/standard zod bun install --dev @arkenv/bun-plugin ``` ```bash nub add @arkenv/standard zod nub add -D @arkenv/bun-plugin ``` npm pnpm yarn bun nub ```bash npm install @arkenv/standard valibot @valibot/to-json-schema npm install -D @arkenv/bun-plugin ``` ```bash pnpm add @arkenv/standard valibot @valibot/to-json-schema pnpm add -D @arkenv/bun-plugin ``` ```bash yarn add @arkenv/standard valibot @valibot/to-json-schema yarn add --dev @arkenv/bun-plugin ``` ```bash bun install @arkenv/standard valibot @valibot/to-json-schema bun install --dev @arkenv/bun-plugin ``` ```bash nub add @arkenv/standard valibot @valibot/to-json-schema nub add -D @arkenv/bun-plugin ``` ## Full-stack setup [#full-stack-setup] A full-stack Bun app has two graphs: the server process, and the client bundle. The plugin has to be on both the dev server and `Bun.build`, or the client graph still imports the real schema. ### Define your schema [#define-your-schema] Create your schema in `src/env.ts`: ```ts title="./src/env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", BUN_PUBLIC_API_URL: "string", BUN_PUBLIC_DEBUG: "boolean = false", }); ``` ```ts title="./src/env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), BUN_PUBLIC_API_URL: z.string(), BUN_PUBLIC_DEBUG: z.boolean().default(false), }); ``` ```ts title="./src/env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), BUN_PUBLIC_API_URL: v.string(), BUN_PUBLIC_DEBUG: v.optional(v.boolean(), false), }); ``` ### Configure dev server [#configure-dev-server] Enable the plugin for development in `bunfig.toml`: ```toml title="./bunfig.toml" [serve.static] plugins = ["@arkenv/bun-plugin"] ``` ### Configure production build [#configure-production-build] Pass the plugin to `Bun.build` in your build script: ```ts title="./build.ts" twoslash import arkenvPlugin from "@arkenv/bun-plugin"; await Bun.build({ entrypoints: ["./src/index.tsx"], outdir: "./dist", plugins: [arkenvPlugin()], }); ``` Import `{ env } from "./env"` in application code. Transform mode inlines public `BUN_PUBLIC_` keys into the client bundle and protects server secrets. ## Backend-only Bun [#backend-only-bun] Skip `@arkenv/bun-plugin`. Call the runtime directly. The plugin is for `Bun.serve()` and `Bun.build()` client graphs. ```ts title="./src/env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", PORT: "number.port = 3000", }); ``` ```ts title="./src/env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), PORT: z.int().min(0).max(65535).default(3000), }); ``` ```ts title="./src/env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), }); ``` ## Client access [#client-access] Import the validated `env` object from your schema module. Do not read `process.env` directly Reading `process.env` skips ArkEnv. Import `{ env }` so public keys stay typed and coerced and server secrets stay out of the client bundle. Types and options: [`@arkenv/bun-plugin`](/docs/reference/bun-plugin). ## SSR and bundling execution [#ssr-and-bundling-execution] Full-stack Bun applications separate server runtime execution from client bundle generation: * **Server execution:** Server entrypoints and backend APIs execute real `@arkenv/core` or `@arkenv/standard` validation at startup, validating database URLs and backend secrets before serving traffic. * **Client bundling:** During `Bun.build` runs or `[serve.static]` requests, `@arkenv/bun-plugin` transforms `env.ts` imports in client code. It inlines `BUN_PUBLIC_*` values as static literals and replaces server keys with throwing runtime stubs. Browser bundles omit the validator engine. ## Next steps [#next-steps] # Frameworks (/docs/frameworks) ArkEnv works in any JavaScript or TypeScript project. When you build full-stack applications, client bundles must never receive server secrets. The framework packages below enforce that boundary during development and production builds. Choose your framework to get started: ## How hosts enforce the boundary [#how-hosts-enforce-the-boundary] Frameworks use **compile-time AST transforms** or **host runtime adapters**: | Framework | Integration strategy | Client bundle output | SSR and server validation | Public env without rebuild (Docker) | | -------------- | ---------------------------------------------------------------------- | ---------------------------------------- | ---------------------------------- | ------------------------------------------ | | Next.js | Host adapter + codegen (`@arkenv/nextjs`) | Generated runtime proxy | Process boot (`process.env`) | Requires rebuild for `NEXT_PUBLIC_*` | | Nuxt | Nitro plugin (`@arkenv/nuxt`) | Dynamic `runtimeConfig` hydration | Nitro server boot gate | Yes (via Nitro `runtimeConfig`) | | Vite | AST build transform (`@arkenv/vite-plugin`) | Inlined public literals + throwing stubs | Full `@arkenv/core` at server boot | Requires rebuild for `VITE_*` | | TanStack Start | AST build transform (`@arkenv/vite-plugin` / `@arkenv/rsbuild-plugin`) | Inlined public literals + throwing stubs | Full `@arkenv/core` at server boot | Requires rebuild for `VITE_*` / `PUBLIC_*` | | Bun | Bundler AST transform (`@arkenv/bun-plugin`) | Inlined public literals + throwing stubs | Full `@arkenv/core` at server boot | Requires rebuild for `BUN_PUBLIC_*` | Vite, TanStack Start, and Bun evaluate two module graphs in full-stack and SSR apps. Application code imports `@arkenv/core` (ArkType), `@arkenv/standard` (Zod), or `@arkenv/standard/valibot` (Valibot); the plugin rewrites the **client** graph so public keys inline and server secrets become throwing stubs. Next.js and Nuxt use host adapters (`@arkenv/nextjs`, `@arkenv/nuxt`) with dedicated entrypoints and codegen or `runtimeConfig` hydration. ArkEnv keeps the **validation engine** (`@arkenv/core` / `@arkenv/standard`) as an **optional peer** of each host adapter. You install the engine you use; the adapter does not bundle either runtime. That keeps host packages lean and lets you pair any adapter with ArkType or a Standard Schema validator. ## Loading `.env` files [#loading-env-files] ArkEnv does not read `.env` files. Something else must load them before `arkenv()` runs. Next.js, Nuxt, Vite, and Bun already load `.env*` files. On plain Node (20.6+), use the built-in `--env-file` flag: ```bash title="Terminal" node --env-file=.env dist/index.js ``` For TypeScript in development, pass the same flag to your usual runner: ```bash title="Terminal" tsx --env-file=.env src/index.ts ``` For NestJS applications, `@nestjs/cli` 11+ supports `nest start --env-file .env` — follow the [Using ArkEnv with NestJS](/docs/guides/use-with-nestjs) guide. After values are in `process.env` (or a custom record), `arkenv()` validates them. # Next.js (/docs/frameworks/nextjs) Next.js will boot a dev server and a production build without checking your schema. ArkEnv runs that check before the app serves traffic, and keeps server secrets out of Client Components. How this host compares with Nuxt, Vite, and Bun is in [Frameworks](/docs/frameworks). ## Quickstart [#quickstart] `arkenv init` is the default on an existing Next.js app. It detects the framework, installs the packages, wraps `next.config` with `withArkEnv`, gitignores `.arkenv/`, and writes a schema that imports from `@/.arkenv`. npm pnpm yarn bun nub ```bash npx arkenv init ``` ```bash pnpm dlx arkenv init ``` ```bash yarn dlx arkenv init ``` ```bash bunx arkenv init ``` ```bash nubx arkenv init ``` ## Manual installation [#manual-installation] Skip this section. The CLI already installed the packages and wrapped `next.config`. Install `@arkenv/nextjs` next to the engine you write schemas with, when `next.config.ts` is already yours and you want each package explicit in `package.json`. ### ArkType engine [#arktype-engine] Install `@arkenv/core` and its peer dependency `arktype`: npm pnpm yarn bun nub ```bash npm install @arkenv/core @arkenv/nextjs arktype ``` ```bash pnpm add @arkenv/core @arkenv/nextjs arktype ``` ```bash yarn add @arkenv/core @arkenv/nextjs arktype ``` ```bash bun install @arkenv/core @arkenv/nextjs arktype ``` ```bash nub add @arkenv/core @arkenv/nextjs arktype ``` ### Standard Schema engine [#standard-schema-engine] If you aren't using ArkType, install `@arkenv/standard` with Zod or Valibot: npm pnpm yarn bun nub ```bash npm install @arkenv/standard @arkenv/nextjs zod ``` ```bash pnpm add @arkenv/standard @arkenv/nextjs zod ``` ```bash yarn add @arkenv/standard @arkenv/nextjs zod ``` ```bash bun install @arkenv/standard @arkenv/nextjs zod ``` ```bash nub add @arkenv/standard @arkenv/nextjs zod ``` npm pnpm yarn bun nub ```bash npm install @arkenv/standard @arkenv/nextjs valibot @valibot/to-json-schema ``` ```bash pnpm add @arkenv/standard @arkenv/nextjs valibot @valibot/to-json-schema ``` ```bash yarn add @arkenv/standard @arkenv/nextjs valibot @valibot/to-json-schema ``` ```bash bun install @arkenv/standard @arkenv/nextjs valibot @valibot/to-json-schema ``` ```bash nub add @arkenv/standard @arkenv/nextjs valibot @valibot/to-json-schema ``` ## Configuration [#configuration] Next.js does not validate an env schema on its own. `withArkEnv` runs that check during development and builds, and writes typed accessors to `.arkenv/env.gen.ts`. Import that factory as `@/.arkenv`. Add `.arkenv/` to `.gitignore` so the generated files stay out of git. ```ts title="./next.config.ts" twoslash import type { NextConfig } from "next"; import { withArkEnv } from "@arkenv/nextjs/config"; const nextConfig: NextConfig = {}; export default withArkEnv(nextConfig); ``` Function-form configs work too. `withArkEnv` awaits your factory, then applies aliases to the resolved object (including phase-dependent options): ```ts title="./next.config.ts" import type { NextConfig } from "next"; import { withArkEnv } from "@arkenv/nextjs/config"; export default withArkEnv(async (phase, { defaultConfig }): Promise => ({ ...defaultConfig, reactStrictMode: phase !== "phase-test", })); ``` ## Schema [#schema] One `env.ts` is the schema. Client variables use the `NEXT_PUBLIC_` prefix so Next can inline them. Everything else stays on the server. Zod and Valibot use the same `@/.arkenv` factory. Point `withArkEnv` at `@arkenv/nextjs/standard/config`, shown under [Standard Schema](#standard-schema). ```ts title="./env.ts" twoslash 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'", }); ``` ```ts title="./env.ts" twoslash import arkenv from "@/.arkenv"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), NEXT_PUBLIC_API_URL: z.url().default("https://api.example.com"), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); ``` ```ts title="./env.ts" twoslash import arkenv from "@/.arkenv"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), NEXT_PUBLIC_API_URL: v.optional( v.pipe(v.string(), v.url()), "https://api.example.com", ), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), }); ``` Import `env` anywhere in your application. When client code accesses a server key such as `DATABASE_URL`, ArkEnv throws a runtime error to prevent data leaks. If secret **names or types** must stay out of the client type graph as well as values, use the optional [two-module recipe](/docs/core-concepts/client-vs-server#advanced-two-module-recipe). ## Standard Schema [#standard-schema] Zod and Valibot both import `withArkEnv` from `@arkenv/nextjs/standard/config`. The schema itself stays on `@/.arkenv`, as in the tabs above. `@arkenv/nextjs/standard` binds the root `@arkenv/standard` engine and publishes the same `react-server` condition as `.`. Server Components and Route Handlers resolve the full schema. Client code resolves public keys only. Zod 4.2+ coerces numbers and booleans from that entry. Valibot does not: there is no `@arkenv/nextjs/standard/valibot` binding, so installing `@valibot/to-json-schema` does not turn `v.number()` or `v.boolean()` into coerced fields here. Keep Valibot fields as strings on this entry, or validate them with [`@arkenv/standard/valibot`](/docs/validators/valibot) outside the Next.js factory. ```ts title="./next.config.ts" twoslash import type { NextConfig } from "next"; import { withArkEnv } from "@arkenv/nextjs/standard/config"; const nextConfig: NextConfig = {}; export default withArkEnv(nextConfig); ``` ## Read `env` in application code [#read-env-in-application-code] Import `{ env } from "./env"` in Server Components, route handlers, and Client Components. One module is the typed object everywhere. Do not read `process.env` directly Reading `process.env` skips ArkEnv. Import `{ env }` so public keys stay typed and coerced and server secrets stay out of the client bundle. ```ts title="./app/page.tsx" import { env } from "../env"; export default function Page() { return

{env.NEXT_PUBLIC_API_URL}

; } ``` Reading `env.DATABASE_URL` from a Client Component throws. See [Client vs. server](/docs/core-concepts/client-vs-server). ## Next steps [#next-steps] # Nuxt (/docs/frameworks/nuxt) Nuxt already hydrates public config through Nitro. ArkEnv validates the schema on server and client routes, and keeps server secrets off the browser while those public keys ride runtime config. How this host compares with Next.js, Vite, and Bun is in [Frameworks](/docs/frameworks). ## Quickstart [#quickstart] `arkenv init` is the default on an existing Nuxt app. It registers `@arkenv/nuxt/module` in your Nuxt config and writes the first schema file. npm pnpm yarn bun nub ```bash npx arkenv init ``` ```bash pnpm dlx arkenv init ``` ```bash yarn dlx arkenv init ``` ```bash bunx arkenv init ``` ```bash nubx arkenv init ``` ## Manual installation [#manual-installation] Skip this section. The CLI already registered the module and wrote the schema. Install `@arkenv/nuxt` next to the engine you write schemas with, when `nuxt.config.ts` is already yours and you want each package explicit in `package.json`. ### ArkType engine [#arktype-engine] Install `@arkenv/core` and its peer dependency `arktype`: npm pnpm yarn bun nub ```bash npm install @arkenv/core @arkenv/nuxt arktype ``` ```bash pnpm add @arkenv/core @arkenv/nuxt arktype ``` ```bash yarn add @arkenv/core @arkenv/nuxt arktype ``` ```bash bun install @arkenv/core @arkenv/nuxt arktype ``` ```bash nub add @arkenv/core @arkenv/nuxt arktype ``` ### Standard Schema engine [#standard-schema-engine] If you aren't using ArkType, install `@arkenv/standard` with Zod or Valibot: npm pnpm yarn bun nub ```bash npm install @arkenv/standard @arkenv/nuxt zod ``` ```bash pnpm add @arkenv/standard @arkenv/nuxt zod ``` ```bash yarn add @arkenv/standard @arkenv/nuxt zod ``` ```bash bun install @arkenv/standard @arkenv/nuxt zod ``` ```bash nub add @arkenv/standard @arkenv/nuxt zod ``` npm pnpm yarn bun nub ```bash npm install @arkenv/standard @arkenv/nuxt valibot @valibot/to-json-schema ``` ```bash pnpm add @arkenv/standard @arkenv/nuxt valibot @valibot/to-json-schema ``` ```bash yarn add @arkenv/standard @arkenv/nuxt valibot @valibot/to-json-schema ``` ```bash bun install @arkenv/standard @arkenv/nuxt valibot @valibot/to-json-schema ``` ```bash nub add @arkenv/standard @arkenv/nuxt valibot @valibot/to-json-schema ``` ## Module configuration [#module-configuration] Nuxt loads modules from `nuxt.config.ts`. Registering `@arkenv/nuxt` there is what injects runtime config and validates variables during development and build. Without the module, importing `env` only checks the process that happens to load it. ```ts title="./nuxt.config.ts" export default defineNuxtConfig({ modules: ["@arkenv/nuxt/module"], }); ``` ## Schema [#schema] One `env.ts` is the schema. Public keys use the `NUXT_PUBLIC_` prefix so Nitro can hydrate them on the client. Everything else stays on the server. Zod and Valibot both import `@arkenv/nuxt/standard` and register `@arkenv/nuxt/standard/module`. `@arkenv/nuxt/standard` binds the root `@arkenv/standard` engine. Zod 4.2+ coerces numbers and booleans from that entry. Valibot does not: there is no `@arkenv/nuxt/standard/valibot` binding, so installing `@valibot/to-json-schema` does not coerce `v.number()` or `v.boolean()` here. Keep Valibot fields as strings on this entry, or validate them with [`@arkenv/standard/valibot`](/docs/validators/valibot) outside the Nuxt module. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/nuxt"; export const env = arkenv({ DATABASE_URL: "string", NUXT_PUBLIC_API_URL: "string = 'https://api.example.com'", NODE_ENV: "'development' | 'production' | 'test' = 'development'", }); ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/nuxt/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), NUXT_PUBLIC_API_URL: z.url().default("https://api.example.com"), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/nuxt/standard"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), NUXT_PUBLIC_API_URL: v.optional( v.pipe(v.string(), v.url()), "https://api.example.com", ), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), }); ``` Unlike Next.js, Nuxt does not emit an `env.gen.ts` file; `@arkenv/nuxt` acts as the primary entrypoint. If you split client and server modules for name/type isolation, **never import the server module from client or Vue code**. See the [two-module recipe](/docs/core-concepts/client-vs-server#advanced-two-module-recipe). ## Read `env` in application code [#read-env-in-application-code] Import `{ env } from "./env"` in server routes and Vue components. One module is the typed object everywhere. Do not read `process.env` directly Reading `process.env` skips ArkEnv. Import `{ env }` so public keys stay typed and coerced and server secrets stay out of the client bundle. ## Standard Schema [#standard-schema] Zod and Valibot both point the module at the standard module path. The schema tabs above already import from `@arkenv/nuxt/standard`. ```ts title="./nuxt.config.ts" export default defineNuxtConfig({ modules: ["@arkenv/nuxt/standard/module"], }); ``` ## Nitro boot gate and runtimeConfig hydration [#nitro-boot-gate-and-runtimeconfig-hydration] Staging and production containers inject environment variables when the process starts, not when the image is built. Vite and Bun inline literals at build time. `@arkenv/nuxt` hooks into Nitro instead: 1. **Server boot gate:** At server startup, an internal Nitro plugin runs before route handlers execute. Nuxt compiles your schema into the server bundle, then the plugin validates live environment variables against that schema and throws fail-fast errors on invalid values. A copied `.output` directory does not need the source `env.ts` beside it. 2. **Runtime configuration hydration:** An internal Nitro plugin passes validated public variables (`NUXT_PUBLIC_*`) into `useRuntimeConfig().public`. 3. **Client access:** Client components read public values from runtime configuration during browser rendering and hydration, avoiding hardcoded build-time literals. You can update environment variables across deployment stages without rebuilding client JavaScript bundles. Ship one image and change env at container start for staging versus production. Unlike Next.js `env.gen.ts`, `@arkenv/nuxt` writes no generated files to commit or gitignore. The module validates at boot and registers public keys on `runtimeConfig`. ## Next steps [#next-steps] # TanStack Start (/docs/frameworks/tanstack-start) TanStack Start ships a client bundle next to `createServerFn` handlers. ArkEnv validates environment variables at boot, inlines public keys into that bundle, and keeps server secrets inside the server functions. When the Vite plugin is registered, it discovers and validates `env.ts` during Vite config resolution. Missing or invalid values abort the dev server or production build before it is ready, and relevant `.env` or schema changes are revalidated during HMR. Without the plugin, validation is import-driven and starts when a module first imports `env.ts`. There is no default-off flag for lazy validation. For high-level architectural trade-offs, see [Frameworks](/docs/frameworks). ## Quickstart [#quickstart] Vite is the default path. TanStack Start already ships a client bundle next to `createServerFn` handlers, and that split is where a server secret leaks if `env` is only a plain object. The TanStack CLI add-on scaffolds the Vite plugin and an `env.ts` schema for ArkType, Zod, or Valibot. ### Create a new project [#create-a-new-project] Generate a TanStack Start app with ArkEnv wired in: npm pnpm yarn bun nub ```bash npx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json ``` ```bash pnpm dlx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json ``` ```bash yarn dlx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json ``` ```bash bunx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json ``` ```bash nubx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json ``` `@tanstack/cli` does not include an `arkenv` add-on, so `--add-ons arkenv` fails. Use the URL above. When the CLI runs, it asks a few setup questions — including which validator engine to use (ArkType, Zod, or Valibot) and whether to include the `/demo/arkenv` route. You can accept the defaults or pick what fits your project. The add-on installs the validator runtime, registers `arkenvPlugin()` in `vite.config.ts`, writes `src/env.ts`, and pre-populates declared keys in `.env.example`. ### Existing projects [#existing-projects] If you already have a TanStack Start app on Vite, add ArkEnv with either the TanStack CLI add-on or the ArkEnv CLI: npm pnpm yarn bun nub ```bash npx @tanstack/cli add https://arkenv.js.org/tanstack/info.json ``` ```bash pnpm dlx @tanstack/cli add https://arkenv.js.org/tanstack/info.json ``` ```bash yarn dlx @tanstack/cli add https://arkenv.js.org/tanstack/info.json ``` ```bash bunx @tanstack/cli add https://arkenv.js.org/tanstack/info.json ``` ```bash nubx @tanstack/cli add https://arkenv.js.org/tanstack/info.json ``` npm pnpm yarn bun nub ```bash npx arkenv init ``` ```bash pnpm dlx arkenv init ``` ```bash yarn dlx arkenv init ``` ```bash bunx arkenv init ``` ```bash nubx arkenv init ``` `arkenv init` detects `@tanstack/react-start` (or `@tanstack/start`) and installs `@arkenv/vite-plugin` the same way it does for Vite projects. ArkEnv fully supports Rsbuild via `@arkenv/rsbuild-plugin`. `@tanstack/cli` does not currently support Rsbuild for scaffolding or add-ons (track [TanStack/cli#505](https://github.com/TanStack/cli/pull/505)). Configure via [Manual installation](#manual-installation) and [Rsbuild](#rsbuild). Until GA, pin exact `@arkenv/*` RC versions in the lockfile if you want bit-for-bit reproducibility across machines and CI. ## Manual installation [#manual-installation] Skip this section. Those commands already installed the plugin that matches your bundler. Install the plugin that matches your bundler alongside your validation engine. Author bare `@arkenv/*` package names. The docs site applies the release tag when it renders the install tabs. Prefer `@arkenv/core` or `@arkenv/standard` and your host plugin as app dependencies — not the `arkenv` CLI package. ### Vite [#vite] #### ArkType engine [#arktype-engine] npm pnpm yarn bun nub ```bash npm install @arkenv/core arktype npm install -D @arkenv/vite-plugin ``` ```bash pnpm add @arkenv/core arktype pnpm add -D @arkenv/vite-plugin ``` ```bash yarn add @arkenv/core arktype yarn add --dev @arkenv/vite-plugin ``` ```bash bun install @arkenv/core arktype bun install --dev @arkenv/vite-plugin ``` ```bash nub add @arkenv/core arktype nub add -D @arkenv/vite-plugin ``` #### Standard Schema engine [#standard-schema-engine] Zod or Valibot: npm pnpm yarn bun nub ```bash npm install @arkenv/standard zod npm install -D @arkenv/vite-plugin ``` ```bash pnpm add @arkenv/standard zod pnpm add -D @arkenv/vite-plugin ``` ```bash yarn add @arkenv/standard zod yarn add --dev @arkenv/vite-plugin ``` ```bash bun install @arkenv/standard zod bun install --dev @arkenv/vite-plugin ``` ```bash nub add @arkenv/standard zod nub add -D @arkenv/vite-plugin ``` npm pnpm yarn bun nub ```bash npm install @arkenv/standard valibot @valibot/to-json-schema npm install -D @arkenv/vite-plugin ``` ```bash pnpm add @arkenv/standard valibot @valibot/to-json-schema pnpm add -D @arkenv/vite-plugin ``` ```bash yarn add @arkenv/standard valibot @valibot/to-json-schema yarn add --dev @arkenv/vite-plugin ``` ```bash bun install @arkenv/standard valibot @valibot/to-json-schema bun install --dev @arkenv/vite-plugin ``` ```bash nub add @arkenv/standard valibot @valibot/to-json-schema nub add -D @arkenv/vite-plugin ``` ### Rsbuild [#rsbuild] #### ArkType engine [#arktype-engine-1] npm pnpm yarn bun nub ```bash npm install @arkenv/core arktype npm install -D @arkenv/rsbuild-plugin ``` ```bash pnpm add @arkenv/core arktype pnpm add -D @arkenv/rsbuild-plugin ``` ```bash yarn add @arkenv/core arktype yarn add --dev @arkenv/rsbuild-plugin ``` ```bash bun install @arkenv/core arktype bun install --dev @arkenv/rsbuild-plugin ``` ```bash nub add @arkenv/core arktype nub add -D @arkenv/rsbuild-plugin ``` #### Standard Schema engine [#standard-schema-engine-1] Zod or Valibot: npm pnpm yarn bun nub ```bash npm install @arkenv/standard zod npm install -D @arkenv/rsbuild-plugin ``` ```bash pnpm add @arkenv/standard zod pnpm add -D @arkenv/rsbuild-plugin ``` ```bash yarn add @arkenv/standard zod yarn add --dev @arkenv/rsbuild-plugin ``` ```bash bun install @arkenv/standard zod bun install --dev @arkenv/rsbuild-plugin ``` ```bash nub add @arkenv/standard zod nub add -D @arkenv/rsbuild-plugin ``` npm pnpm yarn bun nub ```bash npm install @arkenv/standard valibot @valibot/to-json-schema npm install -D @arkenv/rsbuild-plugin ``` ```bash pnpm add @arkenv/standard valibot @valibot/to-json-schema pnpm add -D @arkenv/rsbuild-plugin ``` ```bash yarn add @arkenv/standard valibot @valibot/to-json-schema yarn add --dev @arkenv/rsbuild-plugin ``` ```bash bun install @arkenv/standard valibot @valibot/to-json-schema bun install --dev @arkenv/rsbuild-plugin ``` ```bash nub add @arkenv/standard valibot @valibot/to-json-schema nub add -D @arkenv/rsbuild-plugin ``` ## Configuration [#configuration] Register the ArkEnv plugin next to TanStack Start and React in your bundler config. ### Vite [#vite-1] TanStack Start's React recipe on Vite expects `viteReact()` after `tanstackStart()` (for JSX / Fast Refresh). Add `arkenvPlugin()` to the same `plugins` array: ```ts title="./vite.config.ts" import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; import arkenvPlugin from "@arkenv/vite-plugin"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ tanstackStart({ srcDirectory: "src" }), // React's Vite plugin must come after Start's plugin viteReact(), arkenvPlugin(), ], }); ``` Install `@vitejs/plugin-react` if it is not already a dependency. If you aren't using ArkType, import the ArkEnv plugin from `@arkenv/vite-plugin/standard`. Place `@vitejs/plugin-react` (or `@vitejs/plugin-react-swc`) **after** `tanstackStart()`. Without it, Vite cannot resolve `/@react-refresh`, SSR still renders, and client hydration fails so handlers never attach. ### Rsbuild [#rsbuild-1] TanStack Start also supports [Rsbuild](https://rsbuild.dev) via `@tanstack/react-start/plugin/rsbuild`. Register `arkenvPlugin()` in your `rsbuild.config.ts`: ```ts title="./rsbuild.config.ts" import { pluginReact } from "@rsbuild/plugin-react"; import { tanstackStart } from "@tanstack/react-start/plugin/rsbuild"; import { arkenvPlugin } from "@arkenv/rsbuild-plugin"; import { defineConfig } from "@rsbuild/core"; export default defineConfig({ plugins: [ tanstackStart({ srcDirectory: "src" }), pluginReact(), arkenvPlugin(), ], }); ``` If you aren't using ArkType, import the plugin from `@arkenv/rsbuild-plugin/standard`. ## Define your schema [#define-your-schema] One `env.ts` is the schema for both graphs. Keys the client bundle reads must match the bundler's public prefix: `VITE_` for Vite or `PUBLIC_` for Rsbuild. Everything else stays on the server, including inside `createServerFn`. Runtime validation lives in `@arkenv/core` (ArkType) or `@arkenv/standard` (Zod/Valibot). The `arkenv` package is the interactive CLI only. Importing `arkenv` from the CLI package throws and points you at `@arkenv/core`. See [`init` reference](/docs/reference/init#import-the-validator-from-core). ```ts title="./src/env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", PORT: "number.port = 3000", VITE_API_URL: "string = 'https://api.example.com'", NODE_ENV: "'development' | 'production' | 'test' = 'development'", }); ``` ```ts title="./src/env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", PORT: "number.port = 3000", PUBLIC_API_URL: "string = 'https://api.example.com'", NODE_ENV: "'development' | 'production' | 'test' = 'development'", }); ``` Swap the import for `@arkenv/standard` (Zod) or `@arkenv/standard/valibot` (Valibot) and declare each field with that library. The client prefixes stay `VITE_` or `PUBLIC_`. See [Defining your schema](/docs/core-concepts/defining-your-schema). Pass the object literal into `arkenv({ ... })`, or wrap a shared shape with `type(...)` from `@arkenv/core`. An untyped intermediate object can break overload inference. ## Server functions and client access [#server-functions-and-client-access] Import `{ env }` in routes and server functions. The server graph runs the real validator. The client graph only keeps public keys. ### Server keys in createServerFn [#server-keys-in-createserverfn] Server-only keys work inside `createServerFn` handlers. The handler runs on the server, where `env.ts` executes the real validation runtime against your process environment: ```tsx title="./src/routes/index.tsx" import { createFileRoute } from "@tanstack/react-router"; import { createServerFn } from "@tanstack/react-start"; import { env } from "../env"; const getDatabaseHost = createServerFn({ method: "GET" }).handler(() => { const url = new URL(env.DATABASE_URL); // server-only: validated at boot return url.host; // safe to return to the client }); export const Route = createFileRoute("/")({ component: Home, loader: () => getDatabaseHost(), }); function Home() { const dbHost = Route.useLoaderData(); return (

API: {env.VITE_API_URL}

Database Host: {dbHost}

); } ```
```tsx title="./src/routes/index.tsx" import { createFileRoute } from "@tanstack/react-router"; import { createServerFn } from "@tanstack/react-start"; import { env } from "../env"; const getDatabaseHost = createServerFn({ method: "GET" }).handler(() => { const url = new URL(env.DATABASE_URL); // server-only: validated at boot return url.host; // safe to return to the client }); export const Route = createFileRoute("/")({ component: Home, loader: () => getDatabaseHost(), }); function Home() { const dbHost = Route.useLoaderData(); return (

API: {env.PUBLIC_API_URL}

Database Host: {dbHost}

); } ```
### Client keys in components [#client-keys-in-components] Client components read public keys (`VITE_*` on Vite, `PUBLIC_*` on Rsbuild) from the same import. The plugin rewrites the client module so these values are inlined as coerced literals. Reading `import.meta.env` skips ArkEnv. Import `{ env }` so public keys stay typed and coerced and server secrets stay out of the client bundle. Reading a server-only key in the browser throws instead of leaking: ```tsx env.VITE_API_URL; // string, inlined into the client bundle env.DATABASE_URL; // throws in the browser; real value on the server ``` ```tsx env.PUBLIC_API_URL; // string, inlined into the client bundle env.DATABASE_URL; // throws in the browser; real value on the server ``` The transformed client getter fails loudly: > `Do not access server-only key 'DATABASE_URL' on the client since it will leak sensitive data (prevented by ArkEnv)` That surfaces the leak during development (and in tests) before it reaches production. See [Error reporting](/docs/core-concepts/error-reporting) for the full message shape. ## SSR and the client boundary [#ssr-and-the-client-boundary] TanStack Start builds two module graphs: the SSR graph for the server functions and routes you render on the server, and the client graph for what ships to the browser. * **Server graph:** `env.ts` executes `@arkenv/core` or `@arkenv/standard` at boot and validates your process environment before requests are served. `createServerFn` handlers run on this graph, so they read real, validated values. * **Client graph:** `@arkenv/vite-plugin` or `@arkenv/rsbuild-plugin` transforms `env.ts` during the client build. It inlines public values and replaces private server keys with throwing getters, so the validator engine never ships to the browser. Like Vite and Rsbuild, TanStack Start loads `.env*` files in development. In production, the environment comes from the process that starts your server, so containers must inject variables before boot. ## Examples [#examples] Run a complete setup end to end: * [`with-tanstack-start`](https://github.com/yamcodes/arkenv/tree/v1/examples/with-tanstack-start): TanStack Start with Vite and `@arkenv/vite-plugin`. * [`with-tanstack-start-rsbuild`](https://github.com/yamcodes/arkenv/tree/v1/examples/with-tanstack-start-rsbuild): TanStack Start with Rsbuild and `@arkenv/rsbuild-plugin`. Both examples demonstrate server-only `DATABASE_URL` accessed inside `createServerFn`, public keys rendered in a client component, and a button that demonstrates the client-side throw. ## Next steps [#next-steps] # Vite (/docs/frameworks/vite) Vite reports that it is ready before your schema has run, unless something validates during config resolution. ArkEnv does that check, and keeps server secrets out of the client bundle. ## When does validation run? [#when-does-validation-run] When `@arkenv/vite-plugin` is registered, it discovers and validates `env.ts` during Vite config resolution. An invalid or missing value aborts the dev server or production build before Vite reports that it is ready. Relevant `.env` and schema changes are validated again during HMR. Without the plugin, validation is import-driven: `env.ts` runs when an application module imports it. That applies to both the server graph and the client graph. The plugin is what lets Vite validate the schema before the graph is ready, and rewrite client imports so public `VITE_` keys inline. See the [`@arkenv/vite-plugin` reference](/docs/reference/vite-plugin). There is no default-off lazy-validation flag. How this host compares with Next.js, Nuxt, and Bun is in [Frameworks](/docs/frameworks). ## Quickstart [#quickstart] `arkenv init` is the default on an existing Vite app. It installs `@arkenv/vite-plugin`, configures `vite.config.ts`, and scaffolds `env.ts`. npm pnpm yarn bun nub ```bash npx arkenv init ``` ```bash pnpm dlx arkenv init ``` ```bash yarn dlx arkenv init ``` ```bash bunx arkenv init ``` ```bash nubx arkenv init ``` ## Manual installation [#manual-installation] Skip this section. The CLI already installed the plugin and wrote `vite.config.ts`. Install `@arkenv/vite-plugin` next to the engine you write schemas with, when `vite.config.ts` is already in the repo and you want each package explicit in `package.json`. ### ArkType engine [#arktype-engine] Install `@arkenv/core`, `arktype`, and the Vite plugin: npm pnpm yarn bun nub ```bash npm install @arkenv/core arktype npm install -D @arkenv/vite-plugin ``` ```bash pnpm add @arkenv/core arktype pnpm add -D @arkenv/vite-plugin ``` ```bash yarn add @arkenv/core arktype yarn add --dev @arkenv/vite-plugin ``` ```bash bun install @arkenv/core arktype bun install --dev @arkenv/vite-plugin ``` ```bash nub add @arkenv/core arktype nub add -D @arkenv/vite-plugin ``` ### Standard Schema engine [#standard-schema-engine] If you aren't using ArkType, install `@arkenv/standard` with Zod or Valibot: npm pnpm yarn bun nub ```bash npm install @arkenv/standard zod npm install -D @arkenv/vite-plugin ``` ```bash pnpm add @arkenv/standard zod pnpm add -D @arkenv/vite-plugin ``` ```bash yarn add @arkenv/standard zod yarn add --dev @arkenv/vite-plugin ``` ```bash bun install @arkenv/standard zod bun install --dev @arkenv/vite-plugin ``` ```bash nub add @arkenv/standard zod nub add -D @arkenv/vite-plugin ``` npm pnpm yarn bun nub ```bash npm install @arkenv/standard valibot @valibot/to-json-schema npm install -D @arkenv/vite-plugin ``` ```bash pnpm add @arkenv/standard valibot @valibot/to-json-schema pnpm add -D @arkenv/vite-plugin ``` ```bash yarn add @arkenv/standard valibot @valibot/to-json-schema yarn add --dev @arkenv/vite-plugin ``` ```bash bun install @arkenv/standard valibot @valibot/to-json-schema bun install --dev @arkenv/vite-plugin ``` ```bash nub add @arkenv/standard valibot @valibot/to-json-schema nub add -D @arkenv/vite-plugin ``` ## Configuration [#configuration] Vite does not know about your schema until the plugin is in the plugins array. `arkenvPlugin()` is what validates during config resolution and rewrites client imports. ### Register the plugin [#register-the-plugin] Import the plugin and add it next to the rest of your Vite plugins: ```ts title="./vite.config.ts" twoslash import { defineConfig } from "vite"; import arkenvPlugin from "@arkenv/vite-plugin"; export default defineConfig({ plugins: [arkenvPlugin()], }); ``` Zod and Valibot import the plugin from `@arkenv/vite-plugin/standard`. ### Define your schema [#define-your-schema] The plugin looks for `env.ts`. Import the runtime from `@arkenv/core`, `@arkenv/standard`, or `@arkenv/standard/valibot`. The `arkenv` package is the CLI only. Importing it as a library throws. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", VITE_API_URL: "string", NODE_ENV: "'development' | 'production' | 'test' = 'development'", }); ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), VITE_API_URL: z.string(), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), VITE_API_URL: v.string(), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), }); ``` Import `{ env } from "./env"` across your application. In server contexts (such as SSR or development scripts), the real validation module runs at boot. In client bundles, the Vite plugin rewrites the import to inline public `VITE_` values and guards private server keys. ## Reuse the schema in Vite config [#reuse-the-schema-in-vite-config] Vite does not load `.env*` files while evaluating `vite.config.ts`. When the config needs typed values (for example `server.port`), compile a reusable schema with `type()` and validate it with core `arkenv()` and `loadEnv`. Register the plugin with no schema argument — it still rewrites `env.ts` in the client graph. ```ts title="./vite.config.ts" twoslash import { defineConfig, loadEnv } from "vite"; import arkenvPlugin from "@arkenv/vite-plugin"; import arkenv, { type } from "@arkenv/core"; export const Env = type({ PORT: "number.port = 5173", VITE_API_URL: "string", DATABASE_URL: "string", }); export default defineConfig(({ mode }) => { const env = arkenv(Env, { env: loadEnv(mode, process.cwd(), "") }); return { // Plugin is transform-only — no schema argument plugins: [arkenvPlugin()], server: { port: env.PORT }, }; }); ``` Learn more in [Reusing schemas](/docs/core-concepts/reusing-schemas). ## Client access [#client-access] Import the validated `env` object from your schema module. Do not read `import.meta.env` directly Reading `import.meta.env` skips ArkEnv. Import `{ env }` so public keys stay typed and coerced and server secrets stay out of the client bundle. Types and options: [`@arkenv/vite-plugin`](/docs/reference/vite-plugin). ## SSR and dual-graph execution [#ssr-and-dual-graph-execution] Vite maintains two distinct module graphs when building and serving full-stack or SSR applications: * **Server graph:** During SSR execution and dev server requests, `env.ts` executes real `@arkenv/core` or `@arkenv/standard` validation at boot. It ensures private secrets (such as `DATABASE_URL`) and public configuration validate before handling requests. * **Client graph:** During client compilation, `@arkenv/vite-plugin` intercepts and transforms `env.ts`. It inlines public `VITE_*` values as static literals and replaces private server keys with throwing runtime stubs. Client bundles omit the validator engine. On Vite 6+, the Environment API drives this split: server consumers and custom `ssr` environments keep the real module, everything else gets the client rewrite. On Vite 4 and 5, the plugin falls back to the legacy SSR transform flag. ## Next steps [#next-steps] # Editor integration (/docs/getting-started/editor-integration) ArkType schema strings are still strings to the TypeScript language service. ArkEnv keeps them highlighted in the editor, and TypeScript infers keys on `env` from the same declaration you validate at runtime. ## Syntax highlighting and inline type errors [#syntax-highlighting-and-inline-type-errors] ArkType schema strings are still strings to the TypeScript language service. The [ArkType VS Code extension](https://marketplace.visualstudio.com/items?itemName=arktypeio.arkdark) and [JetBrains plugin](https://plugins.jetbrains.com/plugin/27099-arktype) highlight those strings and report type errors inline, so a bad `number.port` shows up before you run the app. The extension highlights calls whose name contains `ark`. Keep the **default** import name `arkenv`: ```diff title="./env.ts" - import createEnv from "@arkenv/core"; + import arkenv from "@arkenv/core"; - export const env = createEnv({ + export const env = arkenv({ PORT: "number.port = 3000", DEBUG: "boolean = false", }); ``` Skip this section and [ArkThemes](#arkthemes) if you validate with [`@arkenv/standard`](/docs/reference/standard). ArkType editor tools do **not** apply to Zod, Valibot, etc. ## ArkThemes [#arkthemes] The extension highlights syntax. ArkThemes colors those strings so they read like TypeScript, in light and dark mode, in VS Code and Cursor. Visit the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=arktypeio.arkthemes) to install. ## Autocomplete on `env.*` [#autocomplete-on-env] Once the schema typechecks, `env.` completes with the keys you declared and the types ArkEnv inferred. That only holds when application code imports `env`. Reading `process.env` skips both the check and the completion list. ```ts title="./server.ts" twoslash // @filename: env.ts import arkenv from "@arkenv/core"; export const env = arkenv({ PORT: "number.port = 3000", }); // @filename: server.ts // ---cut--- import { env } from "./env"; console.log(`Server is running on port ${env.PORT}`); // @annotate: Always use env.PORT rather than process.env.PORT to preserve typesafety. ``` # Start with an example (/docs/getting-started/examples) An empty directory is the fastest way to see a typed `env` object. ArkEnv's CLI scaffolds an example there, before you add one to your own repo. npm pnpm yarn bun nub ```bash npx arkenv init --example ``` ```bash pnpm dlx arkenv init --example ``` ```bash yarn dlx arkenv init --example ``` ```bash bunx arkenv init --example ``` ```bash nubx arkenv init --example ``` Run that in an empty directory. The CLI writes the example in place. Pass `--force` only when you accept overwriting files that are already there. Skip `--example` and follow [Installation](/docs/getting-started/installation) instead. Init will adopt the existing project. ## Examples [#examples] Each card is an `--example` name. Pass that name and the CLI scaffolds the project. `degit` copies the example directory. You still install dependencies and copy `.env.example` yourself. npm pnpm yarn bun nub ```bash npx degit yamcodes/arkenv/examples/ cd cp .env.example .env npm install npm run dev ``` ```bash pnpm dlx degit yamcodes/arkenv/examples/ cd cp .env.example .env pnpm install pnpm run dev ``` ```bash yarn dlx degit yamcodes/arkenv/examples/ cd cp .env.example .env yarn install yarn dev ``` ```bash bunx degit yamcodes/arkenv/examples/ cd cp .env.example .env bun install bun run dev ``` ```bash nubx degit yamcodes/arkenv/examples/ cd cp .env.example .env nub install nub run dev ``` # Getting started (/docs/getting-started) If you're new to ArkEnv, you can follow these steps to get started. ### Install ArkEnv [#install-arkenv] Run the interactive CLI to detect your stack, write a schema, and wire the matching plugin. npm pnpm yarn bun nub ```bash npx arkenv init ``` ```bash pnpm dlx arkenv init ``` ```bash yarn dlx arkenv init ``` ```bash bunx arkenv init ``` ```bash nubx arkenv init ``` To learn more about installing ArkEnv, see the [installation guide](/docs/getting-started/installation). ### Choose your learning path [#choose-your-learning-path] # Installation (/docs/getting-started/installation) ## Prerequisites [#prerequisites] * **Node.js**: [Active or Maintenance LTS](https://nodejs.org/en/about/previous-releases). * **Package manager**: npm, pnpm, Yarn, or Bun (required for the interactive CLI). ArkEnv is designed to drop into a new or existing TypeScript app. ArkEnv provides an interactive CLI that detects your stack, writes a schema, and wires framework config. npm pnpm yarn bun nub ```bash npx arkenv init ``` ```bash pnpm dlx arkenv init ``` ```bash yarn dlx arkenv init ``` ```bash bunx arkenv init ``` ```bash nubx arkenv init ``` Omit a project name to mutate the current directory. Pass a name to create a new folder (optionally from `--example`): npm pnpm yarn bun nub ```bash npx arkenv init my-app --example with-vite-react --preset vercel ``` ```bash pnpm dlx arkenv init my-app --example with-vite-react --preset vercel ``` ```bash yarn dlx arkenv init my-app --example with-vite-react --preset vercel ``` ```bash bunx arkenv init my-app --example with-vite-react --preset vercel ``` ```bash nubx arkenv init my-app --example with-vite-react --preset vercel ``` For agent sessions, use `--agent` (implies `--yes --quiet --json`): npm pnpm yarn bun nub ```bash npx arkenv init --agent ``` ```bash pnpm dlx arkenv init --agent ``` ```bash yarn dlx arkenv init --agent ``` ```bash bunx arkenv init --agent ``` ```bash nubx arkenv init --agent ``` ## Local installation (recommended workflow) [#local-installation-recommended-workflow] Install the runtime validation engine as a dependency, and the `arkenv` CLI as a `devDependency`: npm pnpm yarn bun nub ```bash npm install @arkenv/core npm install -D arkenv ``` ```bash pnpm add @arkenv/core pnpm add -D arkenv ``` ```bash yarn add @arkenv/core yarn add --dev arkenv ``` ```bash bun install @arkenv/core bun install --dev arkenv ``` ```bash nub add @arkenv/core nub add -D arkenv ``` If you are using Zod: npm pnpm yarn bun nub ```bash npm install @arkenv/standard zod npm install -D arkenv ``` ```bash pnpm add @arkenv/standard zod pnpm add -D arkenv ``` ```bash yarn add @arkenv/standard zod yarn add --dev arkenv ``` ```bash bun install @arkenv/standard zod bun install --dev arkenv ``` ```bash nub add @arkenv/standard zod nub add -D arkenv ``` If you are using Valibot: npm pnpm yarn bun nub ```bash npm install @arkenv/standard valibot @valibot/to-json-schema npm install -D arkenv ``` ```bash pnpm add @arkenv/standard valibot @valibot/to-json-schema pnpm add -D arkenv ``` ```bash yarn add @arkenv/standard valibot @valibot/to-json-schema yarn add --dev arkenv ``` ```bash bun install @arkenv/standard valibot @valibot/to-json-schema bun install --dev arkenv ``` ```bash nub add @arkenv/standard valibot @valibot/to-json-schema nub add -D arkenv ``` ### Why install locally? [#why-install-locally] * **Deterministic versioning**: Locks the exact CLI version in `package-lock.json` or `pnpm-lock.yaml`, ensuring absolute consistency across team members and CI pipelines. * **Zero latency & offline execution**: Executions run instantly from `node_modules/.bin/arkenv` without querying npm, working completely offline. * **CI/CD reliability**: Pipelines execute the version frozen in the lockfile instead of dynamically resolving `@latest`. Once installed locally, running `npx arkenv ` (or `pnpm arkenv`, `bun arkenv`) inside your project automatically executes your locked local binary from `node_modules/.bin`. ### Wire into `package.json` scripts [#wire-into-packagejson-scripts] Add `arkenv check` to your project scripts to validate environment variables before builds: ```json title="package.json" { "scripts": { "build": "arkenv check && next build", "env:check": "arkenv check" } } ``` Flags, output files, and refusal codes live in [`init` reference](/docs/reference/init). ## Add to an existing repository [#add-to-an-existing-repository] When the CLI finds a `package.json`, it adopts the repo incrementally. ### Run the interactive CLI [#run-the-interactive-cli] npm pnpm yarn bun nub ```bash npx arkenv init ``` ```bash pnpm dlx arkenv init ``` ```bash yarn dlx arkenv init ``` ```bash bunx arkenv init ``` ```bash nubx arkenv init ``` The CLI refuses a dirty git working tree. Commit or stash (`git stash -u`) first, or pass [`--force`](/docs/reference/init#force--f) if you accept overwriting generated files. The CLI **never** reads your actual `.env` files. Suggested schema key names come from `.env.example` if you have one, otherwise from `process.env` / `import.meta.env` usage in source. ### Answer the wizard [#answer-the-wizard] The CLI asks about framework, validator, and hosting preset. If `compilerOptions.strict` is off in `tsconfig.json`, the wizard offers to enable it (recommended). ### Review generated files [#review-generated-files] After the CLI, a Next.js app (for example) looks like this: If the project has a `src/` directory, the schema lands at `src/env.ts` instead. ### Import `env` in your app [#import-env-in-your-app] If you don't have a `.env` file yet, copy the example and fill in values: ```bash title="Terminal" cp .env.example .env ``` Import `env` **instead of** `process.env` (or `import.meta.env`): ```ts title="./app/page.tsx" twoslash // @filename: env.ts import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", }); // @filename: app/page.tsx // ---cut--- import { env } from "../env"; console.log(env.DATABASE_URL); ``` ## Manual installation [#manual-installation] Prefer a hands-on setup? Install the validation engine yourself, then add a framework package from the [framework guides](/docs/frameworks). ### Validation engine [#validation-engine] [`@arkenv/core`](/docs/reference/core) is the ArkType-powered engine. `arktype` is a **required** peer. npm pnpm yarn bun nub ```bash npm install @arkenv/core arktype ``` ```bash pnpm add @arkenv/core arktype ``` ```bash yarn add @arkenv/core arktype ``` ```bash bun install @arkenv/core arktype ``` ```bash nub add @arkenv/core arktype ``` If you use Zod or Valibot, install [`@arkenv/standard`](/docs/reference/standard) instead: npm pnpm yarn bun nub ```bash npm install @arkenv/standard zod ``` ```bash pnpm add @arkenv/standard zod ``` ```bash yarn add @arkenv/standard zod ``` ```bash bun install @arkenv/standard zod ``` ```bash nub add @arkenv/standard zod ``` npm pnpm yarn bun nub ```bash npm install @arkenv/standard valibot @valibot/to-json-schema ``` ```bash pnpm add @arkenv/standard valibot @valibot/to-json-schema ``` ```bash yarn add @arkenv/standard valibot @valibot/to-json-schema ``` ```bash bun install @arkenv/standard valibot @valibot/to-json-schema ``` ```bash nub add @arkenv/standard valibot @valibot/to-json-schema ``` ### TypeScript module resolution [#typescript-module-resolution] `@arkenv/standard` subpath exports (`/valibot`, `/zod-mini`) and the framework plugin subpaths require modern TypeScript module resolution. Set `compilerOptions.moduleResolution` to `"bundler"`, `"node16"`, or `"nodenext"`. Legacy `"node"` resolution is not supported. ## Next steps [#next-steps] # Using AI with ArkEnv (/docs/guides/ai) ArkEnv is designed to work seamlessly with AI coding assistants. ArkEnv provides features that help AI understand your environment schema and work more efficiently. ## Coding-agent plugin [#coding-agent-plugin] Most coding agents like Claude Code, Cursor, and Codex support plugins: a combination of skills, slash commands, and MCP tools. ArkEnv provides `@arkenv/agent-plugin` so those hosts can scaffold a project and catch unvalidated env access without grepping the tree. You can add the plugin with: npm pnpm yarn bun nub ```bash npx plugins add yamcodes/arkenv ``` ```bash pnpm dlx plugins add yamcodes/arkenv ``` ```bash yarn dlx plugins add yamcodes/arkenv ``` ```bash bunx plugins add yamcodes/arkenv ``` ```bash nubx plugins add yamcodes/arkenv ``` Once it is installed, ask your assistant to add ArkEnv, or paste a prompt from [Prompts that work well](#prompts-that-work-well). The bundled skill and MCP tools are picked up automatically. You do not need to type a slash command. The plugin teaches agents: * Scaffold with `arkenv init --agent` instead of writing `env.ts` by hand * Audit for raw `process.env` / `import.meta.env` access, server secrets on the client, public-prefix mistakes, and leftover v0 ambient `.d.ts` files Type `/arkenv:init` or `/arkenv:audit` in the agent. Those are shortcuts for the same MCP workflows. Add the MCP server in config instead. See the [`@arkenv/agent-plugin` reference](/docs/reference/agent-plugin). ## Agent skill [#agent-skill] Skip this section. The plugin already includes the skill. Use the skill only when the host cannot load plugins. [Agent Skills](https://agentskills.io) are an open standard that give new capabilities and expertise to agents. They're folders of instructions, scripts, and resources that agents can use to do things more accurately. You can add the ArkEnv skill to your agent, making it an expert in ArkEnv and env validation: npm pnpm yarn bun nub ```bash npx skills add yamcodes/arkenv ``` ```bash pnpm dlx skills add yamcodes/arkenv ``` ```bash yarn dlx skills add yamcodes/arkenv ``` ```bash bunx skills add yamcodes/arkenv ``` ```bash nubx skills add yamcodes/arkenv ``` `arkenv init` does not run this install. When the skill is missing from an existing project, init prints the same command in its next-steps note so you can run it yourself. The skill teaches agents: * Schema authoring for ArkType (`@arkenv/core`) and Standard Schema (`@arkenv/standard` with Zod or Valibot) * Client and server prefixes (`NEXT_PUBLIC_`, `NUXT_PUBLIC_`, `VITE_`, `BUN_PUBLIC_`) * Non-interactive scaffolding with [`--agent`](/docs/reference/init#agent) * `.env.example` templates without copying secret values Find more skills at [skills.sh](https://skills.sh). ## Prompts that work well [#prompts-that-work-well] Copy these into the assistant when you want it to use the CLI, honor public prefixes, and skip a `runtimeEnv` map. **Greenfield setup** ```text Add ArkEnv to this repo. Run `npx arkenv init --agent`, parse the JSON on stdout, and only retry with `--force` if the refusal's `nextActions` include a `run-command` with `--force`. Do not hand-write next.config or env.ts unless init fails. ``` **Existing keys** ```text We already have a .env.example. Run `npx arkenv init --agent` and accept the detected keys for the schema. Keep .env.example values empty or placeholder-only; never copy secrets from .env into git. ``` **Migrate raw process.env usage** ```text Find direct `process.env` and `import.meta.env` reads and route them through the typed `env` export from env.ts. Do not add a runtimeEnv map. Match the framework public prefix (NEXT_PUBLIC_, NUXT_PUBLIC_, VITE_, BUN_PUBLIC_). ``` **Add a variable** ```text Add DATABASE_URL to the ArkEnv schema with the right type, update .env.example with an empty value, and document the key in a comment if it is non-obvious. Do not expose server keys on the client. ``` **Hosting preset** ```text Add Vercel hosting system variables to the ArkEnv schema using the provider snippet from `/docs/core-concepts/hosting-presets`. ``` ## Machine-readable documentation [#machine-readable-documentation] ArkEnv's documentation site is optimized for AI consumption. Fetch markdown instead of HTML when you want a smaller context window. ### Direct markdown routes [#direct-markdown-routes] Append `.md` or `.mdx` to a docs URL, or use `/llms.mdx/docs/...`: * `/docs/frameworks/nextjs.md` * `/docs/core-concepts/client-vs-server.md` * `/llms.mdx/docs/frameworks/nextjs` ### Curated index (`/llms.txt`) [#curated-index-llmstxt] A short map of the docs lives at [/llms.txt](/llms.txt). ### Full documentation (`/llms-full.txt`) [#full-documentation-llms-fulltxt] Every page concatenated: [/llms-full.txt](/llms-full.txt). ## Next steps [#next-steps] # Guides (/docs/guides) Assistants, NestJS, Nub, Docker, and migrations sit in this section. ArkEnv's guides cover those paths, from T3 Env and a `getEnv` helper through v0. Framework and validator setups live in their own sections. Start here when the question is a migration or a host recipe. # Migrating from a getEnv helper (/docs/guides/migrating-from-a-getenv-helper) ArkEnv is designed to replace a handwritten `getEnv` helper. ArkEnv parses and coerces environment variables from one schema, and framework plugins keep server secrets off the client. A presence check is often the first helper a team writes. It installs nothing and types every key as `string`. It holds while every variable is a required string. Booleans, ports, and a browser bundle are where it fails quietly. ## The DIY helper [#the-diy-helper] Most handwritten helpers check whether required keys exist in `process.env`: ```ts title="./env.ts" export function getEnv() { const { PORT, DEBUG, DATABASE_URL, STRIPE_SECRET } = process.env; if ( PORT === undefined || DEBUG === undefined || DATABASE_URL === undefined || STRIPE_SECRET === undefined ) { throw new Error("Missing required environment variables"); } return { PORT, DEBUG, DATABASE_URL, STRIPE_SECRET }; } ``` This helper works when all variables are required strings. The breakdown happens when values need coercion or client/server separation. ## Failure classes comparison [#failure-classes-comparison] The same four inputs, and what each approach does with them: | Failure class | Helper | ArkEnv | | ------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Boolean (`DEBUG=false`) | Truthy string `"false"` | Coerces to boolean `false` | | Number (`PORT` + 1) | Concatenation `"30001"` | Integer arithmetic `3001` | | Empty strings (`DATABASE_URL=`) | Presence check passes; returns `""` | Fails the `string.url` rule at boot (invalid URL), **not** a generic presence / “missing” check. Note: `emptyAsUndefined` is **opt-in**; default does not treat `""` as missing. | | Client/server secrets | No boundary; a server secret imported on the client can leak or silently go `undefined` | Framework plugins enforce the public prefix and block server-key access / server-schema import on the client | ## Where the helper breaks [#where-the-helper-breaks]
### Boolean coercion [#1-boolean-coercion] In JavaScript, any non-empty string is truthy—including `"false"`: ```ts title="./server.ts" function getEnv() { const debug = process.env.DEBUG; if (debug === undefined) { throw new Error("missing DEBUG"); } return { debug }; } const { debug } = getEnv(); if (debug) { // always runs when DEBUG is set — including DEBUG=false } ``` ArkEnv automatically coerces `"true"` and `"false"` into boolean primitives before validation.
### Numeric operations [#2-numeric-operations] Because `process.env` only holds strings, numeric operations without coercion lead to concatenation: ```ts title="./server.ts" const { port } = getEnv(); // port: string listen(port); port + 1; // "30001" port > 1024; // lexicographic, not numeric ``` ArkEnv parses integers and numbers directly, validating ranges (`number.port` or `0 <= number <= 65535`) at boot.
### Empty string values [#3-empty-string-values] When an environment variable is set to an empty string in `.env`: ```ini title=".env" DATABASE_URL= ``` A helper checking `=== undefined` lets `""` pass without error. In ArkEnv, declaring `DATABASE_URL: "string.url"` fails the URL validation rule at boot because `""` is not a valid URL. Note that ArkEnv defaults to preserving empty strings; ignoring them requires the opt-in `emptyAsUndefined: true` option.
### Client/server secret boundary [#4-clientserver-secret-boundary] When a helper exports `STRIPE_SECRET` from a shared module: ```ts title="./env.ts" export const env = getEnv(); // env.STRIPE_SECRET accessed in a client component ``` Bundlers importing this helper into the browser either bundle the plaintext secret or evaluate it as `undefined`. ArkEnv's framework plugins (Next.js, Nuxt, Vite, Bun) enforce client prefixes (such as `NEXT_PUBLIC_` or `NUXT_PUBLIC_`) and block server keys from leaking to browser bundles.
## Graduating to ArkEnv [#graduating-to-arkenv] Replace the helper with one schema in `./env.ts`. The keys stay. The types stop being strings, and a bad value fails at import. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ NAME: "string", PORT: "number.port", DEBUG: "boolean = false", DATABASE_URL: "string.url", NODE_ENV: "'development' | 'production' | 'test' = 'development'", }); env.PORT; // number env.DEBUG; // boolean ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ NAME: z.string(), PORT: z.int().min(0).max(65535), DEBUG: z.boolean().default(false), DATABASE_URL: z.url(), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); env.PORT; // number env.DEBUG; // boolean ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ NAME: v.string(), PORT: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), DEBUG: v.optional(v.boolean(), false), DATABASE_URL: v.pipe(v.string(), v.url()), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), }); env.PORT; // number env.DEBUG; // boolean ``` To scaffold ArkEnv in your project: npm pnpm yarn bun nub ```bash npx arkenv init ``` ```bash pnpm dlx arkenv init ``` ```bash yarn dlx arkenv init ``` ```bash bunx arkenv init ``` ```bash nubx arkenv init ``` ## Next steps [#next-steps] # Migrating from T3 Env (/docs/guides/migrating-from-t3-env) A T3 Env Next.js setup keeps a `runtimeEnv` map in sync with the schema. ArkEnv keeps the flat `env` object and drops that map. T3 Env is a major inspiration for that security model. The cheatsheet of what else changes is [Why ArkEnv?](/docs/why-arkenv). ## What changes [#what-changes] Edit files against this mapping. The flat schema replaces the nested `server` / `client` / `runtimeEnv` object. | Feature | T3 Env | ArkEnv | | ---------------- | --------------------------------------- | --------------------------------------------------------------------- | | Core function | `createEnv` from `@t3-oss/env-nextjs` | `arkenv` from `@arkenv/nextjs` or `@arkenv/core` | | Schema structure | Nested `{ server, client, runtimeEnv }` | Flat schema (optional two-module recipe for name isolation) | | Client inlining | Manual `runtimeEnv` object | `withArkEnv` writes the mapping | | Validator | Zod only | ArkType via `@arkenv/core`, or Zod and Valibot via `@arkenv/standard` | A flat layout still infers types and throws if client code reads a server key. Use the [two-module recipe](/docs/core-concepts/client-vs-server#advanced-two-module-recipe) when secret **names** must stay out of the client graph. ## Next.js migration steps [#nextjs-migration-steps] Work through these steps on an existing Next.js app. ### Install ArkEnv and remove T3 Env [#install-arkenv-and-remove-t3-env] ArkType: npm pnpm yarn bun nub ```bash npm install @arkenv/core @arkenv/nextjs arktype npm rm @t3-oss/env-nextjs ``` ```bash pnpm add @arkenv/core @arkenv/nextjs arktype pnpm remove @t3-oss/env-nextjs ``` ```bash yarn add @arkenv/core @arkenv/nextjs arktype yarn remove @t3-oss/env-nextjs ``` ```bash bun install @arkenv/core @arkenv/nextjs arktype bun remove @t3-oss/env-nextjs ``` ```bash nub add @arkenv/core @arkenv/nextjs arktype nub remove @t3-oss/env-nextjs ``` Zod: npm pnpm yarn bun nub ```bash npm install @arkenv/standard @arkenv/nextjs zod npm rm @t3-oss/env-nextjs ``` ```bash pnpm add @arkenv/standard @arkenv/nextjs zod pnpm remove @t3-oss/env-nextjs ``` ```bash yarn add @arkenv/standard @arkenv/nextjs zod yarn remove @t3-oss/env-nextjs ``` ```bash bun install @arkenv/standard @arkenv/nextjs zod bun remove @t3-oss/env-nextjs ``` ```bash nub add @arkenv/standard @arkenv/nextjs zod nub remove @t3-oss/env-nextjs ``` Valibot: npm pnpm yarn bun nub ```bash npm install @arkenv/standard @arkenv/nextjs valibot @valibot/to-json-schema npm rm @t3-oss/env-nextjs ``` ```bash pnpm add @arkenv/standard @arkenv/nextjs valibot @valibot/to-json-schema pnpm remove @t3-oss/env-nextjs ``` ```bash yarn add @arkenv/standard @arkenv/nextjs valibot @valibot/to-json-schema yarn remove @t3-oss/env-nextjs ``` ```bash bun install @arkenv/standard @arkenv/nextjs valibot @valibot/to-json-schema bun remove @t3-oss/env-nextjs ``` ```bash nub add @arkenv/standard @arkenv/nextjs valibot @valibot/to-json-schema nub remove @t3-oss/env-nextjs ``` ### Wrap `next.config.ts` [#wrap-nextconfigts] ```ts title="./next.config.ts" twoslash import type { NextConfig } from "next"; import { withArkEnv } from "@arkenv/nextjs/config"; const nextConfig: NextConfig = {}; export default withArkEnv(nextConfig); ``` Without ArkType, import `withArkEnv` from `@arkenv/nextjs/standard/config`. ### Replace `createEnv` with a flat schema [#replace-createenv-with-a-flat-schema] T3 Env: ```ts title="./src/env.js" import { createEnv } from "@t3-oss/env-nextjs"; import * as z from "zod"; export const env = createEnv({ server: { DATABASE_URL: z.url(), }, client: { NEXT_PUBLIC_APP_URL: z.url(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, }, }); ``` ArkEnv. `withArkEnv` generates `.arkenv/env.gen.ts`. Zod and Valibot import `withArkEnv` from `@arkenv/nextjs/standard/config`. ```ts title="./env.ts" twoslash import arkenv from "@/.arkenv"; export const env = arkenv({ DATABASE_URL: "string.url", NEXT_PUBLIC_APP_URL: "string.url", }); ``` ```ts title="./env.ts" twoslash import arkenv from "@/.arkenv"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), NEXT_PUBLIC_APP_URL: z.url(), }); ``` ```ts title="./env.ts" twoslash import arkenv from "@/.arkenv"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), NEXT_PUBLIC_APP_URL: v.pipe(v.string(), v.url()), }); ``` You do not copy keys into `runtimeEnv`. `NEXT_PUBLIC_` marks client variables. ### Retarget imports [#retarget-imports] Change `import { env } from "~/env"` (or `src/env.js`) to your new `./env` module. ### Optional: two-module recipe [#optional-two-module-recipe] If you used two `createEnv` calls in T3 Env for name/type isolation, mirror that with two ArkEnv modules and two imports — not a CLI `--strict` flag. See [Client vs. server](/docs/core-concepts/client-vs-server#advanced-two-module-recipe). ## Next steps [#next-steps] # Migrating to v1 (/docs/guides/migrating-to-v1) On v0, the npm package `arkenv` was the library. ArkEnv v1 moves the runtime off that name and uses one `import { env } from "./env"` on every host. v0's `arkenv` package was the library. On v1 that name is the CLI, and importing it as a library throws. Vite and Bun no longer take a schema in the plugin call. Packages ship as `1.0.0-rc.n` under the `rc` npm tag (and `latest` during the RC window). APIs are frozen for the final validation pass before 1.0.0. Use [Migrating from T3 Env](/docs/guides/migrating-from-t3-env) instead. This page is the v0 to v1 package move. Set TypeScript `moduleResolution` to `"bundler"`, `"node16"`, or `"nodenext"`. Legacy `"node"` does not load package `exports` subpaths such as `@arkenv/standard/valibot`. ## Package names [#package-names] On v0, `arkenv` on npm was the runtime. On v1 that name is the **CLI**. Importing `arkenv` as a library throws. Swap packages first. | v0 | v1 | Role | | --------------------- | --------------------- | -------------------------------------------------- | | `arkenv` | `@arkenv/core` | Runtime and ArkType engine | | `@arkenv/cli` | `arkenv` | CLI (`init`, `check`) | | — | `@arkenv/standard` | Zod, Valibot, and other Standard Schema validators | | `@arkenv/vite-plugin` | `@arkenv/vite-plugin` | Vite plugin (transform mode only) | | `@arkenv/bun-plugin` | `@arkenv/bun-plugin` | Bun plugin (transform mode only) | | `@arkenv/nextjs` | `@arkenv/nextjs` | Next.js adapter | | `@arkenv/nuxt` | `@arkenv/nuxt` | Nuxt module | ### Swap the runtime and CLI packages [#swap-the-runtime-and-cli-packages] Remove the v0 library (and `@arkenv/cli` if you had it), then install `@arkenv/core` plus the CLI: npm pnpm yarn bun nub ```bash npm rm arkenv @arkenv/cli npm install @arkenv/core arktype npm install -D arkenv ``` ```bash pnpm remove arkenv @arkenv/cli pnpm add @arkenv/core arktype pnpm add -D arkenv ``` ```bash yarn remove arkenv @arkenv/cli yarn add @arkenv/core arktype yarn add --dev arkenv ``` ```bash bun remove arkenv @arkenv/cli bun install @arkenv/core arktype bun install --dev arkenv ``` ```bash nub remove arkenv @arkenv/cli nub add @arkenv/core arktype nub add -D arkenv ``` If you want Zod or Valibot without ArkType, install [`@arkenv/standard`](/docs/validators) instead of `@arkenv/core` and `arktype`. ### Point imports at the new packages [#point-imports-at-the-new-packages] Replace `from "arkenv"` with `from "@arkenv/core"` (or `@arkenv/standard`). Framework packages keep their names. Keep exporting `env` from your schema module and import it as `{ env } from "./env"`. ## Canonical `env` object [#canonical-env-object] Every framework now uses one validated object: ```ts import { env } from "./env"; ``` On v0, Vite and Bun plugins accepted the schema as the first argument (`arkenv({ VITE_API_URL: "string" })`), rewrote `import.meta.env` or `process.env`, and asked you to merge `ImportMetaEnvAugmented` or `ProcessEnvAugmented` into a `.d.ts` file. That **schema/define** API is gone. Those plugins only rewrite imports of `./env` in the client graph. They do not take a schema. The ambient path could only check values on the build machine, and it only rewrote static reads. Spreads, `import.meta.env[key]`, and aliases still saw raw strings while TypeScript claimed parsed types. A real `env.ts` module lets the plugin inline public keys and stub server secrets, and it validates private keys when the server boots. ## Vite [#vite] Follow these steps if the v0 plugin still receives a schema argument. ### Export `env` from `env.ts` [#export-env-from-envts] ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", VITE_API_URL: "string", PORT: "number.port = 3000", }); ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), VITE_API_URL: z.string(), PORT: z.int().min(0).max(65535).default(3000), }); ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), VITE_API_URL: v.string(), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), }); ``` ### Register the plugin with no schema [#register-the-plugin-with-no-schema] ```diff title="./vite.config.ts" import { defineConfig } from "vite"; import arkenvPlugin from "@arkenv/vite-plugin"; export default defineConfig({ - plugins: [arkenvPlugin({ - VITE_API_URL: "string", - DATABASE_URL: "string", - })], + plugins: [arkenvPlugin()], }); ``` For Zod or Valibot, import from `@arkenv/vite-plugin/standard`. ### Delete ambient `ImportMetaEnv` merges [#delete-ambient-importmetaenv-merges] ```diff title="./src/vite-env.d.ts" /// - - type ImportMetaEnvAugmented = import("@arkenv/vite-plugin").ImportMetaEnvAugmented< - typeof import("./env").Env - >; - - interface ImportMetaEnv extends ImportMetaEnvAugmented {} ``` ### Read `env` in application code [#read-env-in-application-code] ```diff title="./src/App.tsx" + import { env } from "../env"; function App() { - const apiUrl = import.meta.env.VITE_API_URL; + const apiUrl = env.VITE_API_URL; return
API: {apiUrl}
; } ```
See the [Vite guide](/docs/frameworks/vite) for transform mode and reusing a compiled schema in `vite.config.ts`. ## Bun [#bun] Same shape as Vite: schema in `env.ts`, plugin with no schema argument. ### Export `env` from `env.ts` [#export-env-from-envts-1] ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ BUN_PUBLIC_API_URL: "string.url", DATABASE_URL: "string", }); ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ BUN_PUBLIC_API_URL: z.url(), DATABASE_URL: z.url(), }); ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ BUN_PUBLIC_API_URL: v.pipe(v.string(), v.url()), DATABASE_URL: v.pipe(v.string(), v.url()), }); ``` ### Register `@arkenv/bun-plugin` without a schema [#register-arkenvbun-plugin-without-a-schema] Keep the plugin in `bunfig.toml` or `Bun.build`. Do not pass the schema object. Backend-only processes can skip the plugin and call `@arkenv/core` directly. See the [Bun guide](/docs/frameworks/bun). ### Delete ambient `ProcessEnv` merges [#delete-ambient-processenv-merges] ```diff title="./bun-env.d.ts" /// - - type ProcessEnvAugmented = import("@arkenv/bun-plugin").ProcessEnvAugmented< - typeof import("./src/env").default - >; - - declare namespace NodeJS { - interface ProcessEnv extends ProcessEnvAugmented {} - } ``` ### Read `env` in application code [#read-env-in-application-code-1] ```diff title="./src/app.tsx" + import { env } from "../env"; export function App() { - const apiUrl = process.env.BUN_PUBLIC_API_URL; + const apiUrl = env.BUN_PUBLIC_API_URL; return
API: {apiUrl}
; } ```
## Next.js and Nuxt [#nextjs-and-nuxt] Package names for `@arkenv/nextjs` and `@arkenv/nuxt` did not change. After you switch the runtime import from `arkenv` to `@arkenv/core` (or the framework package), keep using `withArkEnv` or the Nuxt module. Flat `arkenv(schema, { exposeToClient })` is the only supported call shape. Nested `{ server, client, shared, runtimeEnv }` bags and the old `expose` / `shared` option aliases are gone — see [Removed in v1](#removed-in-v1) below. Prefer the guides under [Client vs. server](/docs/core-concepts/client-vs-server), [Next.js](/docs/frameworks/nextjs), and [Nuxt](/docs/frameworks/nuxt). ## Strict layout removed (alpha hard cut) [#strict-layout-removed-alpha-hard-cut] Earlier alphas shipped `npx arkenv init --strict` plus `@arkenv/nextjs/server` / `@arkenv/nuxt/client` (and related subpaths). That layout engine is gone. Flat `env.ts` is the only first-class path. `withArkEnv` no longer takes a `layout` option, including the old `"simple"` alias. Delete `layout: "simple"` (and `"flat"` or `"strict"`) from Next.js and Nuxt config. If you relied on `--strict` for name/type isolation, keep two modules by hand — the same shape as two T3 `createEnv` calls: ```ts title="./env/client.ts" import arkenv from "@/.arkenv"; export const env = arkenv({ NEXT_PUBLIC_API_URL: "string", }); ``` ```ts title="./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] }, ); ``` On Nuxt, use `@arkenv/nuxt` for the client module and `@arkenv/core` for the server module. Never import the server module from client code. Full recipe: [Client vs. server](/docs/core-concepts/client-vs-server#advanced-two-module-recipe). ## Dead-code elimination [#dead-code-elimination] `if (import.meta.env.VITE_FLAG)` can constant-fold in a bundler. `if (env.VITE_FLAG)` does not. v1 takes that trade-off: the client stub is a few bytes per key, no validator ships to the browser, and server secrets stay off the client. ## CLI schema location [#cli-schema-location] Older init runs wrote a `package.json` `"arkenv"` field so the CLI could find a non-convention schema path. That pointer is gone. Discovery is `--schema` / `-s`, then convention paths (`env.ts`, `src/env.ts`). If your schema sits elsewhere, put the path on `--schema` in scripts: ```json { "scripts": { "check": "arkenv check --schema config/env.ts" } } ``` You can delete a leftover `"arkenv"` key from `package.json`; the CLI ignores it either way. See [check](/docs/reference/check). ## Next.js `ArkEnvScript` removed [#nextjs-arkenvscript-removed] `` and `globalThis.__arkenv_env__` are gone from `@arkenv/nextjs`. Client public env follows Next.js build-time inlining of `NEXT_PUBLIC_*` via codegen `runtimeEnv`. Runtime public-env injection without a rebuild is outside ArkEnv — use a dedicated ecosystem tool such as [`next-runtime-env`](https://github.com/expatfile/next-runtime-env), or keep dynamic public config on the server and pass it as props. See [Next.js and Docker](/docs/guides/nextjs-docker) for the bake-time default, the server-pass-props alternative, and when an ecosystem library is the right escape hatch. If you had `` in a root layout, delete the import and the component. Rebuild (or retag) images when public client values change. ## Removed in v1 [#removed-in-v1] These APIs no longer work on [`@arkenv/nextjs`](/docs/reference/nextjs) or [`@arkenv/nuxt`](/docs/reference/nuxt). Call sites that still use them throw a migration error at runtime, and static key extraction rejects the same shapes in schema source. * Nested `arkenv({ server, client, shared, runtimeEnv })`. Use flat `arkenv(schema, { exposeToClient, runtimeEnv })` instead. * Options `expose` and `shared`. Use `exposeToClient`. See [Client vs. server](/docs/core-concepts/client-vs-server). ```diff - export const env = arkenv({ - server: { DATABASE_URL: "string" }, - client: { NEXT_PUBLIC_API_URL: "string" }, - shared: { NODE_ENV: "string" }, - runtimeEnv: { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NODE_ENV: process.env.NODE_ENV }, - }); + export const env = arkenv( + { + DATABASE_URL: "string", + NEXT_PUBLIC_API_URL: "string", + NODE_ENV: "string", + }, + { + runtimeEnv: { + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + NODE_ENV: process.env.NODE_ENV, + }, + }, + ); ``` The [changelog](https://github.com/yamcodes/arkenv/releases) lists what changed in each release. ## Next steps [#next-steps] # Next.js and Docker (/docs/guides/nextjs-docker) ArkEnv validates and types your Next.js environment variables. It does not replace Next.js’s build-time model for `NEXT_PUBLIC_*`. In Docker that means **public client values are baked at `next build`**, while **server secrets stay injectable at container start**. Nuxt can swap public config without rebuilding because Nitro’s `runtimeConfig` is a host feature. Next.js freezes `NEXT_PUBLIC_*` in the client bundle. `@arkenv/nextjs` respects that boundary instead of shipping a parallel browser global. ## Default path: baking public values at build [#default-path-baking-public-values-at-build] Enable Next.js standalone output so the image can copy `.next/standalone`. `withArkEnv` still validates the schema at build time, so every required key — including server secrets — needs a value during `npm run build`. Pass a build-only placeholder for secrets; inject the real value when the container starts. ```ts title="next.config.ts" import type { NextConfig } from "next"; import { withArkEnv } from "@arkenv/nextjs/config"; const nextConfig: NextConfig = { output: "standalone", }; export default withArkEnv(nextConfig); ``` Import `withArkEnv` from `@arkenv/nextjs/standard/config`. The ArkType import above generates a factory that cannot parse `z.url()` or `v.pipe(...)`. ```dockerfile title="Dockerfile" FROM node:22-alpine AS build WORKDIR /app COPY . . ARG NEXT_PUBLIC_API_URL # Placeholder for build-time withArkEnv validation — not the runtime secret ARG DATABASE_URL=postgres://build:build@127.0.0.1:5432/build ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV DATABASE_URL=$DATABASE_URL RUN npm ci && npm run build FROM node:22-alpine WORKDIR /app COPY --from=build /app/.next/standalone ./ COPY --from=build /app/.next/static ./.next/static COPY --from=build /app/public ./public CMD ["node", "server.js"] ``` ```bash title="Terminal" # Staging image — public URL frozen into the client bundle docker build \ --build-arg NEXT_PUBLIC_API_URL=https://api.staging.example.com \ -t app:staging . docker run -e DATABASE_URL=postgres://… app:staging # Production — different public URL means a different build docker build \ --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \ -t app:prod . ``` Keep the schema ordinary. `withArkEnv` codegen maps client keys to `process.env.*` so Next can inline them: ```ts title="env.ts" import arkenv from "@/.arkenv"; export const env = arkenv({ DATABASE_URL: "string.url", NEXT_PUBLIC_API_URL: "string.url", }); ``` ```ts title="env.ts" import arkenv from "@/.arkenv"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), NEXT_PUBLIC_API_URL: z.url(), }); ``` ```ts title="env.ts" import arkenv from "@/.arkenv"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), NEXT_PUBLIC_API_URL: v.pipe(v.string(), v.url()), }); ``` `DATABASE_URL` can change on `docker run`. `NEXT_PUBLIC_API_URL` cannot — it was replaced at build time. If you refuse to pass even a placeholder secret into the build stage, set `withArkEnv(nextConfig, { validate: false })`. You lose fail-fast checks during `next build`; runtime (or a separate `arkenv check` in CI) must catch missing keys instead. ## Dynamic public config: keeping it off `NEXT_PUBLIC_*` [#dynamic-public-config-keeping-it-off-next_public_] When a value must vary per deploy without rebuilding the client bundle, do not put it on a `NEXT_PUBLIC_*` key. Read it on the server and pass it as props (or serve it from a Route Handler). ```ts title="env.ts" import arkenv from "@/.arkenv"; export const env = arkenv({ DATABASE_URL: "string.url", // Server-only — not inlined into the client chunk API_URL: "string.url", }); ``` ```ts title="env.ts" import arkenv from "@/.arkenv"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), // Server-only — not inlined into the client chunk API_URL: z.url(), }); ``` ```ts title="env.ts" import arkenv from "@/.arkenv"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), // Server-only — not inlined into the client chunk API_URL: v.pipe(v.string(), v.url()), }); ``` ```tsx title="app/page.tsx" import { env } from "../env"; import { ClientWidget } from "./client-widget"; export default function Page() { return ; } ``` ```tsx title="app/client-widget.tsx" "use client"; export function ClientWidget({ apiUrl }: { apiUrl: string }) { return

API: {apiUrl}

; } ``` One image can then swap `API_URL` at container start. The browser never needed a build-time public literal. Build still needs placeholders (or `validate: false`) for every required server key, same as above. ## Runtime public injection without rebuilds [#runtime-public-injection-without-rebuilds] If you need client-side `NEXT_PUBLIC_*` values to change without a rebuild, that transport is outside ArkEnv. Use a dedicated ecosystem library such as [`next-runtime-env`](https://github.com/expatfile/next-runtime-env) (or a maintained fork), or an entrypoint that rewrites the built JS. ArkEnv still validates whatever lands in `process.env` on the server. It does not inject `globalThis.__arkenv_env__` or export ``. Shipping a runtime public-env global would tax every Next user — including Vercel and other build-per-environment deploys — for a Docker escape hatch that fights Next’s compiler. Nuxt gets no-rebuild public env from Nitro; Next does not, and polyfilling that gap belongs to a dedicated tool, not the validation adapter. ## Next steps [#next-steps] # Using ArkEnv with NestJS (/docs/guides/use-with-nestjs) Nest builds modules, providers, and database connections inside `NestFactory.create()`. ArkEnv validates environment variables when `env.ts` is first imported, before that call.
## Setup [#1-setup] Declare the schema in `src/env.ts` and export `env`. Importing that module is the validation step. Controllers and services then read the typed object directly. ```ts title="src/env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ NODE_ENV: "'development' | 'production' | 'test' = 'development'", PORT: "number.port = 3000", DATABASE_URL: "string", }); ``` ```ts title="src/env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), PORT: z.int().min(0).max(65535).default(3000), DATABASE_URL: z.string(), }); ``` ```ts title="src/env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), DATABASE_URL: v.string(), }); ``` Import `env` directly across your controllers, services, and modules: ```ts title="src/app.service.ts" import { Injectable } from "@nestjs/common"; import { env } from "./env"; @Injectable() export class AppService { getDatabaseUrl(): string { return env.DATABASE_URL; } } ```
## Fail-fast entrypoint [#2-fail-fast-entrypoint] Import `env` at the top of `src/main.ts`, before `NestFactory.create()`, so a bad variable stops the process first. ```ts title="src/main.ts" import { env } from "./env"; import { NestFactory } from "@nestjs/core"; import { AppModule } from "./app.module"; async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(env.PORT); } bootstrap(); ```
## Execution [#3-execution] ArkEnv does not load `.env` files. Nest has to put those values on `process.env` before `src/env.ts` evaluates. On Node.js 20.6+ and `@nestjs/cli` 11+, pass the native `--env-file` flag: ```bash nest start --env-file .env ``` On `@nestjs/cli` 10 and earlier, build the app first and run `node --env-file=.env dist/main.js`. In production, the platform or container injects the variables before the Node process starts.
## Optional DI provider [#4-optional-di-provider] Skip this section. A normal import is the default. A provider is for code that already injects config through Nest's container. Register `env` with a `Symbol` token when a service should receive it through `@Inject` instead of importing the module: ```ts title="src/env.provider.ts" import { env } from "./env"; export const ENV = Symbol("ENV"); export type Env = typeof env; export const EnvProvider = { provide: ENV, useValue: env, }; ``` Register `EnvProvider` in your module's `providers` array (and `exports` if used across modules), then inject `ENV` into services using `@Inject(ENV) private readonly env: Env`.
## Why avoid ConfigModule validation [#5-why-avoid-configmodule-validation] Do not pass an ArkEnv schema into `@nestjs/config`'s `ConfigModule.forRoot({ validationSchema })`. Use `src/env.ts` instead. Starting in `@nestjs/config` 12, `validationSchema` supports Standard Schema validators, but `@nestjs/config` passes raw string environment variables directly to the schema without automatic type coercion. A non-coercing validator (like ArkType's `number.port` or Zod's `z.number()`) rejects `"3000"` with a type error instead of parsing it. In `@nestjs/config` 11 and earlier, `validationSchema` doesn't support Standard Schema validators. Using ArkEnv directly in `src/env.ts` handles string coercion automatically and fails immediately before NestJS initializes. NestJS 12 supports Standard Schema pipes (such as `StandardSchemaValidationPipe`) for HTTP request payloads (`@Body()`, `@Query()`). HTTP validation pipes validate incoming requests at runtime and are distinct from application environment variable validation.
# Using ArkEnv with Nub (/docs/guides/use-with-nub) ArkEnv is designed to work seamlessly with [Nub](https://nubjs.com/). ArkEnv validates and types your environment variables; Nub loads `.env*` files and runs TypeScript before your entrypoint starts. Nub is not a required runtime for ArkEnv. Getting started and the framework guides keep stock runners (`npx`, `node --env-file`, framework CLIs). Use this page when your project already runs under Nub — for example the [basic example](https://github.com/yamcodes/arkenv/tree/v1/examples/basic). ## Who does what [#who-does-what] Nub injects `.env*` into `process.env` before Node starts (file runs, `nub run`, `nub watch`, and `nubx`). ArkEnv does not load dotenv files by itself. Calling `arkenv()` reads the active environment and fails fast when values are missing or wrong. ```ts title="env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string.url", PORT: "0 <= number.integer <= 65535 = 3000", }); ``` ```ts title="env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), PORT: z.int().min(0).max(65535).default(3000), }); ``` ```ts title="env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), }); ``` ```ts title="src/index.ts" import { env } from "../env"; console.log(env.PORT); ``` ```bash title="Terminal" nub src/index.ts ``` That is the default path: Nub loads files, your entry imports `env`, validation runs at module load. Nub can hand environment loading to [Varlock](https://nubjs.com/docs/runtime/varlock) when a project has an `@env-spec` `.env.schema`. That path replaces Nub's own `.env*` loader and is a different product model from ArkEnv's TypeScript `env.ts`. Prefer one owner for the environment — see [Why ArkEnv?](/docs/why-arkenv#varlock). ## Validating earlier than the first import [#validating-earlier-than-the-first-import] Import-time validation is enough when every process that needs config imports `env` early. Use the next layers when you want a failure **before** application code runs, or without starting the app at all. | When | Mechanism | Fails if | | -------------------------------- | ------------------------------------- | ------------------------------------------ | | Module load | `import { env } from "./env"` | First import of the schema module | | Before the entry under Nub | `preload` in `nub.jsonc` | Any `nub` / `nub run` / `nub watch` launch | | Dev / CI without running the app | `arkenv check` | Schema and environment disagree | | Before a build or deploy script | `arkenv check && …` in `package.json` | Check exits non-zero | | Bundler / framework build | Vite, Bun, Next.js, Nuxt adapters | Build or config setup | ### Before the entry: `preload` [#before-the-entry-preload] Nub's [`preload`](https://nubjs.com/docs/config) list runs after environment files load and before your entry file. Point it at the schema module so every Nub launch validates even scripts that never import `env`. ```jsonc title="nub.jsonc" { "$schema": "https://nubjs.com/schema/latest.json", "preload": ["./env.ts"] } ``` Keep `env.ts` declarative: export `env` from `arkenv({ … })` and avoid import-time side effects that assume a full app boot. The same rule applies to [`arkenv check`](/docs/reference/check) schema capture. With `nub watch`, Nub already restarts when `.env*` files change. Pair that with preload (or an early import) so a bad edit fails the restart instead of serving with a stale process. ### Dev and CI: `arkenv check` [#dev-and-ci-arkenv-check] [`arkenv check`](/docs/reference/check) validates the resolved environment against the schema without starting your app. Use it in pre-commit hooks, local smoke scripts, and CI. Install the CLI as a `devDependency`, then run check: npm pnpm yarn bun nub ```bash npm install -D arkenv ``` ```bash pnpm add -D arkenv ``` ```bash yarn add --dev arkenv ``` ```bash bun install --dev arkenv ``` ```bash nub add -D arkenv ``` npm pnpm yarn bun nub ```bash npx arkenv check ``` ```bash pnpm dlx arkenv check ``` ```bash yarn dlx arkenv check ``` ```bash bunx arkenv check ``` ```bash nubx arkenv check ``` Under Nub, prefer the local bin through `nubx` so the check process inherits Nub's automatic `.env*` loading the same way a file run does: ```bash title="Terminal" nubx arkenv check ``` Without Nub (or when you need a specific file set), pass `--env-file`. Check's own parser is literal dotenv — no `${VAR}` expansion — which matches Node's `--env-file`, not Nub's expanding loader: npm pnpm yarn bun nub ```bash npx arkenv check --env-file .env --env-file .env.local ``` ```bash pnpm dlx arkenv check --env-file .env --env-file .env.local ``` ```bash yarn dlx arkenv check --env-file .env --env-file .env.local ``` ```bash bunx arkenv check --env-file .env --env-file .env.local ``` ```bash nubx arkenv check --env-file .env --env-file .env.local ``` ### Build and script gates [#build-and-script-gates] Gate builds and deploy scripts on check so a bad environment fails before the compiler or bundler does expensive work: ```json title="package.json" { "scripts": { "env:check": "arkenv check", "build": "arkenv check && tsc", "start": "nub src/index.ts", "dev": "nub watch src/index.ts" } } ``` `arkenv check` in a script uses the project's `node_modules/.bin` entry. On machines where you only ever run under Nub and want the same `.env*` cascade as `nub src/index.ts`, point the script at `nubx arkenv check` instead. ### Framework builds [#framework-builds] If the app uses Vite, Bun's bundler, Next.js, or Nuxt, keep the matching ArkEnv adapter. Those plugins validate in the bundler or framework config pipeline. Running the app with `nub run dev` does not replace them — see [Frameworks](/docs/frameworks). ## Failing fast in the entry (optional) [#failing-fast-in-the-entry-optional] When you do not want project-wide preload, import `env` at the top of the process entry before other bootstrapping — the same pattern as [Using ArkEnv with NestJS](/docs/guides/use-with-nestjs): ```ts title="src/index.ts" import { env } from "../env"; import { createServer } from "node:http"; createServer((_req, res) => { res.end(`listening on ${env.PORT}`); }).listen(env.PORT); ``` ## What not to add [#what-not-to-add] These are durable product rejects ([ADR 0035](https://github.com/yamcodes/arkenv/blob/v1/docs/adr/0035-no-nub-plugin-or-varlock-style-handoff.md)): * A `nub-arkenv` CLI plugin — `nubx arkenv` and `npx arkenv` already run the same binary; a plugin would only rename the verb. * A Varlock-style Nub hand-off for `env.ts` — ArkEnv's product surface remains the imported `env` object and `arkenv check`, not an invisible dotenv replacement inside Nub's runtime. ## Next steps [#next-steps] * [`arkenv check` reference](/docs/reference/check) — flags, exit codes, and `--verify-example` * [Installation](/docs/getting-started/installation) — wire check into scripts for any package manager * [Why ArkEnv?](/docs/why-arkenv) — how ArkEnv differs from Varlock and other env tools * [Nub environment files](https://nubjs.com/docs/runtime/env) — `.env*` cascade, modes, and expansion # Introduction (/docs) ## What is ArkEnv? [#what-is-arkenv] ArkEnv is a typesafe environment variable validation library for TypeScript. You declare a schema once, `arkenv()` validates the process against that schema before application code runs, and TypeScript infers the types from the same declaration. This page shows why `process.env` is a weak contract, how ArkEnv's typed `env` object replaces it, and where to go next in these docs. ## The `process.env` problem [#the-processenv-problem] Environment variables arrive as `process.env` (or `import.meta.env`), where all values are `string | undefined`. Most teams start with a presence-check helper that verifies required keys: ```ini title=".env" PORT=3000 DEBUG=false ``` ```ts title="./env.ts" twoslash export function getEnv() { const port = process.env.PORT; const debug = process.env.DEBUG; if (port === undefined || debug === undefined) { throw new Error("Missing required environment variables"); } return { port, debug }; } ``` For presence-only strings, this helper installs nothing and gets the job done. But it breaks when variables need types and coercion: * **Booleans (`DEBUG=false`):** `process.env.DEBUG` is the string `"false"`. The presence check passes and returns `{ debug: "false" }`. Because non-empty strings are truthy in JavaScript, `if (debug)` runs even when set to `false`. * **Numbers (`PORT=3000`):** `port` remains a `string`. Arithmetic like `port + 1` evaluates to `"30001"`, and comparisons like `port > 1024` perform lexicographical checks rather than numeric ones. ArkEnv replaces manual presence checks and ad-hoc parsing with declarative schema validation and zero-config coercion. ## The `env` solution [#the-env-solution] **ArkEnv solves the environment variable scaling problem.** Declare a schema in `env.ts`; `arkenv()` validates against it so application code never touches loose `process.env`: ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ PORT: "number.port = 3000", DEBUG: "boolean = false", }); ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ PORT: z.int().min(0).max(65535).default(3000), DEBUG: z.boolean().default(false), }); ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), DEBUG: v.optional(v.boolean(), false), }); ``` The same `if` as above, after ArkEnv: ```ts title="./server.ts" twoslash // @filename: env.ts import arkenv from "@arkenv/core"; export const env = arkenv({ PORT: "number.port = 3000", DEBUG: "boolean = false", }); // @filename: server.ts // ---cut--- import { env } from "./env"; const port = env.PORT; const debug = env.DEBUG; // ^? if (debug) { console.log("verbose logging enabled"); } console.log(`listening on ${port}`); ``` Everything works like our manual solution, but the parsing logic is written declaratively in a single file. This makes the code simpler and more maintainable. In a full-stack app, we **must keep secrets off the client**. The Next.js, Nuxt, Vite, and Bun packages enforce that split. See [Client vs. server](/docs/core-concepts/client-vs-server). ArkType, Zod, and Valibot are first-class. Use [`@arkenv/core`](/docs/validators/arktype) for ArkType, or [`@arkenv/standard`](/docs/validators) for Zod and Valibot (and any other [Standard Schema](https://standardschema.dev/) validator). See the [Zod](/docs/validators/zod) and [Valibot](/docs/validators/valibot) guides. ## How to use these docs [#how-to-use-these-docs] Start with [Getting started](/docs/getting-started) to add ArkEnv to a new or existing project. [Core concepts](/docs/core-concepts) covers the schema, the typed `env` object, engines, and coercion. Some other useful reads: If you're a human, the search bar at the top will help you find your way around. If you happen to be an AI agent, see [/llms.txt](/llms.txt) for an index of all available documentation, or [/llms-full.txt](/llms-full.txt) for the full concatenated docs. See the [Using AI with ArkEnv](/docs/guides/ai) guide to learn how to use AI tools to improve your development workflow. ## Join the community [#join-the-community] If you have questions about ArkEnv, ask on [GitHub Discussions](https://github.com/yamcodes/arkenv/discussions) or [X](https://x.com/_yamcodes). # @arkenv/agent-plugin (/docs/reference/agent-plugin) Coding agents that support plugins can scaffold ArkEnv and audit unvalidated env access without grepping the tree. `@arkenv/agent-plugin` is that plugin: slash commands, a bundled skill, and an MCP server. Use the [Agent skill](/docs/guides/ai#agent-skill) instead. The plugin already includes the skill, slash commands, and MCP. Do not install both. ## Install [#install] npm pnpm yarn bun nub ```bash npx plugins add yamcodes/arkenv ``` ```bash pnpm dlx plugins add yamcodes/arkenv ``` ```bash yarn dlx plugins add yamcodes/arkenv ``` ```bash bunx plugins add yamcodes/arkenv ``` ```bash nubx plugins add yamcodes/arkenv ``` Ask the assistant to add ArkEnv. Slash commands `/arkenv:init` and `/arkenv:audit` are optional shortcuts for the same MCP tools. You do not run a second install command. ### MCP-only hosts [#mcp-only-hosts] If the host has no plugin marketplace, add the server to MCP config. `npx -y @arkenv/agent-plugin` is the process the host spawns, not a follow-up to `plugins add`. ```json { "mcpServers": { "arkenv": { "command": "npx", "args": ["-y", "@arkenv/agent-plugin"] } } } ``` ## Commands [#commands] Two slash commands wrap the CLI and the auditor. Parse JSON on stdout. Do not pass `--force` unless a refusal's `nextActions` include a `--force` run-command. ### `/arkenv:init` [#arkenvinit] Delegates to `npx arkenv init --agent`. ### `/arkenv:audit` [#arkenvaudit] Runs the TypeScript AST auditor. Each diagnostic includes `file`, `line`, `character`, `severity`, `ruleId`, `message`, and `suggestedFix`. | `ruleId` | Meaning | | -------------------- | --------------------------------------------------- | | `unvalidated-access` | `process.env` or `import.meta.env` outside `env.ts` | | `secret-leak` | Server-only key referenced from a client module | | `prefix-violation` | Public prefix on a secret-looking name | | `legacy-ambient` | v0 `ProcessEnv` / `ImportMetaEnv` `.d.ts` glob | Valid `import { env } from "./env"` usage is not flagged. ## Programmatic API [#programmatic-api] Call `auditProject` from tests or a local script when you don't want MCP. ```ts import { auditProject } from "@arkenv/agent-plugin"; const { diagnostics } = await auditProject("."); ``` ## Next steps [#next-steps] # @arkenv/bun-plugin (/docs/reference/bun-plugin) `@arkenv/bun-plugin` rewrites `env.ts` in a Bun client bundle so `BUN_PUBLIC_` keys inline and server keys throw. Backend-only scripts skip it and call [`@arkenv/core`](/docs/reference/core) directly. This page is the export and option list. Setup recipes live in the [Bun guide](/docs/frameworks/bun). ## Installation [#installation] Install the plugin as a dev dependency next to the engine you write schemas with. The plugin does not bundle `@arkenv/core` or `@arkenv/standard`. npm pnpm yarn bun nub ```bash npm install -D @arkenv/bun-plugin npm install @arkenv/core arktype ``` ```bash pnpm add -D @arkenv/bun-plugin pnpm add @arkenv/core arktype ``` ```bash yarn add --dev @arkenv/bun-plugin yarn add @arkenv/core arktype ``` ```bash bun install --dev @arkenv/bun-plugin bun install @arkenv/core arktype ``` ```bash nub add -D @arkenv/bun-plugin nub add @arkenv/core arktype ``` Or if you use Zod, Valibot, or another Standard Schema validator: npm pnpm yarn bun nub ```bash npm install -D @arkenv/bun-plugin npm install @arkenv/standard ``` ```bash pnpm add -D @arkenv/bun-plugin pnpm add @arkenv/standard ``` ```bash yarn add --dev @arkenv/bun-plugin yarn add @arkenv/standard ``` ```bash bun install --dev @arkenv/bun-plugin bun install @arkenv/standard ``` ```bash nub add -D @arkenv/bun-plugin nub add @arkenv/standard ``` ## Exports [#exports] | Export | Role | | -------------------- | ------------------------------------------ | | default | `arkenvPlugin` factory and plugin instance | | named `arkenvPlugin` | Factory and zero-config plugin instance | | `./standard` | Standard Schema plugin entry | `arkenvPlugin` is both a factory (`arkenvPlugin({ schemaPath })`) and a `BunPlugin` object with `name`, `target`, and `setup`. You can preload in `bunfig.toml` (`plugins = ["@arkenv/bun-plugin"]`), register in `Bun.build`, or pass transform options. Default client prefix: `BUN_PUBLIC_`. ## Transform options [#transform-options] Same transform call shape as [`@arkenv/vite-plugin`](/docs/reference/vite-plugin): | Option | Default | Meaning | | --------------------- | --------------- | -------------------------- | | `schemaPath` | auto (`env.ts`) | Schema module to transform | | `clientPrefix` | `BUN_PUBLIC_` | Keys exposed to the client | | `logger` / `logLevel` | (none) | Logging | The plugin validates `process.env` for that build. Set `env`, `coerce`, `onUndeclaredKey`, `arrayFormat`, `emptyAsUndefined`, `debugSecrets`, and `toJsonSchema` on `arkenv()` in `env.ts`. The plugin rejects them. See [Options](/docs/reference/options). A schema argument is rejected. Plugin config has no safe mode — failed validation cannot soft-fail into the bundle. ## Tests and programmatic builds [#tests-and-programmatic-builds] Tests and scripts often call `Bun.build()` in a process that already has environment variables set. The plugin reads `process.env`, so assign the values for that compile before the build. ```ts import arkenvPlugin from "@arkenv/bun-plugin"; const previous = process.env.BUN_PUBLIC_API_URL; process.env.BUN_PUBLIC_API_URL = "https://staging.example.com"; try { await Bun.build({ entrypoints: ["./src/index.ts"], plugins: [arkenvPlugin], }); } finally { if (previous === undefined) delete process.env.BUN_PUBLIC_API_URL; else process.env.BUN_PUBLIC_API_URL = previous; } ``` Restore the previous value in `finally`. The next compile in the same process then sees the original environment. ## Next steps [#next-steps] # check (/docs/reference/check) `arkenv check` validates the active environment against your schema without starting the app. Run it in CI, a local script, or a pre-commit hook when the failure should happen before boot. The bare command uses the environment already on the process. Add `--env-file` to load a dotenv file first (later files win). Add `--schema` when the module is not `env.ts` or `src/env.ts`. npm pnpm yarn bun nub ```bash npx arkenv check ``` ```bash pnpm dlx arkenv check ``` ```bash yarn dlx arkenv check ``` ```bash bunx arkenv check ``` ```bash nubx arkenv check ``` npm pnpm yarn bun nub ```bash npx arkenv check --env-file .env.production ``` ```bash pnpm dlx arkenv check --env-file .env.production ``` ```bash yarn dlx arkenv check --env-file .env.production ``` ```bash bunx arkenv check --env-file .env.production ``` ```bash nubx arkenv check --env-file .env.production ``` npm pnpm yarn bun nub ```bash npx arkenv check --schema ./src/env.ts --env-file .env --env-file .env.local ``` ```bash pnpm dlx arkenv check --schema ./src/env.ts --env-file .env --env-file .env.local ``` ```bash yarn dlx arkenv check --schema ./src/env.ts --env-file .env --env-file .env.local ``` ```bash bunx arkenv check --schema ./src/env.ts --env-file .env --env-file .env.local ``` ```bash nubx arkenv check --schema ./src/env.ts --env-file .env --env-file .env.local ``` [Global flags](/docs/reference#global-flags) (`--quiet`, `--json`, `--agent`, `--help`) apply here too. ## Usage [#usage] ```txt title="Terminal" arkenv check [options] ``` When the environment satisfies the schema, `check` prints a success line and exits with code `0`: ```txt title="Terminal" ✔ No issues found — your environment matches the schema ``` When validation fails, `check` outputs the formatted issues and exits with code `4`. That band means "the command ran, and the environment does not match the schema" — not an internal crash (`1`) or a missing schema (`2`): ```txt title="Terminal" Errors found while validating environment variables DATABASE_URL must be a URL string (was [REDACTED]) PORT must be a number (was a string) ``` Before validating, `check` loads the schema under capture mode (imports the module with a hollow `{}` stub). Keep the schema module declarative. ## Options [#options] These flags are specific to `check`. ### `--schema ` / `-s` [#--schema-path---s] Explicit path to the schema module. Overrides convention discovery (`env.ts` or `src/env.ts`). A JavaScript schema is not auto-discovered; pass `--schema` (for example `--schema ./env.js`). Split-layout leftovers such as `env/server.ts` are not auto-discovered. For a two-module recipe, pass `--schema` at the client (or other loadable) module — not a server file that imports `server-only`, which the CLI cannot evaluate in plain Node. npm pnpm yarn bun nub ```bash npx arkenv check --schema ./src/config/env.ts ``` ```bash pnpm dlx arkenv check --schema ./src/config/env.ts ``` ```bash yarn dlx arkenv check --schema ./src/config/env.ts ``` ```bash bunx arkenv check --schema ./src/config/env.ts ``` ```bash nubx arkenv check --schema ./src/config/env.ts ``` When you omit `--schema`, `check` looks for a schema at flat convention paths only. Put custom locations in your scripts with `--schema` (for example `"check": "arkenv check --schema config/env.ts"`). The CLI does not read a `package.json` `"arkenv"` field for discovery. ### `--env-file ` [#--env-file-file] Load a `.env` file before validating. This flag is repeatable; multiple files are loaded in sequence with later files taking precedence over earlier ones. npm pnpm yarn bun nub ```bash npx arkenv check --env-file .env --env-file .env.local ``` ```bash pnpm dlx arkenv check --env-file .env --env-file .env.local ``` ```bash yarn dlx arkenv check --env-file .env --env-file .env.local ``` ```bash bunx arkenv check --env-file .env --env-file .env.local ``` ```bash nubx arkenv check --env-file .env --env-file .env.local ``` Values from `--env-file` are merged over `process.env`. Missing files fail fast with a non-zero exit code. Parsing is literal plain dotenv syntax (no variable expansion). ### `--verify-example [file]` [#--verify-example-file] Verify that every environment variable declared in the schema is present in `.env.example` (or a custom example file path) without mutating files on disk. npm pnpm yarn bun nub ```bash npx arkenv check --verify-example ``` ```bash pnpm dlx arkenv check --verify-example ``` ```bash yarn dlx arkenv check --verify-example ``` ```bash bunx arkenv check --verify-example ``` ```bash nubx arkenv check --verify-example ``` npm pnpm yarn bun nub ```bash npx arkenv check --verify-example .env.example.production ``` ```bash pnpm dlx arkenv check --verify-example .env.example.production ``` ```bash yarn dlx arkenv check --verify-example .env.example.production ``` ```bash bunx arkenv check --verify-example .env.example.production ``` ```bash nubx arkenv check --verify-example .env.example.production ``` When all schema keys exist in the example file, `check` exits with `0`. When keys are missing, `check` outputs the missing keys and exits with code `4`. This mode specifically verifies example file parity against declared schema keys and does not validate live environment variables. ### `--json` / `-j` [#--json---j] Write a settlement envelope to stdout. Success is `ok: true` with `exitCode: 0`: ```json { "ok": true, "commandId": "check", "result": { "schema": { "path": "env.ts" } }, "exitCode": 0, "diagnostics": [], "nextActions": [] } ``` Validation findings are still `ok: true` — the command completed — with `exitCode: 4` and `diagnostics` / `nextActions` for each key. Missing schema or a missing `--env-file` is `ok: false` with a dotted `CLI.*` code and exit `2`. ```json { "ok": true, "commandId": "check", "result": { "schema": { "path": "env.ts" } }, "exitCode": 4, "diagnostics": [ { "code": "ENV.MISSING_VARIABLE", "severity": "error", "summary": "DATABASE_URL is required", "nextActions": [ { "kind": "edit-file", "label": "Set DATABASE_URL in .env", "where": { "path": ".env" } } ] } ], "nextActions": [ { "kind": "edit-file", "label": "Set DATABASE_URL in .env", "where": { "path": ".env" } } ] } ``` ### `--quiet` / `-q` [#--quiet---q] Suppress normal console output. JSON envelopes still go to stdout when `--json` is set. Exit codes stay the same: `0` success, `2` could not run, `4` findings. ## CI/CD integration [#cicd-integration] Add `arkenv check` to CI so a missing or invalid variable fails the job before the build or deploy. `--verify-example` checks that `.env.example` still lists every schema key, so a new key without an example entry fails the job: ```yaml title=".github/workflows/ci.yml" name: CI on: [push, pull_request] jobs: validate-example: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Verify .env.example matches schema run: pnpm exec arkenv check --verify-example validate-env: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Validate environment env: DATABASE_URL: ${{ secrets.DATABASE_URL }} PORT: "3000" run: pnpm exec arkenv check ``` ## Next steps [#next-steps] # @arkenv/core (/docs/reference/core) `@arkenv/core` is the ArkType validation engine. It exports `arkenv()` and a `type()` that includes ArkType's keywords and [ArkEnv's](/docs/reference/keywords). `arktype` is a required peer. There are no runtime dependencies. Use [`@arkenv/standard`](/docs/reference/standard) instead. That keeps `arktype` out of the tree. ## Installation [#installation] Install the engine and its peer. The peer is required. npm pnpm yarn bun nub ```bash npm install @arkenv/core arktype ``` ```bash pnpm add @arkenv/core arktype ``` ```bash yarn add @arkenv/core arktype ``` ```bash bun install @arkenv/core arktype ``` ```bash nub add @arkenv/core arktype ``` ## Usage [#usage] ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ HOST: "string.host = 'localhost'", PORT: "number.port = 3000", DATABASE_URL: "string", }); ``` ## Exports [#exports] The main entry is the throw-path runtime. Issue helpers and the non-throwing parser live on subpaths so the default import stays small. | Export | Kind | Role | | ------------------ | -------------------------- | ---------------------------------------------------------- | | `arkenv` | function (default + named) | Validate and return a typed env object | | `type` | function | ArkType `type` with [`keywords`](/docs/reference/keywords) | | `ArkEnvError` | class | Thrown on validation failure | | `ArkEnvConfig` | type | [Options](/docs/reference/options) object | | `EnvSchema` | type | Declarative schema map | | `Infer` | type | Infer output from a schema | | `SafeArkEnvResult` | type | Result shape returned by `@arkenv/core/safe` | ### Subpaths [#subpaths] Issue formatting and the non-throwing parser are separate entries. | Import | Role | | --------------------- | --------------------------------------------------------------------------------- | | `@arkenv/core` | Throw-path `arkenv()` and `type()` | | `@arkenv/core/issues` | `formatIssues`, `getSchemaKeys`, and `EnvIssue` / `EnvIssueCode` / `EnvIssueMeta` | | `@arkenv/core/safe` | `arkenv` — result object instead of a throw (default + named export) | ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core/safe"; const result = arkenv( { PORT: "number.port" }, { env: { PORT: "invalid" } }, ); if (!result.success) { console.error(result.issues); } ``` ## Next steps [#next-steps] # env (/docs/reference/env) `arkenv()` returns a typed object of validated (and coerced) values. Import that object as `env` and read properties from it in application code. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", PORT: "number.port = 3000", }); env.DATABASE_URL; // string env.PORT; // number ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), PORT: z.int().min(0).max(65535).default(3000), }); env.DATABASE_URL; // string env.PORT; // number ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), }); env.DATABASE_URL; // string env.PORT; // number ``` ## Default source [#default-source] Without a config object, ArkEnv reads `process.env`. Override with [`options.env`](/docs/reference/options#env) when the host injects variables another way. Your framework or process manager must populate the source before `arkenv()` runs. See [Loading .env files](/docs/frameworks#loading-env-files). ## Return shape [#return-shape] Default `arkenv()` returns a validated object and throws `ArkEnvError` on failure. For a result object, import `arkenv` from `@arkenv/core/safe` or `@arkenv/standard/safe`. | Call | Return type | | --------------------- | --------------------------------------------------------- | | `arkenv()` | Validated object; throws `ArkEnvError` on failure | | `arkenv` from `/safe` | `{ success: true, data }` or `{ success: false, issues }` | Core and `@arkenv/standard` return a plain object. Next.js and Nuxt wrap that object in a Proxy. ## Framework proxies [#framework-proxies] Next.js and Nuxt Proxies throw if client code reads a server-only key, or if code reads a key missing from the schema. Vite and Bun plugins rewrite the client graph so only public keys are inlined. The app still imports `env` from your schema module. See [Client vs. server](/docs/core-concepts/client-vs-server) and [Error reporting](/docs/core-concepts/error-reporting). ## Typing helpers [#typing-helpers] On ArkType, export `Infer` when you need the schema's output type without reading `typeof env`. Zod and Valibot use `typeof env` (or the validator's own infer helper). See [Typesafety](/docs/core-concepts/defining-your-schema#typesafety). ```ts title="./env.ts" twoslash import arkenv, { type Infer } from "@arkenv/core"; const schema = { API_URL: "string" } as const; export const env = arkenv(schema); export type Env = Infer; ``` Do not augment ambient env types Do not augment `ImportMetaEnv` (Vite) or `NodeJS.ProcessEnv` (Bun) for schema keys — those helpers were dropped in v1. Reading `process.env` or `import.meta.env` skips ArkEnv. Import `{ env }` from your schema module. ## Next steps [#next-steps] # API reference (/docs/reference) These pages are the API, CLI, and package surfaces. Start with options and `init`, then open the package that matches your stack. ## Configuration [#configuration] These pages cover options, keywords, schema shapes, and the `env` object. ## Commands [#commands] The `arkenv` package is the CLI. Import validation from `@arkenv/core` or `@arkenv/standard`. ## Packages [#packages] These pages cover the runtime engines and framework plugins. ## Global flags [#global-flags] These flags apply to every ArkEnv CLI command. ### `--yes` / `-y` [#--yes---y] Skip interactive prompts and use defaults. The CLI also forwards this flag to subprocesses it starts, including package-manager installs. Use `--yes` in scripts. Prefer `--agent` when a machine must also get JSON on stdout. ### `--quiet` / `-q` [#--quiet---q] Suppress normal log output. On failure, the CLI still captures logs so you can diagnose the run. ### `--json` / `-j` [#--json---j] Write structured JSON to stdout instead of human-readable text. ### `--agent` [#--agent] Non-interactive, machine-readable mode for AI agents. This is a macro for `--yes --quiet --json`. It does **not** imply `--force`; pass `--force` yourself if you accept overwriting generated files. ### `--help` / `-h` [#--help---h] Print CLI help. ## Next steps [#next-steps] # init (/docs/reference/init) `arkenv` on npm is the CLI. It scaffolds schemas and wires framework config. Validation lives in `@arkenv/core` or `@arkenv/standard`. For the walkthrough, see [Installation](/docs/getting-started/installation). npm pnpm yarn bun nub ```bash npx arkenv init ``` ```bash pnpm dlx arkenv init ``` ```bash yarn dlx arkenv init ``` ```bash bunx arkenv init ``` ```bash nubx arkenv init ``` ## Usage [#usage] ```txt title="Terminal" arkenv init [project-name] [options] ``` Omit `project-name` to mutate the current directory. Pass a name to create a new folder (optionally from `--example`). [Global flags](/docs/reference#global-flags) (`--yes`, `--quiet`, `--json`, `--agent`, `--help`) apply here too. ## Options [#options] These flags are specific to `init`. ### `--force` / `-f` [#--force---f] Bypass dirty-git and other safety checks, then force scaffolding. The CLI refuses a dirty working tree so a run cannot silently overwrite uncommitted work. Commit or stash first, or pass `--force` when you accept that risk (for example in CI). ### `--no-codegen` [#--no-codegen] Skip Next.js `env.gen.ts` generation and related `withArkEnv` wiring. ### `--preset` / `-P` [#--preset---p] Pre-populate provider system variables. Values: `none`, `vercel`, `netlify`, `cloudflare`, `railway`, `render`, `fly`. See [Hosting presets](/docs/core-concepts/hosting-presets). ### `--agent` [#--agent] Same macro as the [global `--agent` flag](/docs/reference#agent): `--yes --quiet --json`. It does not imply `--force`. When a safety check fails (dirty git tree), stdout is an errored settlement envelope (`ok: false`) and the process exits non-zero: ```json { "ok": false, "commandId": "init", "error": { "code": "CLI.GIT_TREE_DIRTY", "severity": "error", "summary": "Git working tree is not clean.", "why": "Commit or stash your changes before running arkenv init.", "nextActions": [ { "kind": "run-command", "label": "Re-run with --force to bypass git working tree check", "command": "arkenv init --force" } ] }, "diagnostics": [], "nextActions": [ { "kind": "run-command", "label": "Re-run with --force to bypass git working tree check", "command": "arkenv init --force" } ] } ``` Branch on `error.code` (dotted `CLI.*` / `ENV.*` codes). Use `nextActions` for remediation — a `run-command` action with `--force` means the refusal is bypassable. Only re-run with that flag after you confirm the bypass is safe. The same envelope shape is documented for [`check --json`](/docs/reference/check#json--j). ### `--example ` [#--example-name] Scaffold from a named example when creating a new project. Short `-e` is reserved and rejected. See [Start with an example](/docs/getting-started/examples). `--strict`, `--simple`, and `--flat` are removed. Scaffold is always a single `env.ts`. ## Output [#output] Depending on the detected framework and flags, `init` writes: * Schema file: `env.ts` (often under `src/`) * `.env` / `.env.example` when missing. Init never reads an existing `.env`; a missing `.env.example` is scaffolded from detected keys and defaults. If `.env` is missing, init may copy `.env.example` into it. * Next.js: `withArkEnv` in `next.config.*`, `.arkenv/` in `.gitignore`, optional `.arkenv/env.gen.ts` (import as `@/.arkenv`) * Vite / Bun / Rsbuild: plugin configuration in `vite.config.*` / `bunfig.toml` / `rsbuild.config.*` * Dependency installs for the chosen dialect (`@arkenv/core`, plugins, Zod/Valibot) `init` does not install the ArkEnv agent skill. On an existing project it checks whether the skill is already present. When it is not, the next-steps note prints the manual command. `--yes` and `--agent` do not run that command. npm pnpm yarn bun nub ```bash npx skills add yamcodes/arkenv ``` ```bash pnpm dlx skills add yamcodes/arkenv ``` ```bash yarn dlx skills add yamcodes/arkenv ``` ```bash bunx skills add yamcodes/arkenv ``` ```bash nubx skills add yamcodes/arkenv ``` That install is separate from scaffolding. `@arkenv/agent-plugin` already includes the skill, so skip the command when the plugin is installed. See [Agent skill](/docs/guides/ai#agent-skill). ## Import the validator from core [#import-the-validator-from-core] The CLI package is not the validation library. Import `arkenv()` from `@arkenv/core` (or `@arkenv/standard`). ```ts // Wrong: arkenv is the CLI package import arkenv from "arkenv"; // Right import arkenv from "@arkenv/core"; ``` Importing `arkenv` as a library throws and points you at `@arkenv/core`. ## Next steps [#next-steps] # keywords (/docs/reference/keywords) ArkEnv adds two env-oriented keywords on top of [ArkType's keyword set](https://arktype.io/docs/primitives). Use them in `arkenv({ ... })` or `type()` from `@arkenv/core`. Keywords work with [`@arkenv/core`](/docs/reference/core). [`@arkenv/standard`](/docs/reference/standard) has its own validator map. ## `string.host` [#stringhost] An IP address (`string.ip`) or the literal `"localhost"`. ```ts twoslash import arkenv from "@arkenv/core"; const env = arkenv({ HOST: "string.host", }); ``` ```ts twoslash import { type } from "@arkenv/core"; const Host = type("string.host"); type Host = typeof Host.infer; ``` ## `number.port` [#numberport] An integer port in the range `0`-`65535`. ```ts twoslash import arkenv from "@arkenv/core"; const env = arkenv({ PORT: "number.port", }); ``` ```ts twoslash import { type } from "@arkenv/core"; const Port = type("number.port"); type Port = typeof Port.infer; ``` ## Everything else [#everything-else] Other primitives (`string`, `number`, `boolean`, enums, morphs) come from ArkType. See [Defining types](/docs/core-concepts/defining-your-schema) for common patterns. ## Next steps [#next-steps] # @arkenv/nextjs (/docs/reference/nextjs) Next.js does not validate an env schema during `next dev` or `next build` on its own. `@arkenv/nextjs` adds that check, Next-aware `arkenv()` entries, and `.arkenv` codegen. This page is the subpath and option list. Setup recipes live in the [Next.js guide](/docs/frameworks/nextjs). Flat `arkenv(schema, { exposeToClient })` is the only call shape. Nested bags and `expose` / `shared` aliases were removed — see [Migrating to v1](/docs/guides/migrating-to-v1#removed-in-v1). ## Installation [#installation] Install the adapter next to the engine you write schemas with. The adapter does not bundle `@arkenv/core` or `@arkenv/standard`. npm pnpm yarn bun nub ```bash npm install @arkenv/core @arkenv/nextjs arktype ``` ```bash pnpm add @arkenv/core @arkenv/nextjs arktype ``` ```bash yarn add @arkenv/core @arkenv/nextjs arktype ``` ```bash bun install @arkenv/core @arkenv/nextjs arktype ``` ```bash nub add @arkenv/core @arkenv/nextjs arktype ``` Or if you use Zod, Valibot, or another Standard Schema validator: npm pnpm yarn bun nub ```bash npm install @arkenv/standard @arkenv/nextjs zod ``` ```bash pnpm add @arkenv/standard @arkenv/nextjs zod ``` ```bash yarn add @arkenv/standard @arkenv/nextjs zod ``` ```bash bun install @arkenv/standard @arkenv/nextjs zod ``` ```bash nub add @arkenv/standard @arkenv/nextjs zod ``` ## Subpaths [#subpaths] | Subpath | Role | | ------------------- | --------------------------------------------------------------------------- | | `.` | Unified `arkenv` (`react-server` condition for RSC, default for client/SSR) | | `./config` | `withArkEnv`, `runCodegen`, config types | | `./standard` | Standard Schema unified entry (`react-server` for RSC, default for client) | | `./standard/config` | Standard `withArkEnv` (forces Standard codegen) | ## `withArkEnv` options [#witharkenv-options] ```ts title="./next.config.ts" import type { NextConfig } from "next"; import { withArkEnv } from "@arkenv/nextjs/config"; const nextConfig: NextConfig = {}; export default withArkEnv(nextConfig); ``` Function-form `next.config` is supported. ArkEnv awaits the factory and applies aliases to the resolved object: ```ts title="./next.config.ts" import type { NextConfig } from "next"; import { withArkEnv } from "@arkenv/nextjs/config"; export default withArkEnv(async (phase, { defaultConfig }): Promise => ({ ...defaultConfig, reactStrictMode: phase !== "phase-test", })); ``` Your app schema imports the generated factory: ```ts title="./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'", }); ``` ```ts title="./env.ts" import arkenv from "@/.arkenv"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), NEXT_PUBLIC_API_URL: z.url().default("https://api.example.com"), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); ``` ```ts title="./env.ts" import arkenv from "@/.arkenv"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), NEXT_PUBLIC_API_URL: v.optional( v.pipe(v.string(), v.url()), "https://api.example.com", ), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), }); ``` Zod and Valibot import `withArkEnv` from `@arkenv/nextjs/standard/config`. See [Standard Schema codegen](#standard-schema-codegen). ### `withArkEnv` options [#witharkenv-options-1] Pass a second argument to change codegen or validation. | Option | Default | Meaning | | ------------ | ------- | ---------------------------- | | `codegen` | `true` | Write `env.gen.ts` | | `validate` | `true` | Validate during config/build | | `schemaPath` | auto | Schema entry path | | `outputPath` | auto | Generated file path | | `logger` | console | Build-time logger | | `logLevel` | (none) | Minimum log level | A custom `outputPath` still imports as `@/.arkenv`. Next.js aliases the specifier; codegen keeps `.arkenv/index.ts` re-exporting that file so `tsc --noEmit` matches. Skip generation during CLI scaffolds with `npx arkenv init --no-codegen`. ### Programmatic codegen [#programmatic-codegen] ```ts import { runCodegen } from "@arkenv/nextjs/config"; await runCodegen(schemaPath, outputPath); ``` ### Standard Schema codegen [#standard-schema-codegen] Import `withArkEnv` from `@arkenv/nextjs/standard/config` to force Standard Schema-compatible generated imports. ```ts title="./next.config.ts" import { withArkEnv } from "@arkenv/nextjs/standard/config"; export default withArkEnv(nextConfig); ``` ## Client and server keys [#client-and-server-keys] Public keys use the `NEXT_PUBLIC_` prefix. One schema file plus a runtime Proxy blocks server-key reads on the client. `.` and `./standard` publish the same export conditions. Next.js resolves `react-server` in Server Components and Route Handlers, so those imports see every schema key. Client Components and SSR resolve `default`, which exposes public keys only. Generated `env.gen.ts` still imports `@arkenv/nextjs` or `@arkenv/nextjs/standard`. The bundler selects the build from the importing context. For name/type isolation beyond values, see the [two-module recipe](/docs/core-concepts/client-vs-server#advanced-two-module-recipe). ## Next steps [#next-steps] # @arkenv/nuxt (/docs/reference/nuxt) `@arkenv/nuxt` validates environment variables from a Nuxt module and hydrates public keys through Nitro `runtimeConfig`. This page is the subpath and option list. Setup recipes live in the [Nuxt guide](/docs/frameworks/nuxt). Flat `arkenv(schema, { exposeToClient })` is the only call shape. Nested bags and `expose` / `shared` aliases were removed — see [Migrating to v1](/docs/guides/migrating-to-v1#removed-in-v1). ## Installation [#installation] Install the module next to the engine you write schemas with. The module does not bundle `@arkenv/core` or `@arkenv/standard`. npm pnpm yarn bun nub ```bash npm install @arkenv/core @arkenv/nuxt arktype ``` ```bash pnpm add @arkenv/core @arkenv/nuxt arktype ``` ```bash yarn add @arkenv/core @arkenv/nuxt arktype ``` ```bash bun install @arkenv/core @arkenv/nuxt arktype ``` ```bash nub add @arkenv/core @arkenv/nuxt arktype ``` Or if you use Zod, Valibot, or another Standard Schema validator: npm pnpm yarn bun nub ```bash npm install @arkenv/standard @arkenv/nuxt ``` ```bash pnpm add @arkenv/standard @arkenv/nuxt ``` ```bash yarn add @arkenv/standard @arkenv/nuxt ``` ```bash bun install @arkenv/standard @arkenv/nuxt ``` ```bash nub add @arkenv/standard @arkenv/nuxt ``` ## Subpaths [#subpaths] | Subpath | Role | | ------------------- | ------------------------------------------------ | | `.` | Runtime `arkenv` | | `./module` | Nuxt module (`modules: ["@arkenv/nuxt/module"]`) | | `./config` | `setupArkEnv` and config types | | `./standard` | Standard Schema runtime | | `./standard/module` | Standard Schema Nuxt module | ## Module setup [#module-setup] Register the module in `nuxt.config`. The schema module is the surface. Nuxt leaves `env.gen.ts` to Next.js. ```ts title="./nuxt.config.ts" export default defineNuxtConfig({ modules: ["@arkenv/nuxt/module"], }); ``` ### Module options [#module-options] These options live under `arkenv` in `nuxt.config`. | Option | Default | Meaning | | --------------------- | ------- | ------------------------------ | | `validate` | `true` | Validate during Nuxt build/dev | | `schemaPath` | auto | Schema entry path | | `logger` / `logLevel` | (none) | Logging controls | ## Schema [#schema] Use the default entry for a single schema file. ```ts title="./env.ts" import arkenv from "@arkenv/nuxt"; export const env = arkenv({ DATABASE_URL: "string", NUXT_PUBLIC_API_URL: "string", }); ``` ```ts title="./env.ts" import arkenv from "@arkenv/nuxt/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), NUXT_PUBLIC_API_URL: z.string(), }); ``` ```ts title="./env.ts" import arkenv from "@arkenv/nuxt/standard"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), NUXT_PUBLIC_API_URL: v.string(), }); ``` For an optional client/server module split, see [Client vs. server](/docs/core-concepts/client-vs-server#advanced-two-module-recipe). Never import a server env module from client or Vue code. ## Next steps [#next-steps] # options (/docs/reference/options) The second argument to `arkenv()` is an optional configuration object. The snippets below use ArkType. Zod and Valibot take the same options; only the field schemas change. See [Defining your schema](/docs/core-concepts/defining-your-schema). Shared fields exist on both engines: `env`, `coerce`, `emptyAsUndefined`, `onUndeclaredKey`, `arrayFormat`, and `debugSecrets`. `toJsonSchema` exists only on [`@arkenv/standard`](/docs/reference/standard). ArkType already exposes JSON Schema, so [`@arkenv/core`](/docs/reference/core) has nothing to fall back to. ## Type [#type] `ArkEnvConfig` is the ArkType engine table. `StandardEnvConfig` is that table plus optional `toJsonSchema`. ## Options [#options] Each field below is optional. Omit the whole object to read `process.env` with coercion on. ### env [#env] The record to parse. Defaults to `process.env`. Pass a record in tests, Cloudflare Workers, or when you load values with Vite `loadEnv`. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { PORT: "number.port" }, { env: { PORT: "3000" } }, ); ``` ### coerce [#coerce] Default: `true` When `true`, ArkEnv turns env strings into the types in your schema before the validator runs. Turn it off when you want raw strings and you transform them yourself. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { PORT: "string" }, { coerce: false, env: { PORT: "3000" } }, ); env.PORT; // "3000" ``` See [Coercion and parsing](/docs/core-concepts/coercion-and-parsing). ### arrayFormat [#arrayformat] Default: `"comma"` How ArkEnv parses array env values when coercion is on. * `"comma"`: split on commas and trim each item * `"json"`: parse the string as JSON Use `"json"` when your host stores arrays as JSON strings. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { TAGS: "string[]" }, { arrayFormat: "json", env: { TAGS: '["web", "app"]' } }, ); ``` ### emptyAsUndefined [#emptyasundefined] Default: `false` An empty assignment in a `.env` file still sets the key. ArkEnv sees `""`, so defaults do not apply and non-string types fail: ```ini title=".env" PORT= DEBUG= ``` Set `emptyAsUndefined: true` to treat empty strings as missing before validation. Then `PORT=` behaves like an unset key and the default applies: ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { PORT: "number.port = 3000", DEBUG: "boolean = false", }, { emptyAsUndefined: true, env: { PORT: "", DEBUG: "", }, }, ); env.PORT; // 3000 env.DEBUG; // false ``` The `env` option above only simulates a loaded `.env` for the snippet. In an app, your runner or framework loads `.env*` into `process.env` first; you pass `{ emptyAsUndefined: true }` alone. ### onUndeclaredKey [#onundeclaredkey] Default: `"delete"` What to do with keys that appear on input but not in your schema. * `"delete"`: allow them on input, strip them from the output * `"ignore"`: allow them and keep them on the output * `"reject"`: fail validation ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { PORT: "number.port" }, { onUndeclaredKey: "reject", env: { PORT: "3000", EXTRA: "nope" }, }, ); ``` ### debugSecrets [#debugsecrets] Default: `process.env.ARKENV_DEBUG_SECRETS` is `"true"` or `"1"` Bypass secret redaction in debug output so you can see raw values. Leave this off in shared logs and CI. ### toJsonSchema [#tojsonschema] `@arkenv/standard` only. This field is not on ArkType `ArkEnvConfig`. Vite and Bun `/standard` plugin configs that alias `ParseStandardConfig` inherit it. Pass a fallback converter for Standard Schema validators that omit JSON Schema on the value (Valibot, Zod Mini, Zod v3 via `zod-to-json-schema`, and similar). ArkEnv calls it per key when it can't read JSON Schema from that value. Wiring: prefer [`@arkenv/standard/valibot`](/docs/validators/valibot) and [`@arkenv/standard/zod-mini`](/docs/validators/zod#zod-mini). The callback remains the escape hatch for Zod v3 and mixed maps. See [Coercion](/docs/core-concepts/coercion-and-parsing#valibot-tojsonschema). ## Framework-only options [#framework-only-options] Next.js and Nuxt wrappers accept `extends` to merge shared or client env objects into a server schema. That field lives outside core `ArkEnvConfig`. See [Client vs. server](/docs/core-concepts/client-vs-server). ## Next steps [#next-steps] # @arkenv/rsbuild-plugin (/docs/reference/rsbuild-plugin) `@arkenv/rsbuild-plugin` validates `env.ts` during an Rsbuild compile and rewrites the client graph so `PUBLIC_` keys inline and server keys throw. This page is the export and option list. Setup recipes live in the [TanStack Start guide](/docs/frameworks/tanstack-start#rsbuild). ## Installation [#installation] Install the plugin as a dev dependency next to the engine you write schemas with. The plugin does not bundle `@arkenv/core` or `@arkenv/standard`. npm pnpm yarn bun nub ```bash npm install -D @arkenv/rsbuild-plugin npm install @arkenv/core arktype ``` ```bash pnpm add -D @arkenv/rsbuild-plugin pnpm add @arkenv/core arktype ``` ```bash yarn add --dev @arkenv/rsbuild-plugin yarn add @arkenv/core arktype ``` ```bash bun install --dev @arkenv/rsbuild-plugin bun install @arkenv/core arktype ``` ```bash nub add -D @arkenv/rsbuild-plugin nub add @arkenv/core arktype ``` Or if you use Zod, Valibot, or another Standard Schema validator: npm pnpm yarn bun nub ```bash npm install -D @arkenv/rsbuild-plugin npm install @arkenv/standard ``` ```bash pnpm add -D @arkenv/rsbuild-plugin pnpm add @arkenv/standard ``` ```bash yarn add --dev @arkenv/rsbuild-plugin yarn add @arkenv/standard ``` ```bash bun install --dev @arkenv/rsbuild-plugin bun install @arkenv/standard ``` ```bash nub add -D @arkenv/rsbuild-plugin nub add @arkenv/standard ``` ## Exports [#exports] The default export and the named `arkenvPlugin` export are the same factory. Zod and Valibot import `./standard`, which binds `@arkenv/standard`. | Export | Role | | -------------------- | ----------------------------- | | default | `arkenvPlugin` plugin factory | | named `arkenvPlugin` | Plugin factory | | `./standard` | Standard Schema plugin entry | ## Transform options [#transform-options] Pass transform options only. A schema argument is rejected. | Option | Default | Meaning | | --------------------- | --------------- | -------------------------- | | `schemaPath` | auto (`env.ts`) | Schema module to transform | | `clientPrefix` | `PUBLIC_` | Keys exposed to the client | | `logger` / `logLevel` | (none) | Logging | The plugin validates the environment loaded for that build: Rsbuild env files, with variables already set on `process.env` winning. Set `env`, `coerce`, `onUndeclaredKey`, `arrayFormat`, `emptyAsUndefined`, `debugSecrets`, and `toJsonSchema` on `arkenv()` in `env.ts`. The plugin rejects them. See [Options](/docs/reference/options). Call `arkenvPlugin()` with no args, or an options object that includes transform fields (`schemaPath`, `clientPrefix`, logging). Do not pass a schema map or compiled `type()` to the plugin. ## Tests and programmatic builds [#tests-and-programmatic-builds] Tests and scripts often start Rsbuild in a process that already loaded env files. To give one compile different values, set them on `process.env` before the build. Rsbuild lets those values win over env files, and the plugin validates that loaded environment. ```ts import { createRsbuild } from "@rsbuild/core"; import arkenvPlugin from "@arkenv/rsbuild-plugin"; const previous = process.env.PUBLIC_API_URL; process.env.PUBLIC_API_URL = "https://staging.example.com"; try { const rsbuild = await createRsbuild({ rsbuildConfig: { plugins: [arkenvPlugin()], }, }); await rsbuild.build(); } finally { if (previous === undefined) delete process.env.PUBLIC_API_URL; else process.env.PUBLIC_API_URL = previous; } ``` Restore the previous value in `finally`. The next compile in the same process then sees the original environment. ## Next steps [#next-steps] # schema (/docs/reference/schema) `arkenv()` accepts a schema that maps environment variable names to validators. Core uses the ArkType DSL (and compiled `type()` values). Standard mode accepts a map of Standard Schema validators. ## Declarative map [#declarative-map] Pass a map of ArkType strings (or nested objects). This is an `EnvSchema`: each value is validated against ArkEnv's ArkType scope before parsing. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ NODE_ENV: "'development' | 'production' | 'test' = 'development'", HOST: "string.host = 'localhost'", PORT: "number.port = 3000", DATABASE_URL: "string", }); ``` ## Compiled `type()` [#compiled-type] Build the schema once with `type()` when you reuse it across runtimes or packages. ```ts title="./env-schema.ts" twoslash import arkenv, { type } from "@arkenv/core"; export const Env = type({ PORT: "number.port = 3000", DATABASE_URL: "string", }); export const env = arkenv(Env); ``` See [Reusing schemas](/docs/core-concepts/reusing-schemas). ## Standard Schema maps [#standard-schema-maps] With [`@arkenv/standard`](/docs/reference/standard), every value must be a Standard Schema validator. ArkType DSL strings fail. Zod uses the root import. Valibot uses `@arkenv/standard/valibot`. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ PORT: z.int().min(0).max(65535), DATABASE_URL: z.url(), }); ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ PORT: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), DATABASE_URL: v.pipe(v.string(), v.url()), }); ``` ## Inferring types [#inferring-types] `Infer` works for declarative maps, compiled ArkType types, and Standard Schema values. ```ts title="./env.ts" twoslash import arkenv, { type Infer } from "@arkenv/core"; const schema = { DATABASE_URL: "string", PORT: "number.port = 3000", } as const; export const env = arkenv(schema); export type Env = Infer; ``` ## Client and server schemas [#client-and-server-schemas] Framework packages enforce public prefixes on a flat schema: * **Flat:** one schema; public prefixes decide client exposure * **Optional recipe:** two modules with `extends` when names must stay off the client type graph Details: [Client vs. server](/docs/core-concepts/client-vs-server), [`@arkenv/nextjs`](/docs/reference/nextjs), [`@arkenv/nuxt`](/docs/reference/nuxt). ## Next steps [#next-steps] # @arkenv/standard (/docs/reference/standard) `@arkenv/standard` is the ArkType-free validation engine. First-class support is Zod and Valibot. It consumes [Standard Schema](https://standardschema.dev/) validators. Libraries that only implement the validate hook may still need a `toJsonSchema` helper or a dedicated subpath (for example `@arkenv/standard/valibot`). The root import has no runtime dependencies or peers. Use [`@arkenv/core`](/docs/reference/core) instead. ArkType strings belong on that engine, not wrapped through this one. TypeScript must use `moduleResolution: "bundler" | "node16" | "nodenext"`. ## Installation [#installation] Install the engine next to Zod or Valibot. It does not pull in `arktype`. npm pnpm yarn bun nub ```bash npm install @arkenv/standard ``` ```bash pnpm add @arkenv/standard ``` ```bash yarn add @arkenv/standard ``` ```bash bun install @arkenv/standard ``` ```bash nub add @arkenv/standard ``` ## Usage [#usage] ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ PORT: z.int().min(0).max(65535).default(3000), DATABASE_URL: z.url(), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), DATABASE_URL: v.pipe(v.string(), v.url()), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), }); ``` ## Compared to `@arkenv/core` [#compared-to-arkenvcore] Both engines share `arkenv()` options, errors, and framework plugins. They differ in schema style and peers. | | `@arkenv/core` | `@arkenv/standard` | | --------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Validator | ArkType DSL + `type()` | Standard Schema map only | | Keywords (`string.host`, `number.port`) | Yes | No | | Peer `arktype` | Required | None | | [Options](/docs/reference/options) | `ArkEnvConfig` | `StandardEnvConfig` (`ArkEnvConfig` plus `toJsonSchema`) | | Coercion | Built-in for ArkType shapes | Standard JSON Schema on the value, or `@arkenv/standard/valibot` / `/zod-mini` / [`toJsonSchema`](/docs/core-concepts/coercion-and-parsing#valibot-tojsonschema) | ## Exports [#exports] The main entry is the throw-path runtime. Issue helpers and the non-throwing parser live on subpaths so the default import stays small. | Export | Kind | Role | | ------------------- | -------------------------- | ------------------------------------------------ | | `arkenv` | function (default + named) | Validate a Standard Schema field map | | `ArkEnvError` | class | Thrown on validation failure | | `SafeArkEnvResult` | type | Result shape returned by `@arkenv/standard/safe` | | `StandardEnvConfig` | type | Extends core options with `toJsonSchema` | ### Subpaths [#subpaths] Issue formatting and the non-throwing parser are separate entries. | Import | Role | | --------------------------- | ---------------------------------------------------------------------------------------------- | | `@arkenv/standard` | Root engine (throw path). Dependency-free. Zod works zero-config. | | `@arkenv/standard/issues` | `formatIssues`, `getSchemaKeys`, and `EnvIssue` / `EnvIssueCode` / `EnvIssueMeta` | | `@arkenv/standard/safe` | `arkenv` — result object instead of a throw (default + named export) | | `@arkenv/standard/valibot` | Same `arkenv`, pre-configured for Valibot. `valibot` + `@valibot/to-json-schema` are required. | | `@arkenv/standard/zod-mini` | Same `arkenv`, pre-configured for Zod Mini. `zod` is required. | ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard/safe"; import * as z from "zod"; const result = arkenv( { PORT: z.coerce.number() }, { env: { PORT: "invalid" } }, ); if (!result.success) { console.error(result.issues); } ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ PORT: v.optional(v.number(), 3000), }); ``` ## Framework `/standard` subpaths [#framework-standard-subpaths] Next.js, Nuxt, Vite, and Bun ship `/standard` (and related) entries that pair with this package. See each package reference for import paths. ## Next steps [#next-steps] # @arkenv/vite-plugin (/docs/reference/vite-plugin) `@arkenv/vite-plugin` validates `env.ts` while Vite resolves config, then rewrites the client graph so public keys inline and server keys throw. This page is the export and option list. Setup recipes live in the [Vite guide](/docs/frameworks/vite). ## Installation [#installation] Install the plugin as a dev dependency next to the engine you write schemas with. The plugin does not bundle `@arkenv/core` or `@arkenv/standard`. npm pnpm yarn bun nub ```bash npm install -D @arkenv/vite-plugin npm install @arkenv/core arktype ``` ```bash pnpm add -D @arkenv/vite-plugin pnpm add @arkenv/core arktype ``` ```bash yarn add --dev @arkenv/vite-plugin yarn add @arkenv/core arktype ``` ```bash bun install --dev @arkenv/vite-plugin bun install @arkenv/core arktype ``` ```bash nub add -D @arkenv/vite-plugin nub add @arkenv/core arktype ``` Or if you use Zod, Valibot, or another Standard Schema validator: npm pnpm yarn bun nub ```bash npm install -D @arkenv/vite-plugin npm install @arkenv/standard ``` ```bash pnpm add -D @arkenv/vite-plugin pnpm add @arkenv/standard ``` ```bash yarn add --dev @arkenv/vite-plugin yarn add @arkenv/standard ``` ```bash bun install --dev @arkenv/vite-plugin bun install @arkenv/standard ``` ```bash nub add -D @arkenv/vite-plugin nub add @arkenv/standard ``` ## Exports [#exports] The default export and the named `arkenvPlugin` export are the same factory. Zod and Valibot import `./standard`, which binds `@arkenv/standard`. | Export | Role | | -------------------- | ----------------------------- | | default | `arkenvPlugin` plugin factory | | named `arkenvPlugin` | Plugin factory | | `./standard` | Standard Schema plugin entry | ## Transform options [#transform-options] Pass transform options only. A schema argument is rejected. | Option | Default | Meaning | | --------------------- | -------------------------- | -------------------------- | | `schemaPath` | auto (`env.ts`) | Schema module to transform | | `clientPrefix` | Vite `envPrefix` (`VITE_`) | Keys exposed to the client | | `logger` / `logLevel` | (none) | Logging | The plugin validates the environment loaded for that build: Vite's env files, with variables already set on `process.env` winning. Set `env`, `coerce`, `onUndeclaredKey`, `arrayFormat`, `emptyAsUndefined`, `debugSecrets`, and `toJsonSchema` on `arkenv()` in `env.ts`. The plugin rejects them. See [Options](/docs/reference/options). Call `arkenvPlugin()` with no args, or an options object that includes transform fields (`schemaPath`, `clientPrefix`, logging). Do not pass a schema map or compiled `type()` to the plugin. ## Startup validation [#startup-validation] When the plugin is registered, it resolves `env.ts` and validates it during Vite config resolution. Missing or invalid environment variables therefore abort the dev server or production build before Vite is ready. The plugin revalidates the schema when a relevant `.env` file or schema module changes during HMR. Without the plugin, validation runs when a module imports `env.ts`. There is no default-off lazy-validation flag. ## Tests and programmatic builds [#tests-and-programmatic-builds] Tests and scripts often call `build()` in a process that already loaded a `.env` file. To give one compile different values, set them on `process.env` before the build. Vite lets those values win over env files, and the plugin validates that loaded environment. ```ts import { build } from "vite"; import arkenvPlugin from "@arkenv/vite-plugin"; const previous = process.env.VITE_API_URL; process.env.VITE_API_URL = "https://staging.example.com"; try { await build({ plugins: [arkenvPlugin()] }); } finally { if (previous === undefined) delete process.env.VITE_API_URL; else process.env.VITE_API_URL = previous; } ``` Restore the previous value in `finally`. The next compile in the same process then sees the original environment. ## Next steps [#next-steps] # ArkType (/docs/validators/arktype) ArkType strings can describe a port or a host without wrapping each key. ArkEnv's core engine takes those DSL strings, adds `number.port` and `string.host`, and coerces strings before validation. `arktype` is a required peer of `@arkenv/core`. How this engine differs from Zod and Valibot is in [Validators](/docs/validators). ## Installation [#installation] Install `@arkenv/core` and its peer `arktype`. The peer is required. `@arkenv/core` itself has no runtime dependencies. npm pnpm yarn bun nub ```bash npm install @arkenv/core arktype ``` ```bash pnpm add @arkenv/core arktype ``` ```bash yarn add @arkenv/core arktype ``` ```bash bun install @arkenv/core arktype ``` ```bash nub add @arkenv/core arktype ``` ## Define your schema [#define-your-schema] Put the schema in `env.ts` and import `{ env }` where the process starts. A missing key or a value that fails the schema throws [`ArkEnvError`](/docs/reference/core) at that import, before the rest of the app runs. DSL strings cover the common keys. Reach for compiled `type()` when you need a morph or a function default. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", PORT: "number.port = 3000", DEBUG: "boolean = false", NODE_ENV: "'development' | 'production' | 'test' = 'development'", }); ``` ## Automatic coercion [#automatic-coercion] Environment variables arrive as strings. ArkEnv reads the compiled ArkType schema's JSON Schema and coerces those strings into numbers, booleans, and arrays before validation, so `PORT` is a `number` in application code. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv( { PORT: "number.port", DEBUG: "boolean", TAGS: "string[]", }, { env: { PORT: "8080", DEBUG: "true", TAGS: "web, api", }, }, ); env.PORT; // number env.DEBUG; // boolean env.TAGS; // string[] ``` To disable automatic string conversion and preserve raw strings for custom morphs, pass `{ coerce: false }`. Learn more in [Coercion](/docs/core-concepts/coercion-and-parsing). ## Defaults and optionals [#defaults-and-optionals] ArkType syntax supports inline default values and optional properties: | Pattern | Behavior | | :--------------------------------- | :--------------------------------------------------------------- | | `= 'value'` / `= 3000` | Fallback value when the variable is undefined | | `string \| undefined` or `string?` | Optional environment variable | | `emptyAsUndefined: true` | Converts empty strings (`""`) to `undefined` so defaults trigger | For specialized validators like `string.host` and `number.port`, see [Keywords](/docs/reference/keywords). To build complex custom schemas or morphs, see [Defining types](/docs/core-concepts/defining-your-schema). ## Next steps [#next-steps] # Validators (/docs/validators) ArkType, Zod, and Valibot can all describe an environment variable. ArkEnv ships two engines that share `arkenv()`, fail-fast errors, and the framework plugins. You pick the one that matches the schemas you already write. Shared [options](/docs/reference/options) (`env`, `coerce`, and the rest) apply to both. `toJsonSchema` is on `@arkenv/standard` only. ArkType already exposes JSON Schema. The engines differ in schema style and peer dependencies. | | [`@arkenv/core`](/docs/reference/core) | [`@arkenv/standard`](/docs/reference/standard) | | ------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Schema style | ArkType DSL strings + `type()` | Per-key Standard Schema validators | | Typical libraries | ArkType | Zod, Valibot | | Runtime `dependencies` | None | None | | Peer `arktype` | Required | None | | Env keywords (`string.host`, `number.port`) | Yes | Use the validator's own APIs | | Coercion | Built-in | Built-in when fields expose Standard JSON Schema v1, via `/valibot` or `/zod-mini`, or via `toJsonSchema` | | Framework plugins | `@arkenv/nextjs`, … | Same packages via `/standard` subpaths | Pick the engine that matches the schema library you write, then follow the cookbook below. ## Side by side [#side-by-side] The same keys, three declaration styles. Zod 4.2+ does not need `z.coerce`, and Valibot does not need `v.transform(Number)`; ArkEnv coerces first. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/core"; export const env = arkenv({ DATABASE_URL: "string", PORT: "number.port = 3000", NODE_ENV: "'development' | 'production' | 'test' = 'development'", }); ``` ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), PORT: z.int().min(0).max(65535).default(3000), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); ``` ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), PORT: v.optional( v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(65535)), 3000, ), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), }); ``` Install either stack the same way you would any env schema: one validation package, then framework plugins if you need them. ArkType engine: npm pnpm yarn bun nub ```bash npm install @arkenv/core arktype ``` ```bash pnpm add @arkenv/core arktype ``` ```bash yarn add @arkenv/core arktype ``` ```bash bun install @arkenv/core arktype ``` ```bash nub add @arkenv/core arktype ``` Standard Schema engine (Zod): npm pnpm yarn bun nub ```bash npm install @arkenv/standard zod ``` ```bash pnpm add @arkenv/standard zod ``` ```bash yarn add @arkenv/standard zod ``` ```bash bun install @arkenv/standard zod ``` ```bash nub add @arkenv/standard zod ``` Standard Schema engine (Valibot): npm pnpm yarn bun nub ```bash npm install @arkenv/standard valibot @valibot/to-json-schema ``` ```bash pnpm add @arkenv/standard valibot @valibot/to-json-schema ``` ```bash yarn add @arkenv/standard valibot @valibot/to-json-schema ``` ```bash bun install @arkenv/standard valibot @valibot/to-json-schema ``` ```bash nub add @arkenv/standard valibot @valibot/to-json-schema ``` ## When each fits [#when-each-fits] | Situation | Engine | | --------------------------------------------------------- | ------------------ | | You want ArkType's DSL and ArkEnv keywords | `@arkenv/core` | | The app already uses Zod or Valibot for other schemas | `@arkenv/standard` | | You must stay ArkType-free | `@arkenv/standard` | | You are scaffolding a new TypeScript app and like ArkType | `@arkenv/core` | ArkType itself implements Standard Schema. If you already depend on ArkType, stay on `@arkenv/core` rather than wrapping ArkType types through `@arkenv/standard`. ## Packaging [#packaging] Engines are separate packages so peer dependencies stay honest. Framework plugins stay single packages with `/standard` subpaths (`@arkenv/nextjs/standard`, `@arkenv/vite-plugin/standard`, and so on) instead of a second npm package per host. ## JSON Schema and coercion [#json-schema-and-coercion] [Standard Schema](https://standardschema.dev/) and [Standard JSON Schema](https://standardschema.dev/json-schema) are **orthogonal** specs: one is about validation, the other about converting a type to JSON Schema. A value can implement one, both, or neither. ArkEnv's pre-coercion step for `@arkenv/standard` prefers **Standard JSON Schema v1** on the value (`~standard.jsonSchema.input` / `.output`). When a library keeps conversion outside the schema, use a first-class subpath or the optional [`toJsonSchema`](/docs/core-concepts/coercion-and-parsing#valibot-tojsonschema) escape hatch. | Library | How ArkEnv gets JSON Schema | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Zod (v4.2+) | On the value (Standard JSON Schema v1) | | VineJS (v4.3+) | On the value (Standard JSON Schema v1) | | Valibot (v1.2+ with `@valibot/to-json-schema`) | `@arkenv/standard/valibot` (see [Valibot](/docs/validators/valibot)) | | Zod Mini | `@arkenv/standard/zod-mini` ([mixing](/docs/core-concepts/coercion-and-parsing#mixing-with-zod-mini)) | | Other Standard JSON Schema v1 validators | On the value | See [Coercion and parsing](/docs/core-concepts/coercion-and-parsing). A one-file `Env.assert(process.env)` works for a hello-world Node script. ArkEnv adds env-specific errors, automatic coercion, a CLI, and framework plugins that keep server secrets out of the client graph. See [Why ArkEnv?](/docs/why-arkenv) for DIY, Varlock, T3 Env, znv, Envalid, and related tools. ## Cookbooks [#cookbooks] # Valibot (/docs/validators/valibot) Valibot's `v.number()` and `v.boolean()` expect real numbers and booleans. Environment variables arrive as strings, so ArkEnv's `@arkenv/standard/valibot` entry wires coercion before those schemas run. Valibot implements Standard Schema. It does not embed JSON Schema on the schema object, so the subpath pre-configures [`@valibot/to-json-schema`](https://valibot.dev/guides/json-schema/). That converter is what lets ArkEnv turn strings into numbers, booleans, and arrays. How the two engines differ is in [Validators](/docs/validators). ## Installation [#installation] Install `@arkenv/standard`, `valibot`, and the JSON Schema converter. The converter is an optional peer of `@arkenv/standard`. The `/valibot` entry imports it, so this path needs the package installed. npm pnpm yarn bun nub ```bash npm install @arkenv/standard valibot @valibot/to-json-schema ``` ```bash pnpm add @arkenv/standard valibot @valibot/to-json-schema ``` ```bash yarn add @arkenv/standard valibot @valibot/to-json-schema ``` ```bash bun install @arkenv/standard valibot @valibot/to-json-schema ``` ```bash nub add @arkenv/standard valibot @valibot/to-json-schema ``` TypeScript must use `moduleResolution: "bundler" | "node16" | "nodenext"`. Subpath exports are not resolved under legacy `"node"` module resolution. ## Define your schema [#define-your-schema] Put Valibot schemas in `env.ts` and import `arkenv` from `@arkenv/standard/valibot`, not the root `@arkenv/standard` entry. The root import does not bind Valibot's JSON Schema converter, so numbers and booleans stay strings. ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/valibot"; import * as v from "valibot"; export const env = arkenv({ DATABASE_URL: v.pipe(v.string(), v.url()), PORT: v.optional(v.number(), 3000), DEBUG: v.optional(v.boolean(), false), TAGS: v.optional(v.array(v.string()), []), NODE_ENV: v.optional( v.picklist(["development", "production", "test"]), "development", ), }); ``` Use `v.number()` and `v.boolean()` directly. Manual `v.transform(Number)` steps are not required. ## JSON Schema configuration [#json-schema-configuration] Valibot does not embed JSON Schema metadata on schema instances. The `/valibot` subpath calls `@valibot/to-json-schema` with: * `typeMode: "input"` so piped and transformed schemas are evaluated according to their input types and strings can be coerced * `target: "draft-07"` so output matches the JSON Schema draft ArkEnv expects The root `toJsonSchema` callback remains available on `@arkenv/standard` for custom converters or mixed-validator maps. See [Coercion](/docs/core-concepts/coercion-and-parsing#valibot-tojsonschema). ## Defaults and optionals [#defaults-and-optionals] Valibot fallbacks and optionals map onto ArkEnv the same way as other engines: | Pattern | Behavior | | :--------------------------------- | :------------------------------------------------------------------------ | | `v.optional(schema, defaultValue)` | Fallback value used when the variable is undefined | | `v.optional(schema)` | Allows the variable to remain undefined | | `emptyAsUndefined: true` | Converts empty strings (`""`) to `undefined` so optional defaults trigger | ## Next steps [#next-steps] # Zod (/docs/validators/zod) The Zod schemas you already write for other inputs can validate the environment too. ArkEnv runs them against that environment and coerces string values before they hit Zod. Zod 4.2+ embeds [Standard JSON Schema](https://standardschema.dev/json-schema) on schema objects, which is what makes `"3000"` and `"true"` coerce. Classic Zod uses the root `@arkenv/standard` import. There is no `@arkenv/standard/zod` subpath. `@arkenv/standard/valibot` and `@arkenv/standard/zod-mini` exist only to bind JSON Schema converters those libraries keep off the value. How the two engines differ is in [Validators](/docs/validators). ## Installation [#installation] Install `@arkenv/standard` next to `zod`. `@arkenv/standard` has no runtime dependencies. Zod stays your dependency. npm pnpm yarn bun nub ```bash npm install @arkenv/standard zod ``` ```bash pnpm add @arkenv/standard zod ``` ```bash yarn add @arkenv/standard zod ``` ```bash bun install @arkenv/standard zod ``` ```bash nub add @arkenv/standard zod ``` ## Define your schema [#define-your-schema] Put the Zod schemas you already write into `env.ts`. Each field is that schema. Import `{ env }` where the process starts. ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import * as z from "zod"; export const env = arkenv({ DATABASE_URL: z.url(), PORT: z.int().min(0).max(65535).default(3000), DEBUG: z.boolean().default(false), TAGS: z.array(z.string()).default([]), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), }); ``` Because Zod 4.2+ includes JSON Schema metadata, ArkEnv automatically converts `"3000"` to `3000`, `"true"` to `true`, and comma-separated lists like `"web, api"` into string arrays before validation. You can use standard `z.number()` and `z.boolean()` directly without manual `z.coerce` helpers. ## Defaults and optionals [#defaults-and-optionals] Zod methods for defaults and optional fields behave consistently with ArkEnv: | Method | Behavior | | :----------------------- | :-------------------------------------------------------------------- | | `.default(...)` | Fallback value used when the environment variable is missing | | `.optional()` | Allows the environment variable to remain undefined | | `emptyAsUndefined: true` | Converts empty strings (`""`) to `undefined` so `.default()` triggers | ## Zod Mini [#zod-mini] Skip this section. The root import already coerces. Zod Mini is the build that leaves JSON Schema off the schema object. [Zod Mini](https://zod.dev/packages/mini) (`import * as z from "zod/mini"`) omits embedded JSON Schema metadata from schema instances. Import `@arkenv/standard/zod-mini` so Mini `z.number()` and `z.boolean()` coerce without a callback: ```ts title="./env.ts" twoslash import { arkenv } from "@arkenv/standard/zod-mini"; import * as z from "zod/mini"; export const env = arkenv({ PORT: z.number(), DEBUG: z.boolean(), }); ``` TypeScript must use `moduleResolution: "bundler" | "node16" | "nodenext"`. The root `toJsonSchema` callback remains available for mixed maps; see [Coercion](/docs/core-concepts/coercion-and-parsing#mixing-with-zod-mini). ## Zod v3 [#zod-v3] Zod v3 (including `zod/v3` subpath exports in Zod 4) implements Standard Schema validation but lacks native JSON Schema metadata. You can enable automatic coercion by passing [`zod-to-json-schema`](https://github.com/StefanTerdell/zod-to-json-schema) through the `toJsonSchema` callback: ```ts title="./env.ts" twoslash import arkenv from "@arkenv/standard"; import { z } from "zod/v3"; import { zodToJsonSchema } from "zod-to-json-schema"; export const env = arkenv( { PORT: z.number(), DEBUG: z.boolean(), }, { toJsonSchema: (schema) => zodToJsonSchema(schema as z.ZodTypeAny, { $refStrategy: "none", }), }, ); ``` Use Zod 4.2+ when you can. It ships JSON Schema metadata, so you skip the `toJsonSchema` callback. ## Next steps [#next-steps] # Why ArkEnv? (/docs/why-arkenv) ArkEnv is designed to work seamlessly with the validation library you already use. ArkEnv provides a typed `env` object from a TypeScript schema, plus first-party plugins for the frameworks you ship on. Start at [Migrating from T3 Env](/docs/guides/migrating-from-t3-env) for a step-by-step transition guide. ## Comparison cheatsheet [#comparison-cheatsheet] | Feature | **ArkEnv** | Varlock | T3 Env | vite-plugin-validate-env | znv | Envalid | | :---------------------------------- | :--------: | :-----: | :----: | :----------------------: | :-: | :-----: | | **Pure TypeScript schemas** | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | | **Native ArkType support\*** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | **Standard Schema (Zod / Valibot)** | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | | **Automatic coercion** | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | | **First-class Next.js support** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | | **First-class Vite support** | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | | **First-class Nuxt support** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | | **Command Line Interface** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | **Remote secret orchestration** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | **Secret masking & log redaction** | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | \* ArkType is supported directly without wrapping each key in ArkType's `type()`. ✅ Built-in / first-class support ❌ Not supported ## Detailed comparisons [#detailed-comparisons] ### DIY [#diy] Most teams can validate `process.env` with ArkType (or Zod or Valibot) in a few lines: ```ts title="env.ts" twoslash import { type } from "arktype"; const Env = type({ PORT: type("string.integer.parse").to("0 <= number <= 65535").default("3000"), NODE_ENV: "'development' | 'production' | 'test' = 'development'", }); export const env = Env.assert(process.env); ``` Past a hello-world script, the DIY path gets tedious: * **Manual parsing.** `process.env` is strings only, so every number or boolean needs a parse. ArkEnv handles this with built-in [coercion](/docs/core-concepts/coercion-and-parsing). * **Verbose errors.** Most validation libraries log as if you sent a nested API payload. ArkEnv prints a concise env-var dump. * **Framework bundler quirks.** Passing `process.env` into a schema fails in Vite and Bun, where variables are statically replaced. ArkEnv's plugins rewrite `env.ts` in the client graph and still validate at boot on the server. * **Silent degradation.** Accessing an unconfigured or server-only variable on a plain object returns `undefined`. Code silently falls back to defaults or degrades until an obscure error surfaces downstream. ArkEnv wraps client environments in a fail-fast proxy that throws immediately on boundary violations. * **Repeated boilerplate.** Copying the same assert across projects gets old. ArkEnv is one declarative `arkenv()` call and `import { env } from "./env"`. If that list is the work you wanted to skip, start at [Getting started](/docs/getting-started). ### Varlock [#varlock] [Varlock](https://varlock.dev) is a heavy-duty environment orchestrator. It handles remote secret fetching, encrypted deployments, log redaction, and AI credential proxying so agents never touch raw secrets. Because it operates as an infrastructure layer, Varlock requires its own custom DSL ([env-spec](https://varlock.dev/env-spec/reference/)). You define validation rules inside a `.env.schema` file using comment decorators (for example [`# @type=string(startsWith="postgresql://")`](https://varlock.dev/reference/item-decorators/#type)). It does not integrate with the TypeScript validators you already use for request bodies and API contracts. ArkEnv leaves secret rotation and remote fetching to your hosting provider or secret manager. It focuses on a Typesafe `env` object from standard Zod, ArkType, or Valibot schemas, wired through first-party framework plugins. Use Varlock if you need a dedicated infrastructure orchestrator to pipe secrets across environments. Use ArkEnv if you want environment variables to validate exactly like the rest of your TypeScript app. ### T3 Env [#t3-env] [T3 Env](https://env.t3.gg) inspired ArkEnv's flat `env` object and client/server split. The day-to-day differences are mostly boilerplate and how much of the stack each tool covers: * **Fewer moving parts.** In Next.js and Nuxt, T3 Env needs a dedicated `runtimeEnv` map (for example `NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL`). Drift between that map and your schema can break the app silently. ArkEnv removes that sync work: Next.js generates the mapping at build time; Nuxt reads public values from `useRuntimeConfig()` at runtime, with no generated files. * **Flat by default, still safe.** A runtime proxy throws when a server variable is accessed on the client instead of returning `undefined` or allowing silent fallback logic. You skip mandatory `server:` / `client:` blocks unless you need compile-time isolation of sensitive *names*. See [Client vs. server](/docs/core-concepts/client-vs-server) and [Migrating from T3 Env](/docs/guides/migrating-from-t3-env). * **Schema flexibility.** Native ArkType DSL without wrapping each key in `type()`, or Zod / Valibot. T3 Env speaks Standard Schema; it does not take ArkType strings the same way. ### vite-plugin-validate-env [#vite-plugin-validate-env] [@julr/vite-plugin-validate-env](https://github.com/julien-r44/vite-plugin-validate-env) is a strong build-time check when your world is strictly Vite and you already speak Standard Schema. ArkEnv covers that same Vite workflow, while adding automatic coercion, native ArkType definitions, and parity across other runtimes if your stack expands. ### znv [#znv] [znv](https://github.com/lostfictions/znv) is a lean, zero-dependency environment validator that pioneered clean runtime coercion. ArkEnv builds on that coercion model while decoupling from Zod, so you can use ArkType, Zod, or Valibot with first-party framework integration. ### Envalid [#envalid] [Envalid](https://github.com/af/envalid) is a battle-tested validator that relies on custom validator functions and synthetic types. ArkEnv replaces those helpers with the standard TypeScript schemas and framework plugins you already use across the rest of your app. ## Next steps [#next-steps]