TanStack Start

Learn how to use ArkEnv in a TanStack Start project.

Edit on GitHub

TanStack Start is designed to work seamlessly with typesafe env validation. TanStack Start apps use ArkEnv to validate environment variables at boot, inline public keys into the client bundle, and keep server secrets inside 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.

This is the existing fail-fast contract, not a new lazy-validation option. ArkEnv does not add a default-off flag for lazy validation.

For high-level architectural trade-offs, see Frameworks.

Quickstart

Vite is the default path. The TanStack CLI add-on scaffolds the Vite plugin, an env.ts schema, and ArkType as the validator engine.

Create a new project

Generate a TanStack Start app with ArkEnv wired in:

npx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json
pnpm dlx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json
yarn dlx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json
bunx @tanstack/cli create my-app --add-ons https://arkenv.js.org/tanstack/info.json

The add-on installs the validator runtime, registers arkenvVitePlugin() in vite.config.ts, writes src/env.ts, and pre-populates declared keys in .env.example.

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:

npx @tanstack/cli add https://arkenv.js.org/tanstack/info.json
pnpm dlx @tanstack/cli add https://arkenv.js.org/tanstack/info.json
yarn dlx @tanstack/cli add https://arkenv.js.org/tanstack/info.json
bunx @tanstack/cli add https://arkenv.js.org/tanstack/info.json
npx arkenv init
pnpm dlx arkenv init
yarn dlx arkenv init
bunx arkenv init

arkenv init detects @tanstack/react-start and installs @arkenv/vite-plugin the same way it does for Vite projects.

Using Rsbuild?

ArkEnv fully supports Rsbuild via @arkenv/rsbuild-plugin. @tanstack/cli does not currently support Rsbuild for scaffolding or add-ons (track TanStack/cli#505). Configure via Manual installation and Rsbuild.

Pinning RCs

Until GA, pin exact @arkenv/* RC versions in the lockfile if you want bit-for-bit reproducibility across machines and CI.

Manual installation

Install the plugin that matches your bundler alongside your validation engine. Author bare @arkenv/* package names; the docs site applies the release tag automatically.

Prefer @arkenv/core and your host plugin as app dependencies — not the arkenv CLI package.

Vite

ArkType engine

npm install @arkenv/core arktype
npm install -D @arkenv/vite-plugin
pnpm add @arkenv/core arktype
pnpm add -D @arkenv/vite-plugin
yarn add @arkenv/core arktype
yarn add --dev @arkenv/vite-plugin
bun install @arkenv/core arktype
bun install --dev @arkenv/vite-plugin

Standard Schema engine

If you aren't using ArkType, install @arkenv/standard:

npm install @arkenv/standard
npm install -D @arkenv/vite-plugin
pnpm add @arkenv/standard
pnpm add -D @arkenv/vite-plugin
yarn add @arkenv/standard
yarn add --dev @arkenv/vite-plugin
bun install @arkenv/standard
bun install --dev @arkenv/vite-plugin

Rsbuild

ArkType engine

npm install @arkenv/core arktype
npm install -D @arkenv/rsbuild-plugin
pnpm add @arkenv/core arktype
pnpm add -D @arkenv/rsbuild-plugin
yarn add @arkenv/core arktype
yarn add --dev @arkenv/rsbuild-plugin
bun install @arkenv/core arktype
bun install --dev @arkenv/rsbuild-plugin

Standard Schema engine

If you aren't using ArkType, install @arkenv/standard:

npm install @arkenv/standard
npm install -D @arkenv/rsbuild-plugin
pnpm add @arkenv/standard
pnpm add -D @arkenv/rsbuild-plugin
yarn add @arkenv/standard
yarn add --dev @arkenv/rsbuild-plugin
bun install @arkenv/standard
bun install --dev @arkenv/rsbuild-plugin

Configuration

Register the ArkEnv plugin next to TanStack Start and React in your bundler config.

Vite

TanStack Start's React recipe on Vite expects viteReact() after tanstackStart() (for JSX / Fast Refresh). Add arkenvVitePlugin() to the same plugins array:

./vite.config.ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import arkenvVitePlugin 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(),
    arkenvVitePlugin(),
  ],
});

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.

React Refresh must come after Start's plugin

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

TanStack Start also supports Rsbuild via @tanstack/react-start/plugin/rsbuild. Register arkenvRsbuildPlugin() in your rsbuild.config.ts:

./rsbuild.config.ts
import { pluginReact } from "@rsbuild/plugin-react";
import { tanstackStart } from "@tanstack/react-start/plugin/rsbuild";
import { arkenvRsbuildPlugin } from "@arkenv/rsbuild-plugin";
import { defineConfig } from "@rsbuild/core";

export default defineConfig({
  plugins: [
    tanstackStart({ srcDirectory: "src" }),
    pluginReact(),
    arkenvRsbuildPlugin(),
  ],
});

If you aren't using ArkType, import the plugin from @arkenv/rsbuild-plugin/standard.

Define your schema

Create an env.ts file in your source tree. Keys your client bundle reads must match your bundler's client prefix: VITE_ for Vite or PUBLIC_ for Rsbuild. Everything else stays on the server.

Import from the runtime package, not the CLI

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.

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

export const  = ({
  : "string",
  : "number.port = 3000",
  : "string = 'https://api.example.com'",
  : "'development' | 'production' | 'test' = 'development'",
});
./src/env.ts
import  from "@arkenv/core";

export const  = ({
  : "string",
  : "number.port = 3000",
  : "string = 'https://api.example.com'",
  : "'development' | 'production' | 'test' = 'development'",
});

Pass the schema as an object literal

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

Read the validated env object from your schema module in routes and server functions.

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:

./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 (
    <div>
      <h1>API: {env.VITE_API_URL}</h1>
      <p>Database Host: {dbHost}</p>
    </div>
  );
}
./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 (
    <div>
      <h1>API: {env.PUBLIC_API_URL}</h1>
      <p>Database Host: {dbHost}</p>
    </div>
  );
}

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.

Use the env import, not import.meta.env

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:

env.VITE_API_URL; // string, inlined into the client bundle
env.DATABASE_URL; // throws in the browser; real value on the server
env.PUBLIC_API_URL; // string, inlined into the client bundle
env.DATABASE_URL; // throws in the browser; real value on the server

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 the real @arkenv/core runtime 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

Run a complete setup end to end:

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