diff --git a/.changeset/arkenv-addon.md b/.changeset/arkenv-addon.md
new file mode 100644
index 00000000..10073e85
--- /dev/null
+++ b/.changeset/arkenv-addon.md
@@ -0,0 +1,10 @@
+---
+'@tanstack/create': minor
+---
+
+Add an ArkEnv add-on for environment variable validation in React Start apps.
+
+`tanstack add arkenv` (or `--add-ons arkenv` on `tanstack create`)
+writes `src/env.ts`, registers `@arkenv/vite-plugin`, and can add a
+`/demo/arkenv` route. ArkType, Zod, and Valibot are selectable. ArkEnv
+and T3Env are mutually exclusive because both write `src/env.ts`.
diff --git a/packages/create/scripts/generate-manifest.mjs b/packages/create/scripts/generate-manifest.mjs
index cdb61f56..70983e14 100644
--- a/packages/create/scripts/generate-manifest.mjs
+++ b/packages/create/scripts/generate-manifest.mjs
@@ -120,6 +120,15 @@ function scanCatalogDirectory(addOnsBase) {
const addOnDir = join(addOnsBase, entry.name)
const info = readJson(join(addOnDir, 'info.json'))
+ for (const integration of info.integrations ?? []) {
+ if (
+ typeof integration.import === 'string' &&
+ integration.import.includes('<%')
+ ) {
+ registerTemplate(integration.import)
+ }
+ }
+
let packageAdditions = {}
let packageTemplate
const packageJsonPath = join(addOnDir, 'package.json')
@@ -430,6 +439,18 @@ function createTemplateRenderersForAddOn(addOn) {
renderers.set(getTemplateKey(addOn.readme), compileTemplate(addOn.readme))
}
+ for (const integration of addOn.integrations ?? []) {
+ if (
+ typeof integration.import === 'string' &&
+ integration.import.includes('<%')
+ ) {
+ renderers.set(
+ getTemplateKey(integration.import),
+ compileTemplate(integration.import),
+ )
+ }
+ }
+
return renderers
}
diff --git a/packages/create/src/edge-template-file.ts b/packages/create/src/edge-template-file.ts
index 916a2342..ea5091eb 100644
--- a/packages/create/src/edge-template-file.ts
+++ b/packages/create/src/edge-template-file.ts
@@ -117,10 +117,6 @@ export function createTemplateFile(environment: Environment, options: Options) {
const localRelativePath = (path: string, stripExtension: boolean = false) =>
relativePath(file, path, stripExtension)
- const integrationImportContent = (integration: Integration) =>
- integration.import ||
- `import ${integration.jsName} from '${localRelativePath(integration.path || '')}'`
-
const integrationImportCode = (integration: Integration) =>
integration.code || integration.jsName
@@ -150,7 +146,17 @@ export function createTemplateFile(environment: Environment, options: Options) {
relativePath: (path: string, stripExtension: boolean = false) =>
relativePath(file, path, stripExtension),
- integrationImportContent,
+ integrationImportContent: (integration: Integration) => {
+ const raw =
+ integration.import ||
+ `import ${integration.jsName} from '${localRelativePath(integration.path || '')}'`
+
+ if (!raw.includes('<%')) {
+ return raw
+ }
+
+ return renderForOptions(options, raw, templateValues)
+ },
integrationImportCode,
renderTemplate: (templateContent: string) => {
diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/README.md b/packages/create/src/frameworks/react/add-ons/arkenv/README.md
new file mode 100644
index 00000000..be775a12
--- /dev/null
+++ b/packages/create/src/frameworks/react/add-ons/arkenv/README.md
@@ -0,0 +1,18 @@
+## ArkEnv
+
+Typesafe environment variables for TanStack Start. The add-on installs
+`@arkenv/vite-plugin`, writes `src/env.ts`, and can add a `/demo/arkenv`
+route that shows server-only keys staying on the server.
+
+Pick a validator when you scaffold: ArkType (`@arkenv/core`), Zod, or
+Valibot (`@arkenv/standard`).
+
+### Usage
+
+```ts
+import { env } from "#/env";
+
+console.log(env.VITE_API_URL);
+```
+
+Docs: [https://arkenv.js.org/docs/frameworks/tanstack-start](https://arkenv.js.org/docs/frameworks/tanstack-start)
diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/assets/_dot_env.example b/packages/create/src/frameworks/react/add-ons/arkenv/assets/_dot_env.example
new file mode 100644
index 00000000..079ebef3
--- /dev/null
+++ b/packages/create/src/frameworks/react/add-ons/arkenv/assets/_dot_env.example
@@ -0,0 +1,11 @@
+# Port for the dev/preview server
+PORT=3000
+
+# Public API URL (inlined into client bundle)
+VITE_API_URL=https://api.example.com
+
+# Server-only database connection URL (protected from client access)
+DATABASE_URL=postgresql://postgres:postgres@localhost:5432/db
+
+# Environment mode
+NODE_ENV=development
diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/env.ts.ejs b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/env.ts.ejs
new file mode 100644
index 00000000..33458e19
--- /dev/null
+++ b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/env.ts.ejs
@@ -0,0 +1,55 @@
+<% const validator = (addOnOption.arkenv && addOnOption.arkenv.validator) || 'arktype' -%>
+<% if (validator === 'zod') { -%>
+import arkenv from '@arkenv/standard'
+import { z } from 'zod'
+
+export const env = arkenv({
+ PORT: z.coerce.number().int().min(1).max(65535).default(3000),
+ VITE_API_URL: z.string().url().default('https://api.example.com'),
+ DATABASE_URL: z
+ .string()
+ .url()
+ .default('postgresql://postgres:postgres@localhost:5432/db'),
+ NODE_ENV: z
+ .enum(['development', 'production', 'test'])
+ .default('development'),
+})
+<% } else if (validator === 'valibot') { -%>
+import arkenv from '@arkenv/standard'
+import * as v from 'valibot'
+
+export const env = arkenv({
+ PORT: v.optional(
+ v.pipe(
+ v.unknown(),
+ v.transform(Number),
+ v.integer(),
+ v.minValue(1),
+ v.maxValue(65535),
+ ),
+ 3000,
+ ),
+ VITE_API_URL: v.optional(
+ v.pipe(v.string(), v.url()),
+ 'https://api.example.com',
+ ),
+ DATABASE_URL: v.optional(
+ v.pipe(v.string(), v.url()),
+ 'postgresql://postgres:postgres@localhost:5432/db',
+ ),
+ NODE_ENV: v.optional(
+ v.picklist(['development', 'production', 'test']),
+ 'development',
+ ),
+})
+<% } else { -%>
+import arkenv from '@arkenv/core'
+
+export const env = arkenv({
+ PORT: 'number.port = 3000',
+ VITE_API_URL: "string = 'https://api.example.com'",
+ DATABASE_URL:
+ "string = 'postgresql://postgres:postgres@localhost:5432/db'",
+ NODE_ENV: "'development' | 'production' | 'test' = 'development'",
+})
+<% } -%>
diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/routes/demo/arkenv.tsx.ejs b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/routes/demo/arkenv.tsx.ejs
new file mode 100644
index 00000000..953ea617
--- /dev/null
+++ b/packages/create/src/frameworks/react/add-ons/arkenv/assets/src/routes/demo/arkenv.tsx.ejs
@@ -0,0 +1,82 @@
+<% if (!includeExamples) { ignoreFile(); return; } %>
+import { useState } from 'react'
+import { createFileRoute } from '@tanstack/react-router'
+import { createServerFn } from '@tanstack/react-start'
+import { env } from '../../env'
+
+const getDatabaseConfig = createServerFn({ method: 'GET' }).handler(() => {
+ // Read the server-only key here. Return a fixed example so the deployed
+ // database endpoint is not sent to the browser.
+ if (!env.DATABASE_URL) {
+ throw new Error('DATABASE_URL is not set')
+ }
+
+ return { host: 'localhost:5432', protocol: 'postgresql:' }
+})
+
+export const Route = createFileRoute('/demo/arkenv')({
+ component: ArkEnvDemo,
+ loader: () => getDatabaseConfig(),
+})
+
+function LeakedSecret() {
+ // Accessing server-only DATABASE_URL directly on the client throws at runtime
+ return
Server key leaked: {env.DATABASE_URL}
+}
+
+function ArkEnvDemo() {
+ const dbConfig = Route.useLoaderData()
+ const [attemptLeak, setAttemptLeak] = useState(false)
+
+ return (
+
+
ArkEnv Demo
+
+ Typesafe environment variables with build-time validation and runtime
+ leak protection.
+
+
+
+
Public Client Variables
+
+ Inlined safely into client bundles:
+
+
+ env.VITE_API_URL: {env.VITE_API_URL}
+
+
+
+
+
Server-Only Variables
+
+ Accessible inside createServerFn handlers. This page shows an example
+ endpoint, not the deployed database host:
+
+
+ Example endpoint: {dbConfig.host} ({dbConfig.protocol})
+
+
+
+
+
+ Secret Leak Protection
+
+
+ Clicking the button below attempts to access the server secret{' '}
+ env.DATABASE_URL on the client, which ArkEnv blocks:
+
+ {attemptLeak ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/info.json b/packages/create/src/frameworks/react/add-ons/arkenv/info.json
new file mode 100644
index 00000000..d780c64a
--- /dev/null
+++ b/packages/create/src/frameworks/react/add-ons/arkenv/info.json
@@ -0,0 +1,49 @@
+{
+ "id": "arkenv",
+ "name": "ArkEnv",
+ "description": "Typesafe environment variable validation with build-time validation and runtime leak protection.",
+ "type": "add-on",
+ "phase": "add-on",
+ "category": "tooling",
+ "exclusive": ["env"],
+ "color": "#06B6D4",
+ "priority": 28,
+ "link": "https://arkenv.js.org",
+ "modes": ["file-router", "code-router"],
+ "options": {
+ "validator": {
+ "type": "select",
+ "label": "Validator Engine",
+ "default": "arktype",
+ "options": [
+ {
+ "value": "arktype",
+ "label": "ArkType (@arkenv/core) - Recommended"
+ },
+ {
+ "value": "zod",
+ "label": "Zod (@arkenv/standard)"
+ },
+ {
+ "value": "valibot",
+ "label": "Valibot (@arkenv/standard)"
+ }
+ ]
+ }
+ },
+ "routes": [
+ {
+ "url": "/demo/arkenv",
+ "name": "ArkEnv Demo",
+ "path": "src/routes/demo/arkenv.tsx",
+ "jsName": "ArkEnvDemo"
+ }
+ ],
+ "integrations": [
+ {
+ "type": "vite-plugin",
+ "import": "import arkenv from '<%= (addOnOption.arkenv && (addOnOption.arkenv.validator === 'zod' || addOnOption.arkenv.validator === 'valibot')) ? '@arkenv/vite-plugin/standard' : '@arkenv/vite-plugin' %>'",
+ "code": "arkenv()"
+ }
+ ]
+}
diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs b/packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs
new file mode 100644
index 00000000..dda88865
--- /dev/null
+++ b/packages/create/src/frameworks/react/add-ons/arkenv/package.json.ejs
@@ -0,0 +1,18 @@
+<% const validator = (addOnOption.arkenv && addOnOption.arkenv.validator) || 'arktype' -%>
+{
+ "dependencies": {
+<% if (validator === 'zod') { -%>
+ "@arkenv/standard": "^1.0.0-rc.2",
+ "zod": "^4.4.1"
+<% } else if (validator === 'valibot') { -%>
+ "@arkenv/standard": "^1.0.0-rc.2",
+ "valibot": "^1.0.0"
+<% } else { -%>
+ "@arkenv/core": "^1.0.0-rc.2",
+ "arktype": "^2.2.0"
+<% } -%>
+ },
+ "devDependencies": {
+ "@arkenv/vite-plugin": "^1.0.0-rc.2"
+ }
+}
diff --git a/packages/create/src/frameworks/react/add-ons/arkenv/small-logo.svg b/packages/create/src/frameworks/react/add-ons/arkenv/small-logo.svg
new file mode 100644
index 00000000..5dd0d792
--- /dev/null
+++ b/packages/create/src/frameworks/react/add-ons/arkenv/small-logo.svg
@@ -0,0 +1,14 @@
+
diff --git a/packages/create/src/frameworks/react/add-ons/t3env/info.json b/packages/create/src/frameworks/react/add-ons/t3env/info.json
index 55a3b856..4e7dd903 100644
--- a/packages/create/src/frameworks/react/add-ons/t3env/info.json
+++ b/packages/create/src/frameworks/react/add-ons/t3env/info.json
@@ -4,6 +4,7 @@
"phase": "add-on",
"type": "add-on",
"category": "tooling",
+ "exclusive": ["env"],
"color": "#6366F1",
"priority": 29,
"link": "https://github.com/t3-oss/t3-env",
diff --git a/packages/create/src/template-file.ts b/packages/create/src/template-file.ts
index 8713c127..c564c269 100644
--- a/packages/create/src/template-file.ts
+++ b/packages/create/src/template-file.ts
@@ -120,10 +120,6 @@ export function createTemplateFile(environment: Environment, options: Options) {
const localRelativePath = (path: string, stripExtension: boolean = false) =>
relativePath(file, path, stripExtension)
- const integrationImportContent = (integration: Integration) =>
- integration.import ||
- `import ${integration.jsName} from '${localRelativePath(integration.path || '')}'`
-
const integrationImportCode = (integration: Integration) =>
integration.code || integration.jsName
@@ -153,7 +149,17 @@ export function createTemplateFile(environment: Environment, options: Options) {
relativePath: (path: string, stripExtension: boolean = false) =>
relativePath(file, path, stripExtension),
- integrationImportContent,
+ integrationImportContent: (integration: Integration) => {
+ const raw =
+ integration.import ||
+ `import ${integration.jsName} from '${localRelativePath(integration.path || '')}'`
+
+ if (!raw.includes('<%')) {
+ return raw
+ }
+
+ return render(raw, templateValues)
+ },
integrationImportCode,
renderTemplate: (content: string) => {
diff --git a/packages/create/src/types.ts b/packages/create/src/types.ts
index 27e1212f..cfb17f48 100644
--- a/packages/create/src/types.ts
+++ b/packages/create/src/types.ts
@@ -60,7 +60,7 @@ export const AddOnBaseSchema = z.object({
])
.optional(),
exclusive: z
- .array(z.enum(['orm', 'auth', 'deploy', 'database', 'linter']))
+ .array(z.enum(['orm', 'auth', 'deploy', 'database', 'linter', 'env']))
.optional(),
color: z.string().optional(),
priority: z.number().optional(),
diff --git a/packages/create/tests/arkenv-addon.test.ts b/packages/create/tests/arkenv-addon.test.ts
new file mode 100644
index 00000000..c09cdc5c
--- /dev/null
+++ b/packages/create/tests/arkenv-addon.test.ts
@@ -0,0 +1,183 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ finalizeAddOns,
+ populateAddOnOptionsDefaults,
+} from '../src/add-ons.js'
+import { createApp } from '../src/create-app.js'
+import { createMemoryEnvironment } from '../src/environment.js'
+import {
+ createApp as createEdgeApp,
+ createMemoryEnvironment as createEdgeMemoryEnvironment,
+ finalizeAddOns as finalizeEdgeAddOns,
+ getFrameworkById as getEdgeFrameworkById,
+ populateAddOnOptionsDefaults as populateEdgeAddOnOptionsDefaults,
+} from '../src/edge.js'
+import { createFrameworkDefinition } from '../src/frameworks/react/index.js'
+import { createBundledWorkerManifestLoader } from '../src/generated/worker/bundled-loader.js'
+import {
+ createMemoryEnvironment as createWorkerMemoryEnvironment,
+ createWorkerCreate,
+} from '../src/worker.js'
+
+import type { Framework, FrameworkDefinition, Options } from '../src/types.js'
+
+function frameworkFromDefinition(definition: FrameworkDefinition): Framework {
+ const { addOns, base, ...framework } = definition
+
+ return {
+ ...framework,
+ getFiles: () => Promise.resolve(Object.keys(base)),
+ getFileContents: (path: string) => Promise.resolve(base[path]),
+ getDeletedFiles: () => Promise.resolve([]),
+ getAddOns: () => addOns,
+ }
+}
+
+async function generateArkEnvApp(validator?: string) {
+ const definition = createFrameworkDefinition()
+ const framework = frameworkFromDefinition(definition)
+ const chosenAddOns = await finalizeAddOns(framework, 'file-router', ['arkenv'])
+ const targetDir = '/arkenv-app'
+ const { environment, output } = createMemoryEnvironment(targetDir)
+
+ await createApp(environment, {
+ projectName: 'arkenv-app',
+ targetDir,
+ framework,
+ mode: 'file-router',
+ typescript: true,
+ tailwind: true,
+ packageManager: 'pnpm',
+ git: false,
+ install: false,
+ intent: false,
+ chosenAddOns,
+ addOnOptions: {
+ ...populateAddOnOptionsDefaults(chosenAddOns),
+ ...(validator ? { arkenv: { validator } } : {}),
+ },
+ includeExamples: true,
+ } satisfies Options)
+
+ return output
+}
+
+beforeEach(() => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(
+ async () =>
+ new Response(JSON.stringify({ version: '1.0.0' }), { status: 200 }),
+ ),
+ )
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('ArkEnv add-on', () => {
+ it('uses the ArkType Vite plugin by default and keeps the database endpoint off the demo page', async () => {
+ const output = await generateArkEnvApp()
+ const demo = output.files['src/routes/demo/arkenv.tsx']
+
+ expect(output.files['vite.config.ts']).toContain(
+ "import arkenv from '@arkenv/vite-plugin'",
+ )
+ expect(output.files['vite.config.ts']).not.toContain(
+ '@arkenv/vite-plugin/standard',
+ )
+ expect(output.files['src/env.ts']).toContain("from '@arkenv/core'")
+ expect(demo).toContain("host: 'localhost:5432'")
+ expect(demo).toContain("protocol: 'postgresql:'")
+ expect(demo).not.toContain('new URL')
+ expect(demo).not.toContain('url.host')
+ })
+
+ it.each(['zod', 'valibot'])(
+ 'uses the standard Vite plugin for %s',
+ async (validator) => {
+ const output = await generateArkEnvApp(validator)
+
+ expect(output.files['vite.config.ts']).toContain(
+ "import arkenv from '@arkenv/vite-plugin/standard'",
+ )
+ expect(output.files['src/env.ts']).toContain("from '@arkenv/standard'")
+ },
+ )
+
+ it('rejects out-of-range ports in the Valibot schema', async () => {
+ const output = await generateArkEnvApp('valibot')
+ const env = output.files['src/env.ts']
+
+ expect(env).toContain('v.minValue(1)')
+ expect(env).toContain('v.maxValue(65535)')
+ })
+
+ it('renders the validator-specific Vite import on the edge and worker paths', async () => {
+ const edgeFramework = getEdgeFrameworkById('react')
+ expect(edgeFramework).toBeDefined()
+ const edgeAddOns = await finalizeEdgeAddOns(edgeFramework!, 'file-router', [
+ 'arkenv',
+ ])
+ const { environment: edgeEnvironment, output: edgeOutput } =
+ createEdgeMemoryEnvironment('/arkenv-edge')
+
+ await createEdgeApp(edgeEnvironment, {
+ projectName: 'arkenv-edge',
+ targetDir: '/arkenv-edge',
+ framework: edgeFramework!,
+ mode: 'file-router',
+ typescript: true,
+ tailwind: true,
+ packageManager: 'pnpm',
+ git: false,
+ install: false,
+ intent: false,
+ chosenAddOns: edgeAddOns,
+ addOnOptions: {
+ ...populateEdgeAddOnOptionsDefaults(edgeAddOns),
+ arkenv: { validator: 'zod' },
+ },
+ includeExamples: false,
+ } satisfies Options)
+
+ expect(edgeOutput.files['vite.config.ts']).toContain(
+ "import arkenv from '@arkenv/vite-plugin/standard'",
+ )
+
+ const workerCreate = createWorkerCreate(createBundledWorkerManifestLoader())
+ const workerFramework = await workerCreate.getFrameworkById('react')
+ const workerAddOns = await workerCreate.finalizeAddOns(
+ workerFramework!,
+ 'file-router',
+ ['arkenv'],
+ )
+ const { environment: workerEnvironment, output: workerOutput } =
+ createWorkerMemoryEnvironment('/arkenv-worker')
+
+ await workerCreate.createApp(workerEnvironment, {
+ projectName: 'arkenv-worker',
+ targetDir: '/arkenv-worker',
+ framework: workerFramework!,
+ mode: 'file-router',
+ typescript: true,
+ tailwind: true,
+ packageManager: 'pnpm',
+ git: false,
+ install: false,
+ intent: false,
+ chosenAddOns: workerAddOns,
+ addOnOptions: {
+ ...workerCreate.populateAddOnOptionsDefaults(workerAddOns),
+ arkenv: { validator: 'valibot' },
+ },
+ includeExamples: false,
+ } satisfies Options)
+
+ expect(workerOutput.files['vite.config.ts']).toContain(
+ "import arkenv from '@arkenv/vite-plugin/standard'",
+ )
+ })
+})