Skip to content
Open
10 changes: 10 additions & 0 deletions .changeset/arkenv-addon.md
Original file line number Diff line number Diff line change
@@ -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`.
19 changes: 2 additions & 17 deletions packages/create/src/add-to-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { basename, resolve } from 'node:path'

import { CONFIG_FILE } from './constants.js'
import { finalizeAddOns } from './add-ons.js'
import { assertNoExclusiveAddOnConflicts } from './build-tools.js'
import { getFrameworkById } from './frameworks.js'
import { createMemoryEnvironment } from './environment.js'
import { createApp } from './create-app.js'
Expand All @@ -25,7 +26,7 @@ import { runSpecialSteps } from './special-steps/index.js'
import { loadStarter } from './custom-add-ons/starter.js'
import { setupIntent } from './integrations/intent.js'

import type { AddOn, Environment, Options } from './types.js'
import type { Environment, Options } from './types.js'
import type { PersistedOptions } from './config-file.js'

const ENV_FILE_NAMES = new Set(['.env', '.env.local', '.env.example'])
Expand Down Expand Up @@ -128,22 +129,6 @@ async function createOptions(
} as Options
}

function assertNoExclusiveAddOnConflicts(addOns: Array<AddOn>) {
const selectedByExclusiveGroup = new Map<string, AddOn>()

for (const addOn of addOns) {
for (const exclusive of addOn.exclusive ?? []) {
const selected = selectedByExclusiveGroup.get(exclusive)
if (selected && selected.id !== addOn.id) {
throw new Error(
`Cannot combine ${selected.name} and ${addOn.name}. Add-ons in the "${exclusive}" group are mutually exclusive.`,
)
}
selectedByExclusiveGroup.set(exclusive, addOn)
}
}
}

async function runCreateApp(options: Required<Options>, packageManagerVersion?: string) {
const { environment, output } = createMemoryEnvironment(options.targetDir)
await createApp(environment, options, { existingProject: true, packageManagerVersion })
Expand Down
22 changes: 22 additions & 0 deletions packages/create/src/build-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@ export function assertAddOnSupportedByBundler(
}
}

export function assertNoExclusiveAddOnConflicts(
addOns: Array<AddOn>,
onlyGroup?: NonNullable<AddOn['exclusive']>[number],
) {
const selectedByExclusiveGroup = new Map<string, AddOn>()

for (const addOn of addOns) {
for (const exclusive of addOn.exclusive ?? []) {
if (onlyGroup && exclusive !== onlyGroup) continue
const selected = selectedByExclusiveGroup.get(exclusive)
if (selected && selected.id !== addOn.id) {
throw new Error(
`Cannot combine ${selected.name} and ${addOn.name}. Add-ons in the "${exclusive}" group are mutually exclusive.`,
)
}
selectedByExclusiveGroup.set(exclusive, addOn)
}
}
}

export function normalizeAndValidateBundlerOptions(options: Options): Options {
const bundler = resolveBundler(options.framework, options.bundler).id

Expand All @@ -67,6 +87,8 @@ export function normalizeAndValidateBundlerOptions(options: Options): Options {
assertAddOnSupportedByBundler(addOn, bundler)
}

assertNoExclusiveAddOnConflicts(options.chosenAddOns, 'env')

return {
...options,
bundler,
Expand Down
18 changes: 18 additions & 0 deletions packages/create/src/frameworks/react/add-ons/arkenv/README.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<% const validator = addOnOption.arkenv?.validator -%>
<% if (validator === 'zod' || validator === 'valibot') { -%>
export { default } from '@arkenv/vite-plugin/standard'
<% } else { -%>
export { default } from '@arkenv/vite-plugin'
<% } -%>
Original file line number Diff line number Diff line change
@@ -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/valibot'
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'",
})
<% } -%>
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<% if (!includeExamples || routerOnly || !fileRouter) { 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 <p>Server key leaked: {env.DATABASE_URL}</p>
}

function ArkEnvDemo() {
const dbConfig = Route.useLoaderData()
const [attemptLeak, setAttemptLeak] = useState(false)

return (
<div className="p-6 max-w-xl mx-auto space-y-4 font-sans">
<h1 className="text-2xl font-bold">ArkEnv Demo</h1>
<p className="text-sm text-gray-600">
Typesafe environment variables with build-time validation and runtime
leak protection.
</p>

<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-md space-y-2">
<h2 className="font-semibold text-lg">Public Client Variables</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
Inlined safely into client bundles:
</p>
<code className="block p-2 bg-white dark:bg-black rounded border text-xs font-mono">
env.VITE_API_URL: {env.VITE_API_URL}
</code>
</div>

<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-md space-y-2">
<h2 className="font-semibold text-lg">Server-Only Variables</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
Accessible inside createServerFn handlers. This page shows an example
endpoint, not the deployed database host:
</p>
<code className="block p-2 bg-white dark:bg-black rounded border text-xs font-mono">
Example endpoint: {dbConfig.host} ({dbConfig.protocol})
</code>
</div>

<div className="p-4 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded-md space-y-2">
<h2 className="font-semibold text-red-800 dark:text-red-300 text-lg">
Secret Leak Protection
</h2>
<p className="text-sm text-red-700 dark:text-red-400">
Clicking the button below attempts to access the server secret{' '}
<code>env.DATABASE_URL</code> on the client, which ArkEnv blocks:
</p>
{attemptLeak ? (
<LeakedSecret />
) : (
<button
type="button"
className="px-3 py-1.5 bg-red-600 text-white rounded text-sm hover:bg-red-700 cursor-pointer"
onClick={() => setAttemptLeak(true)}
>
Attempt client access to DATABASE_URL
</button>
)}
</div>
</div>
)
}
50 changes: 50 additions & 0 deletions packages/create/src/frameworks/react/add-ons/arkenv/info.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"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",
"path": "arkenv-vite-plugin.ts",
"jsName": "arkenvPlugin",
"code": "arkenvPlugin()"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<% 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/to-json-schema": "^1.0.0",
"valibot": "^1.0.0"
<% } else { -%>
"@arkenv/core": "^1.0.0-rc.2",
"arktype": "^2.2.0"
<% } -%>
},
"devDependencies": {
"@arkenv/vite-plugin": "^1.0.0-rc.5"
}
}
14 changes: 14 additions & 0 deletions packages/create/src/frameworks/react/add-ons/arkenv/small-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/create/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading