| name | execution-model |
| description | Isomorphic-by-default principle, environment boundary functions (createServerFn, createServerOnlyFn, createClientOnlyFn, createIsomorphicFn), ClientOnly component, useHydrated hook, import protection, dead code elimination, environment variable safety (VITE_ prefix, process.env). |
| type | sub-skill |
| library | tanstack-start |
| library_version | 1.166.2 |
| requires | ["start-core"] |
| sources | ["TanStack/router:docs/start/framework/react/guide/execution-model.md","TanStack/router:docs/start/framework/react/guide/environment-variables.md"] |
Execution Model
Understanding where code runs is fundamental to TanStack Start. This skill covers the isomorphic execution model and how to control environment boundaries.
CRITICAL: ALL code in TanStack Start is isomorphic by default — it runs in BOTH server and client bundles. Route loaders run on BOTH server (during SSR) AND client (during navigation). Server-only operations MUST use createServerFn.
CRITICAL: Module-level process.env access runs in both environments. Secret values leak into the client bundle. Access secrets ONLY inside createServerFn or createServerOnlyFn.
CRITICAL: VITE_ prefixed environment variables are exposed to the client bundle. Server secrets must NOT have the VITE_ prefix.
Execution Control APIs
| API | Use Case | Client Behavior | Server Behavior |
|---|
createServerFn() | RPC calls, data mutations | Network request to server | Direct execution |
createServerOnlyFn(fn) | Utility functions | Throws error | Direct execution |
createClientOnlyFn(fn) | Browser utilities | Direct execution | Throws error |
createIsomorphicFn() | Different impl per env | Uses .client() impl | Uses .server() impl |
<ClientOnly> | Browser-only components | Renders children | Renders fallback |
useHydrated() | Hydration-dependent logic | true after hydration | false |
Server-Only Execution
createServerFn (RPC pattern)
The primary way to run server-only code. On the client, calls become fetch requests:
import { createServerFn } from '@tanstack/react-start'
const fetchUser = createServerFn().handler(async () => {
const secret = process.env.API_SECRET
return await db.users.find()
})
const user = await fetchUser()
createServerOnlyFn (throws on client)
For utility functions that must never run on client:
import { createServerOnlyFn } from '@tanstack/react-start'
const getSecret = createServerOnlyFn(() => process.env.DATABASE_URL)
Client-Only Execution
createClientOnlyFn
import { createClientOnlyFn } from '@tanstack/react-start'
const saveToStorage = createClientOnlyFn((key: string, value: string) => {
localStorage.setItem(key, value)
})
ClientOnly Component
import { ClientOnly } from '@tanstack/react-router'
function Analytics() {
return (
<ClientOnly fallback={null}>
<GoogleAnalyticsScript />
</ClientOnly>
)
}
useHydrated Hook
import { useHydrated } from '@tanstack/react-router'
function TimeZoneDisplay() {
const hydrated = useHydrated()
const timeZone = hydrated
? Intl.DateTimeFormat().resolvedOptions().timeZone
: 'UTC'
return <div>Your timezone: {timeZone}</div>
}
Behavior: SSR → false, first client render → false, after hydration → true (stays true).
Environment-Specific Implementations
import { createIsomorphicFn } from '@tanstack/react-start'
const getDeviceInfo = createIsomorphicFn()
.server(() => ({ type: 'server', platform: process.platform }))
.client(() => ({ type: 'client', userAgent: navigator.userAgent }))
Environment Variables
Server-Side (inside createServerFn)
Access any variable via process.env:
const connectDb = createServerFn().handler(async () => {
const url = process.env.DATABASE_URL
return createConnection(url)
})
Client-Side (components)
Only VITE_ prefixed variables are available:
function ApiProvider({ children }: { children: React.ReactNode }) {
const apiUrl = import.meta.env.VITE_API_URL
return (
<ApiContext.Provider value={{ apiUrl }}>{children}</ApiContext.Provider>
)
}
Runtime Client Variables
If you need server-side variables on the client without VITE_ prefix, pass them through a server function:
const getRuntimeVar = createServerFn({ method: 'GET' }).handler(() => {
return process.env.MY_RUNTIME_VAR
})
export const Route = createFileRoute('/')({
loader: async () => {
const foo = await getRuntimeVar()
return { foo }
},
component: () => {
const { foo } = Route.useLoaderData()
return <div>{foo}</div>
},
})
Type Safety for Environment Variables
interface ImportMetaEnv {
readonly VITE_APP_NAME: string
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare global {
namespace NodeJS {
interface ProcessEnv {
readonly DATABASE_URL: string
readonly JWT_SECRET: string
}
}
}
export {}
Common Mistakes
1. CRITICAL: Assuming loaders are server-only
export const Route = createFileRoute('/dashboard')({
loader: async () => {
const secret = process.env.API_SECRET
return fetch(`https://api.example.com/data`, {
headers: { Authorization: secret },
})
},
})
const getData = createServerFn({ method: 'GET' }).handler(async () => {
const secret = process.env.API_SECRET
return fetch(`https://api.example.com/data`, {
headers: { Authorization: secret },
})
})
export const Route = createFileRoute('/dashboard')({
loader: () => getData(),
})
2. CRITICAL: Exposing secrets via module-level process.env
const apiKey = process.env.SECRET_KEY
export function fetchData() {
}
const fetchData = createServerFn({ method: 'GET' }).handler(async () => {
const apiKey = process.env.SECRET_KEY
return fetch(url, { headers: { Authorization: apiKey } })
})
3. CRITICAL: Using VITE_ prefix for server secrets
VITE_SECRET_API_KEY=sk_live_xxx
SECRET_API_KEY=sk_live_xxx
VITE_APP_NAME=My App