@tanstack/start-client-core 1.168.0 → 1.168.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/start-client-core",
3
- "version": "1.168.0",
3
+ "version": "1.168.2",
4
4
  "description": "Modern and scalable routing for React applications",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -69,19 +69,15 @@
69
69
  "node": ">=22.12.0"
70
70
  },
71
71
  "dependencies": {
72
- "seroval": "^1.5.0",
73
- "@tanstack/router-core": "1.169.0",
72
+ "seroval": "^1.5.4",
73
+ "@tanstack/router-core": "1.169.2",
74
74
  "@tanstack/start-fn-stubs": "1.161.6",
75
- "@tanstack/start-storage-context": "1.166.33"
75
+ "@tanstack/start-storage-context": "1.166.35"
76
76
  },
77
77
  "devDependencies": {
78
- "@tanstack/intent": "^0.0.14",
79
78
  "vite": "*",
80
79
  "@types/node": ">=20"
81
80
  },
82
- "bin": {
83
- "intent": "./bin/intent.js"
84
- },
85
81
  "scripts": {
86
82
  "clean": "rimraf ./dist && rimraf ./coverage",
87
83
  "test": "pnpm test:eslint && pnpm test:types && pnpm test:build && pnpm test:unit",
@@ -24,13 +24,14 @@ TanStack Start is a full-stack React framework built on TanStack Router and Vite
24
24
 
25
25
  ## Sub-Skills
26
26
 
27
- | Task | Sub-Skill |
28
- | -------------------------------------------- | ------------------------------------------------------------------- |
29
- | Type-safe RPCs, data fetching, mutations | [start-core/server-functions/SKILL.md](./server-functions/SKILL.md) |
30
- | Request/function middleware, context, auth | [start-core/middleware/SKILL.md](./middleware/SKILL.md) |
31
- | Isomorphic execution, environment boundaries | [start-core/execution-model/SKILL.md](./execution-model/SKILL.md) |
32
- | REST API endpoints alongside app routes | [start-core/server-routes/SKILL.md](./server-routes/SKILL.md) |
33
- | Hosting, SSR modes, prerendering, SEO | [start-core/deployment/SKILL.md](./deployment/SKILL.md) |
27
+ | Task | Sub-Skill |
28
+ | ------------------------------------------------ | ------------------------------------------------------------------------------- |
29
+ | Type-safe RPCs, data fetching, mutations | [start-core/server-functions/SKILL.md](./server-functions/SKILL.md) |
30
+ | Request/function middleware, context, auth | [start-core/middleware/SKILL.md](./middleware/SKILL.md) |
31
+ | Server-side auth: sessions, cookies, OAuth, CSRF | [start-core/auth-server-primitives/SKILL.md](./auth-server-primitives/SKILL.md) |
32
+ | Isomorphic execution, environment boundaries | [start-core/execution-model/SKILL.md](./execution-model/SKILL.md) |
33
+ | REST API endpoints alongside app routes | [start-core/server-routes/SKILL.md](./server-routes/SKILL.md) |
34
+ | Hosting, SSR modes, prerendering, SEO | [start-core/deployment/SKILL.md](./deployment/SKILL.md) |
34
35
 
35
36
  ## Quick Decision Tree
36
37
 
@@ -41,6 +42,9 @@ Need to run code exclusively on the server (DB, secrets)?
41
42
  Need auth checks, logging, or shared logic across server functions?
42
43
  → start-core/middleware
43
44
 
45
+ Need to add login, sessions, OAuth, CSRF, password reset?
46
+ → start-core/auth-server-primitives
47
+
44
48
  Need to understand where code runs (server vs client)?
45
49
  → start-core/execution-model
46
50
 
@@ -0,0 +1,410 @@
1
+ ---
2
+ name: start-core/auth-server-primitives
3
+ description: >-
4
+ Server-side authentication primitives for TanStack Start: session
5
+ cookies (HttpOnly, Secure, SameSite, __Host- prefix), session
6
+ read/issue/destroy via createServerFn and middleware, OAuth
7
+ authorization-code flow with state and PKCE, password-reset
8
+ enumeration defense, CSRF for non-GET RPCs, rate limiting auth
9
+ endpoints, session rotation on privilege change. Pairs with
10
+ router-core/auth-and-guards for the routing side.
11
+ type: sub-skill
12
+ library: tanstack-start
13
+ library_version: '1.166.2'
14
+ requires:
15
+ - start-core
16
+ - start-core/server-functions
17
+ - start-core/middleware
18
+ sources:
19
+ - TanStack/router:docs/start/framework/react/guide/authentication-overview.md
20
+ - TanStack/router:docs/start/framework/react/guide/authentication-server-primitives.md
21
+ ---
22
+
23
+ # Auth Server Primitives
24
+
25
+ This skill covers the **server half** of authentication: session storage, cookie issuance, OAuth flow, password-reset hardening, CSRF, rate limiting. For the **routing half** (`_authenticated` layout, `beforeLoad` redirects, RBAC checks), see [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md).
26
+
27
+ > **CRITICAL**: A route guard does NOT protect a `createServerFn` on that route. Server functions are RPC endpoints reachable by direct POST regardless of which route renders them. Auth must be enforced **inside the handler** (or via middleware), not on the calling route.
28
+ > **CRITICAL**: Validating the _shape_ of a client-supplied identifier (`z.string().uuid().parse(...)`) is not authorization. A parsed UUID is still _some_ tenant — re-check membership against the session principal before using it.
29
+ > **CRITICAL**: Read session/cookies inside `.handler()` or middleware `.server()`, not at module scope. Module-level reads run before requests exist (and are also undefined on Cloudflare Workers).
30
+
31
+ ## Session Cookies
32
+
33
+ The recommended session storage is an HTTP-only cookie holding either an opaque session ID (with server-side lookup) or a signed/encrypted token. The cookie flags matter — set them all.
34
+
35
+ ```tsx
36
+ // src/server/session.ts
37
+ import {
38
+ getRequestHeader,
39
+ setResponseHeader,
40
+ } from '@tanstack/react-start/server'
41
+
42
+ const SESSION_COOKIE = '__Host-session' // __Host- prefix binds to the exact origin + path '/'
43
+ const ONE_DAY = 60 * 60 * 24
44
+
45
+ export function setSessionCookie(token: string) {
46
+ setResponseHeader(
47
+ 'Set-Cookie',
48
+ [
49
+ `${SESSION_COOKIE}=${token}`,
50
+ `HttpOnly`, // not readable from JS — defeats XSS exfiltration
51
+ `Secure`, // HTTPS only (required for __Host- prefix)
52
+ `SameSite=Lax`, // sent on top-level navigations, blocks most CSRF
53
+ `Path=/`, // required for __Host- prefix
54
+ `Max-Age=${ONE_DAY}`,
55
+ ].join('; '),
56
+ )
57
+ }
58
+
59
+ export function clearSessionCookie() {
60
+ setResponseHeader(
61
+ 'Set-Cookie',
62
+ `${SESSION_COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`,
63
+ )
64
+ }
65
+
66
+ export function readSessionToken(): string | null {
67
+ const header = getRequestHeader('cookie')
68
+ if (!header) return null
69
+ for (const part of header.split(/;\s*/)) {
70
+ // Split only on the FIRST '=' — signed/base64 values often contain '='.
71
+ const eq = part.indexOf('=')
72
+ if (eq === -1) continue
73
+ if (part.slice(0, eq) === SESSION_COOKIE) return part.slice(eq + 1)
74
+ }
75
+ return null
76
+ }
77
+ ```
78
+
79
+ Flag rationale:
80
+
81
+ - `HttpOnly` — JavaScript can't read the cookie, so an XSS bug can't steal the session.
82
+ - `Secure` — HTTPS only. Required when using `__Host-` prefix.
83
+ - `SameSite=Lax` — blocks CSRF on most cross-origin POST/PUT/DELETE. Use `Strict` for highest-security flows where loss of cross-site GET navigation is acceptable.
84
+ - `__Host-` prefix — binds the cookie to the exact origin (no Domain attribute, Path must be `/`, Secure must be set). Prevents subdomain takeover from forging a session cookie.
85
+ - `Path=/` — required by `__Host-`.
86
+ - `Max-Age` — finite lifetime so a stolen cookie isn't useful forever. Pair with server-side session rotation.
87
+
88
+ ## Session Lookup as Middleware
89
+
90
+ Use middleware to centralize session loading so every protected handler sees a typed session:
91
+
92
+ ```tsx
93
+ // src/server/auth-middleware.ts
94
+ import { createMiddleware } from '@tanstack/react-start'
95
+ import { readSessionToken } from './session'
96
+
97
+ export const authMiddleware = createMiddleware({ type: 'function' }).server(
98
+ async ({ next }) => {
99
+ const token = readSessionToken()
100
+ const session = token ? await db.sessions.findValid(token) : null
101
+ if (!session) throw new Error('Unauthorized')
102
+ return next({ context: { session } })
103
+ },
104
+ )
105
+ ```
106
+
107
+ Attach it to every server function that needs a logged-in user:
108
+
109
+ ```tsx
110
+ import { createServerFn } from '@tanstack/react-start'
111
+ import { authMiddleware } from '~/server/auth-middleware'
112
+
113
+ export const getMyOrders = createServerFn({ method: 'GET' })
114
+ .middleware([authMiddleware])
115
+ .handler(async ({ context }) => {
116
+ return db.orders.findMany({ where: { userId: context.session.userId } })
117
+ })
118
+ ```
119
+
120
+ > **Route guards do not cover this.** A `createFileRoute('/_authenticated/orders')` with a `beforeLoad` redirect does NOT protect `getMyOrders` — the RPC is reachable via direct POST whether or not the user ever hits the route. Apply `authMiddleware` (or re-check inside `.handler()`) on every server function that needs auth.
121
+
122
+ ## Issuing a Session on Login
123
+
124
+ ```tsx
125
+ // src/server/login.functions.ts
126
+ import { createServerFn } from '@tanstack/react-start'
127
+ import { z } from 'zod'
128
+ import { setSessionCookie } from './session'
129
+
130
+ export const login = createServerFn({ method: 'POST' })
131
+ .inputValidator(z.object({ email: z.string().email(), password: z.string() }))
132
+ .handler(async ({ data }) => {
133
+ const user = await db.users.findByEmail(data.email)
134
+ // Always run verifyPasswordHash — even when the user doesn't exist —
135
+ // so the user-not-found branch takes the same time as wrong-password.
136
+ // DUMMY_PASSWORD_HASH is a hash of any throwaway password computed once
137
+ // at startup with the same algorithm/cost as real password hashes.
138
+ const hashToCheck = user?.passwordHash ?? DUMMY_PASSWORD_HASH
139
+ const passwordMatches = await verifyPasswordHash(hashToCheck, data.password)
140
+ const ok = user != null && passwordMatches
141
+ if (!ok) throw new Error('Invalid email or password')
142
+
143
+ // ROTATE on privilege change: destroy any existing session, then issue fresh.
144
+ await db.sessions.revokeAllForUser(user.id)
145
+ const token = await db.sessions.create({ userId: user.id })
146
+ setSessionCookie(token)
147
+ return { ok: true }
148
+ })
149
+ ```
150
+
151
+ ## Logout
152
+
153
+ ```tsx
154
+ import { createServerFn } from '@tanstack/react-start'
155
+ import { authMiddleware } from '~/server/auth-middleware'
156
+ import { clearSessionCookie } from '~/server/session'
157
+
158
+ export const logout = createServerFn({ method: 'POST' })
159
+ .middleware([authMiddleware])
160
+ .handler(async ({ context }) => {
161
+ await db.sessions.revoke(context.session.id)
162
+ clearSessionCookie()
163
+ return { ok: true }
164
+ })
165
+ ```
166
+
167
+ ## OAuth: state + PKCE
168
+
169
+ For OAuth authorization-code flow, generate a one-time `state` (CSRF defense) and a PKCE verifier (defense against authorization-code interception). Store both in a short-lived signed cookie keyed to this exact login attempt.
170
+
171
+ ```tsx
172
+ // src/server/oauth.functions.ts
173
+ import { createServerFn } from '@tanstack/react-start'
174
+ import { redirect } from '@tanstack/react-router'
175
+ import {
176
+ getRequestHeader,
177
+ setResponseHeader,
178
+ } from '@tanstack/react-start/server'
179
+ import crypto from 'node:crypto'
180
+
181
+ const OAUTH_STATE_COOKIE = '__Host-oauth' // expires fast; one-shot
182
+
183
+ function base64url(buf: Buffer) {
184
+ return buf
185
+ .toString('base64')
186
+ .replace(/=/g, '')
187
+ .replace(/\+/g, '-')
188
+ .replace(/\//g, '_')
189
+ }
190
+
191
+ export const startOAuth = createServerFn({ method: 'GET' }).handler(
192
+ async () => {
193
+ const state = base64url(crypto.randomBytes(32))
194
+ const verifier = base64url(crypto.randomBytes(32))
195
+ const challenge = base64url(
196
+ crypto.createHash('sha256').update(verifier).digest(),
197
+ )
198
+
199
+ setResponseHeader(
200
+ 'Set-Cookie',
201
+ `${OAUTH_STATE_COOKIE}=${signed({ state, verifier })}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=600`,
202
+ )
203
+
204
+ throw redirect({
205
+ href:
206
+ `https://provider.example/authorize` +
207
+ `?response_type=code` +
208
+ `&client_id=${process.env.OAUTH_CLIENT_ID}` +
209
+ `&redirect_uri=${encodeURIComponent(process.env.OAUTH_REDIRECT_URI!)}` +
210
+ `&state=${state}` +
211
+ `&code_challenge=${challenge}` +
212
+ `&code_challenge_method=S256`,
213
+ })
214
+ },
215
+ )
216
+ ```
217
+
218
+ In the callback handler, **verify the cookie state matches the returned state** and exchange the code with the verifier. If state is missing or doesn't match, abort — the request did not originate from your `startOAuth`.
219
+
220
+ ## Password Reset: defeat user enumeration
221
+
222
+ When a user requests a reset, do not let the response shape or timing reveal whether the email is registered.
223
+
224
+ ```tsx
225
+ import { createServerFn } from '@tanstack/react-start'
226
+ import { z } from 'zod'
227
+
228
+ export const requestPasswordReset = createServerFn({ method: 'POST' })
229
+ .inputValidator(z.object({ email: z.string().email() }))
230
+ .handler(async ({ data }) => {
231
+ const user = await db.users.findByEmail(data.email)
232
+ if (user) {
233
+ const token = await db.passwordResets.issue(user.id)
234
+ await sendResetEmail(user.email, token)
235
+ }
236
+ // Always 200, always the same body, regardless of whether the user exists.
237
+ // The user is told to check their inbox; no confirmation either way.
238
+ return { ok: true }
239
+ })
240
+ ```
241
+
242
+ Do NOT:
243
+
244
+ - Return 200 if exists, 404 if not.
245
+ - Use a different message ("we sent you a link" vs "no account found").
246
+ - Skip the work when the user doesn't exist (timing leak — measurable from the wire).
247
+
248
+ ## CSRF for non-GET RPCs
249
+
250
+ `SameSite=Lax` on the session cookie blocks most cross-site CSRF for POST/PUT/DELETE. Two cases need extra defense:
251
+
252
+ 1. **Top-level GET navigation that mutates** — never do this. Always use POST/PUT/DELETE for mutations.
253
+ 2. **POST from a page on a sibling subdomain** — `SameSite=Lax` does NOT block this; verify the `Origin` header matches your app's origin in middleware.
254
+
255
+ ```tsx
256
+ import { createMiddleware } from '@tanstack/react-start'
257
+ import { getRequest } from '@tanstack/react-start/server'
258
+
259
+ export const csrfMiddleware = createMiddleware().server(async ({ next }) => {
260
+ const request = getRequest()
261
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
262
+ const origin = request.headers.get('origin')
263
+ // Compare the FULL origin (scheme + host + port) — host alone lets
264
+ // http://example.com pass a check meant for https://example.com.
265
+ if (!origin || new URL(origin).origin !== process.env.APP_ORIGIN) {
266
+ throw new Error('Origin check failed')
267
+ }
268
+ }
269
+ return next()
270
+ })
271
+ ```
272
+
273
+ Attach this to global request middleware in `src/start.ts` so it covers every non-GET request, including server routes and SSR.
274
+
275
+ ## Rate Limiting Auth Endpoints
276
+
277
+ A login endpoint without rate limiting is a credential-stuffing target. Limit per-IP (and ideally per-account) with a sliding window.
278
+
279
+ ```tsx
280
+ import { createMiddleware } from '@tanstack/react-start'
281
+ import { getRequest } from '@tanstack/react-start/server'
282
+
283
+ function rateLimitMiddleware(opts: {
284
+ key: string
285
+ max: number
286
+ windowMs: number
287
+ }) {
288
+ return createMiddleware().server(async ({ next }) => {
289
+ const request = getRequest()
290
+ const ip =
291
+ request.headers.get('cf-connecting-ip') ??
292
+ request.headers.get('x-forwarded-for')?.split(',')[0] ??
293
+ 'unknown'
294
+ const bucketKey = `rl:${opts.key}:${ip}`
295
+ const allowed = await rateLimiter.consume(
296
+ bucketKey,
297
+ opts.max,
298
+ opts.windowMs,
299
+ )
300
+ if (!allowed) throw new Error('Too many requests')
301
+ return next()
302
+ })
303
+ }
304
+
305
+ // On the login server function:
306
+ export const login = createServerFn({ method: 'POST' }).middleware([
307
+ rateLimitMiddleware({ key: 'login', max: 5, windowMs: 60_000 }),
308
+ ])
309
+ // ...
310
+ ```
311
+
312
+ ## Session Rotation on Privilege Change
313
+
314
+ Whenever the user's privileges change — login, logout, role change, password change — **destroy the old session and issue a new one**. This neutralizes session-fixation attacks where an attacker plants their own session ID in the victim's browser before login.
315
+
316
+ ```tsx
317
+ // In the login handler (already shown above): destroy any pre-login session, then create a fresh one.
318
+ await db.sessions.revokeAllForUser(user.id)
319
+ const token = await db.sessions.create({ userId: user.id })
320
+ setSessionCookie(token)
321
+ ```
322
+
323
+ ```tsx
324
+ // On password change / role grant:
325
+ await db.sessions.revokeAllForUser(user.id) // destroy existing
326
+ const token = await db.sessions.create({ userId: user.id }) // issue fresh
327
+ setSessionCookie(token)
328
+ ```
329
+
330
+ ## Common Mistakes
331
+
332
+ ### CRITICAL: Trusting the route guard for server-function auth
333
+
334
+ ```tsx
335
+ // WRONG — the RPC is callable directly via POST regardless of the route
336
+ export const Route = createFileRoute('/_authenticated/orders')({
337
+ beforeLoad: ({ context }) => {
338
+ if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
339
+ },
340
+ })
341
+ const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
342
+ return db.orders.findMany() // ← anyone can hit the RPC and get all orders
343
+ })
344
+
345
+ // CORRECT — auth enforced on the handler itself
346
+ const getMyOrders = createServerFn({ method: 'GET' })
347
+ .middleware([authMiddleware])
348
+ .handler(async ({ context }) => {
349
+ return db.orders.findMany({ where: { userId: context.session.userId } })
350
+ })
351
+ ```
352
+
353
+ ### CRITICAL: Treating shape validation as authorization
354
+
355
+ A parsed UUID is _some_ workspace, not an _authorized_ workspace.
356
+
357
+ ```tsx
358
+ // WRONG — UUID is well-formed but the user may not be a member
359
+ const getWorkspaceData = createServerFn({ method: 'GET' })
360
+ .middleware([authMiddleware])
361
+ .inputValidator(z.object({ workspaceId: z.string().uuid() }))
362
+ .handler(async ({ context, data }) => {
363
+ return db.workspaces.findById(data.workspaceId) // missing membership check!
364
+ })
365
+
366
+ // CORRECT — verify the session principal has access to that workspace
367
+ const getWorkspaceData = createServerFn({ method: 'GET' })
368
+ .middleware([authMiddleware])
369
+ .inputValidator(z.object({ workspaceId: z.string().uuid() }))
370
+ .handler(async ({ context, data }) => {
371
+ const member = await db.memberships.find({
372
+ userId: context.session.userId,
373
+ workspaceId: data.workspaceId,
374
+ })
375
+ if (!member) throw new Error('Not a member of this workspace')
376
+ return db.workspaces.findById(data.workspaceId)
377
+ })
378
+ ```
379
+
380
+ ### HIGH: Returning different responses based on email existence
381
+
382
+ Already covered above — `requestPasswordReset` must return the same body regardless of whether the email matches a user.
383
+
384
+ ### HIGH: Reading cookies/env at module scope
385
+
386
+ ```tsx
387
+ // WRONG — module-load time, before any request exists
388
+ const SESSION_SECRET = process.env.SESSION_SECRET
389
+ export function signSession(payload) {
390
+ return sign(payload, SESSION_SECRET)
391
+ }
392
+
393
+ // CORRECT — read inside per-request callback
394
+ export function signSession(payload) {
395
+ return sign(payload, process.env.SESSION_SECRET)
396
+ }
397
+ ```
398
+
399
+ On Cloudflare Workers and other edge runtimes, the module-level read evaluates to `undefined` even on the server because env is injected per-request. See [start-core/execution-model](../execution-model/SKILL.md).
400
+
401
+ ### MEDIUM: Long-lived sessions with no rotation
402
+
403
+ A session token that never rotates is functionally a long-lived credential. Rotate on login, logout, password change, and role/permission change.
404
+
405
+ ## Cross-References
406
+
407
+ - [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md) — the routing side: `_authenticated` layout, `beforeLoad`, `redirect`, RBAC checks.
408
+ - [start-core/server-functions](../server-functions/SKILL.md) — how to expose RPCs (and how the route guard does NOT cover them).
409
+ - [start-core/middleware](../middleware/SKILL.md) — composing `authMiddleware` and others.
410
+ - [start-core/execution-model](../execution-model/SKILL.md) — why module-level env/secret reads are wrong.
@@ -57,6 +57,15 @@ export default defineConfig({
57
57
 
58
58
  Deploy: `npx wrangler login && pnpm run deploy`
59
59
 
60
+ > **Worker env is per-request.** Cloudflare Workers inject env vars at request time. `process.env.X` at module scope evaluates to `undefined` even on the server. The Cloudflare-canonical way to read env (including from module scope) is the `cloudflare:workers` env binding:
61
+ >
62
+ > ```ts
63
+ > import { env } from 'cloudflare:workers'
64
+ > const apiHost = env.API_HOST
65
+ > ```
66
+ >
67
+ > Or read `process.env.X` per-request inside `.handler()` / middleware `.server()`. See [Cloudflare's environment-variables docs](https://developers.cloudflare.com/workers/configuration/environment-variables/) and [start-core/execution-model](../execution-model/SKILL.md).
68
+
60
69
  ### Netlify
61
70
 
62
71
  ```bash
@@ -14,6 +14,7 @@ requires:
14
14
  sources:
15
15
  - TanStack/router:docs/start/framework/react/guide/execution-model.md
16
16
  - TanStack/router:docs/start/framework/react/guide/environment-variables.md
17
+ - TanStack/router:docs/start/framework/react/guide/import-protection.md
17
18
  ---
18
19
 
19
20
  # Execution Model
@@ -21,19 +22,21 @@ sources:
21
22
  Understanding where code runs is fundamental to TanStack Start. This skill covers the isomorphic execution model and how to control environment boundaries.
22
23
 
23
24
  > **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`.
24
- > **CRITICAL**: Module-level `process.env` access runs in both environments. Secret values leak into the client bundle. Access secrets ONLY inside `createServerFn` or `createServerOnlyFn`.
25
+ > **CRITICAL**: Module-level `process.env` access is wrong on **two** axes — security (values leak into the client bundle) AND runtime correctness (on Cloudflare Workers and other edge runtimes, env is injected per-request, so module-level reads evaluate to `undefined` even on the server). Read env inside `.handler()` or another per-request function, never at module scope.
25
26
  > **CRITICAL**: `VITE_` prefixed environment variables are exposed to the client bundle. Server secrets must NOT have the `VITE_` prefix.
26
27
 
27
28
  ## Execution Control APIs
28
29
 
29
- | API | Use Case | Client Behavior | Server Behavior |
30
- | ------------------------ | ------------------------- | ------------------------- | --------------------- |
31
- | `createServerFn()` | RPC calls, data mutations | Network request to server | Direct execution |
32
- | `createServerOnlyFn(fn)` | Utility functions | Throws error | Direct execution |
33
- | `createClientOnlyFn(fn)` | Browser utilities | Direct execution | Throws error |
34
- | `createIsomorphicFn()` | Different impl per env | Uses `.client()` impl | Uses `.server()` impl |
35
- | `<ClientOnly>` | Browser-only components | Renders children | Renders fallback |
36
- | `useHydrated()` | Hydration-dependent logic | `true` after hydration | `false` |
30
+ | API | Use Case | Client Behavior | Server Behavior |
31
+ | ------------------------------------------- | --------------------------- | ------------------------- | --------------------- |
32
+ | `createServerFn()` | RPC calls, data mutations | Network request to server | Direct execution |
33
+ | `createServerOnlyFn(fn)` | Utility functions | Throws error | Direct execution |
34
+ | `createClientOnlyFn(fn)` | Browser utilities | Direct execution | Throws error |
35
+ | `createIsomorphicFn()` | Different impl per env | Uses `.client()` impl | Uses `.server()` impl |
36
+ | `<ClientOnly>` | Browser-only components | Renders children | Renders fallback |
37
+ | `useHydrated()` | Hydration-dependent logic | `true` after hydration | `false` |
38
+ | `import '@tanstack/<fw>-start/server-only'` | Mark whole file server-only | Import denied | Allowed |
39
+ | `import '@tanstack/<fw>-start/client-only'` | Mark whole file client-only | Allowed | Import denied |
37
40
 
38
41
  ## Server-Only Execution
39
42
 
@@ -125,6 +128,45 @@ const getDeviceInfo = createIsomorphicFn()
125
128
  .client(() => ({ type: 'client', userAgent: navigator.userAgent }))
126
129
  ```
127
130
 
131
+ ## Import Protection: File Markers
132
+
133
+ > Experimental.
134
+
135
+ The `.server.*` and `.client.*` filename suffixes (e.g. `db.server.ts`) opt a file into Start's import protection — it can't be imported from the wrong environment. When you can't or don't want to rename the file, add a side-effect import at the top of the file to apply the same protection by marker:
136
+
137
+ ```ts
138
+ // src/lib/secrets.ts (filename can't be *.server.ts)
139
+ import '@tanstack/react-start/server-only'
140
+ // (or @tanstack/solid-start/server-only, @tanstack/vue-start/server-only)
141
+
142
+ export function getApiKey() {
143
+ return process.env.API_KEY
144
+ }
145
+ ```
146
+
147
+ ```ts
148
+ // src/lib/storage.ts
149
+ import '@tanstack/react-start/client-only'
150
+ // (or @tanstack/solid-start/client-only, @tanstack/vue-start/client-only)
151
+
152
+ export function savePreferences(prefs: Record<string, string>) {
153
+ localStorage.setItem('prefs', JSON.stringify(prefs))
154
+ }
155
+ ```
156
+
157
+ Rules:
158
+
159
+ - Both markers in the same file is an error.
160
+ - Type-only imports are ignored (they erase to nothing at runtime).
161
+ - Default behavior is `error` in production builds and `mock` in dev. The mock returns a recursive Proxy so dev keeps running while you fix the import graph.
162
+
163
+ Pick the right tool:
164
+
165
+ - File should never run on the wrong side **and** has no client API → `*.server.ts` filename or `import '@tanstack/<fw>-start/server-only'`.
166
+ - One symbol needs to behave differently per environment → `createIsomorphicFn().client(...).server(...)`.
167
+ - One function should error if called from the wrong side → `createServerOnlyFn` / `createClientOnlyFn`.
168
+ - Component renders only after hydration → `<ClientOnly>` or `useHydrated()`.
169
+
128
170
  ## Environment Variables
129
171
 
130
172
  ### Server-Side (inside createServerFn)
@@ -229,22 +271,29 @@ export const Route = createFileRoute('/dashboard')({
229
271
  })
230
272
  ```
231
273
 
232
- ### 2. CRITICAL: Exposing secrets via module-level process.env
274
+ ### 2. CRITICAL: Reading process.env at module scope
275
+
276
+ Module-level `process.env` reads are wrong for **two** reasons, not one:
277
+
278
+ 1. **Security:** the value can be inlined into the client bundle, leaking secrets.
279
+ 2. **Runtime correctness (edge runtimes):** Cloudflare Workers and other edge SSR runtimes inject env at request time. Module-level code runs at module load, before the env exists, so the read evaluates to `undefined` even on the server. The bug only surfaces at deploy time.
233
280
 
234
281
  ```tsx
235
- // WRONG — runs in both environments, value in client bundle
282
+ // WRONG — leaks to client AND is undefined on Workers
236
283
  const apiKey = process.env.SECRET_KEY
237
284
  export function fetchData() {
238
- /* uses apiKey */
285
+ /* uses apiKey, which is undefined under Worker SSR */
239
286
  }
240
287
 
241
- // CORRECT — access inside server function only
288
+ // CORRECT — read per-request, inside the handler
242
289
  const fetchData = createServerFn({ method: 'GET' }).handler(async () => {
243
290
  const apiKey = process.env.SECRET_KEY
244
291
  return fetch(url, { headers: { Authorization: apiKey } })
245
292
  })
246
293
  ```
247
294
 
295
+ The same rule applies to middleware `.server()` callbacks, server-route handlers, and any function that runs per request — read env there, not at the top of the file.
296
+
248
297
  ### 3. CRITICAL: Using VITE\_ prefix for server secrets
249
298
 
250
299
  ```bash
@@ -21,7 +21,7 @@ sources:
21
21
  Middleware customizes the behavior of server functions and server routes. It is composable — middleware can depend on other middleware to form a chain.
22
22
 
23
23
  > **CRITICAL**: TypeScript enforces method order: `middleware()` → `inputValidator()` → `client()` → `server()`. Wrong order causes type errors.
24
- > **CRITICAL**: Client context sent via `sendContext` is NOT validated by default. If you send dynamic user-generated data, validate it in server-side middleware before use.
24
+ > **CRITICAL**: Validating the _shape_ of `sendContext` (e.g. `z.string().uuid().parse(...)`) is NOT authorization. A parsed identifier is a well-formed identifier, not an authorized one. Always re-check access against the session principal before using a client-sent ID as a query key, filter, or path parameter.
25
25
 
26
26
  ## Two Types of Middleware
27
27
 
@@ -241,7 +241,11 @@ const authMiddleware = createMiddleware().server(async ({ next, request }) => {
241
241
  if (!session) throw new Error('Unauthorized')
242
242
  return next({ context: { session } })
243
243
  })
244
+ ```
245
+
246
+ > **Attach `authMiddleware` to every `createServerFn` that needs auth.** Server functions are RPC endpoints — a route `beforeLoad` does NOT protect the RPC, only the route's UI. Pair every protected route with handler-level enforcement here. See [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md) and [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md).
244
247
 
248
+ ```tsx
245
249
  type Permissions = Record<string, string[]>
246
250
 
247
251
  function authorizationMiddleware(permissions: Permissions) {
@@ -299,23 +303,50 @@ Fetch precedence (highest to lowest): call site → later middleware → earlier
299
303
 
300
304
  ## Common Mistakes
301
305
 
302
- ### 1. HIGH: Trusting client sendContext without validation
306
+ ### 1. CRITICAL: Trusting client sendContext shape check is not access check
307
+
308
+ `sendContext` from a client middleware arrives on the server as untrusted client input. Most agents stop after parsing the shape with Zod and assume the value is safe. It isn't: a parsed UUID is _some_ workspace, not the requesting user's workspace. Without a membership check against the session principal, you've built a tenant-walking endpoint.
309
+
310
+ **Layer 1 — WRONG (no validation):**
303
311
 
304
312
  ```tsx
305
- // WRONG — client can send arbitrary data
306
313
  .server(async ({ next, context }) => {
314
+ // SQL-injectable AND tenant-walkable
307
315
  await db.query(`SELECT * FROM workspace_${context.workspaceId}`)
308
316
  return next()
309
317
  })
318
+ ```
319
+
320
+ **Layer 2 — STILL WRONG (shape only):**
310
321
 
311
- // CORRECT — validate before use
322
+ ```tsx
312
323
  .server(async ({ next, context }) => {
324
+ // Looks safe, isn't. UUID is well-formed but the user may not be a member.
313
325
  const workspaceId = z.string().uuid().parse(context.workspaceId)
314
326
  await db.query('SELECT * FROM workspaces WHERE id = $1', [workspaceId])
315
327
  return next()
316
328
  })
317
329
  ```
318
330
 
331
+ **Layer 3 — CORRECT (shape AND access):**
332
+
333
+ ```tsx
334
+ .middleware([authMiddleware]) // session loaded from cookie, NOT from sendContext
335
+ .server(async ({ next, context }) => {
336
+ const workspaceId = z.string().uuid().parse(context.workspaceId)
337
+ // Verify the session principal can access this workspace.
338
+ const member = await db.memberships.find({
339
+ userId: context.session.userId,
340
+ workspaceId,
341
+ })
342
+ if (!member) throw new Error('Not a member of this workspace')
343
+ await db.query('SELECT * FROM workspaces WHERE id = $1', [workspaceId])
344
+ return next({ context: { workspaceId } })
345
+ })
346
+ ```
347
+
348
+ The session itself must come from a server-trusted source (the cookie + DB lookup in `authMiddleware`), never from `sendContext` — anything the client can send, the client can lie about. See [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md).
349
+
319
350
  ### 2. MEDIUM: Confusing request vs server function middleware
320
351
 
321
352
  Request middleware runs on ALL requests (SSR, routes, functions). Server function middleware runs only for `createServerFn` calls and has `.client()` method.
@@ -363,3 +394,5 @@ createMiddleware({ type: 'function' })
363
394
 
364
395
  - [start-core/server-functions](../server-functions/SKILL.md) — what middleware wraps
365
396
  - [start-core/server-routes](../server-routes/SKILL.md) — middleware on API endpoints
397
+ - [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md) — building the `authMiddleware` factory itself: session cookie reads, OAuth state, CSRF
398
+ - [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md) — routing-side guards (route `beforeLoad` does NOT protect server functions; pair guards with `authMiddleware` on every protected RPC)
@@ -19,6 +19,7 @@ sources:
19
19
 
20
20
  Server functions are type-safe RPCs created with `createServerFn`. They run exclusively on the server but can be called from anywhere — loaders, components, hooks, event handlers, or other server functions.
21
21
 
22
+ > **CRITICAL**: Server functions are RPC endpoints. They are reachable by direct POST regardless of which route renders the calling UI. **Auth must be enforced inside the handler (or via middleware) — a route `beforeLoad` does NOT protect the RPC.** See [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md) for the session/middleware pattern.
22
23
  > **CRITICAL**: Loaders are ISOMORPHIC — they run on BOTH client and server. Database queries, file system access, and secret API keys MUST go inside `createServerFn`, NOT in loaders directly.
23
24
  > **CRITICAL**: Do not use `"use server"` directives, `getServerSideProps`, or any Next.js/Remix server patterns. TanStack Start uses `createServerFn` exclusively.
24
25
 
@@ -199,16 +200,29 @@ import {
199
200
  setResponseStatus,
200
201
  } from '@tanstack/react-start/server'
201
202
 
202
- const getCachedData = createServerFn({ method: 'GET' }).handler(async () => {
203
- const request = getRequest()
204
- const authHeader = getRequestHeader('Authorization')
205
-
203
+ // Public, non-personalized data safe to cache shared across users.
204
+ const getPublicData = createServerFn({ method: 'GET' }).handler(async () => {
206
205
  setResponseHeaders({
206
+ // 'public' is correct ONLY when the response does not depend on identity.
207
+ // For anything tied to a session/user/tenant, use 'private' or 'no-store'.
207
208
  'Cache-Control': 'public, max-age=300',
208
209
  })
209
210
  setResponseStatus(200)
211
+ return fetchPublicData()
212
+ })
210
213
 
211
- return fetchData()
214
+ // Authenticated data — must NOT be 'public'.
215
+ const getMyData = createServerFn({ method: 'GET' }).handler(async () => {
216
+ const authHeader = getRequestHeader('Authorization')
217
+ // ... auth check ...
218
+
219
+ setResponseHeaders({
220
+ // 'private' = only the user-agent may cache. Vary by Cookie/Authorization
221
+ // so any intermediary that does cache keys by identity, not URL alone.
222
+ 'Cache-Control': 'private, max-age=60',
223
+ Vary: 'Cookie, Authorization',
224
+ })
225
+ return fetchPersonalizedData()
212
226
  })
213
227
  ```
214
228
 
@@ -254,7 +268,33 @@ Static imports of server functions are safe — the build replaces implementatio
254
268
 
255
269
  ## Common Mistakes
256
270
 
257
- ### 1. CRITICAL: Putting server-only code in loaders
271
+ ### 1. CRITICAL: Relying on a route guard to protect a server function
272
+
273
+ A `beforeLoad` redirect protects the **route's UI**, not the **RPC**. `createServerFn` exposes a callable endpoint that an attacker can hit directly — no need to load the route at all. Auth on the route is necessary but not sufficient.
274
+
275
+ ```tsx
276
+ // WRONG — the route guard doesn't reach the handler
277
+ const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
278
+ return db.orders.findMany() // ← anyone can call the RPC
279
+ })
280
+ export const Route = createFileRoute('/_authenticated/orders')({
281
+ beforeLoad: ({ context }) => {
282
+ if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
283
+ },
284
+ loader: () => getMyOrders(),
285
+ })
286
+
287
+ // CORRECT — auth enforced on the handler itself
288
+ const getMyOrders = createServerFn({ method: 'GET' })
289
+ .middleware([authMiddleware])
290
+ .handler(async ({ context }) => {
291
+ return db.orders.findMany({ where: { userId: context.session.userId } })
292
+ })
293
+ ```
294
+
295
+ Apply `authMiddleware` (or an equivalent in-handler check) to **every** `createServerFn` that needs auth. See [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md) for the full session/middleware pattern and [start-core/middleware](../middleware/SKILL.md) for composing the factory.
296
+
297
+ ### 2. CRITICAL: Putting server-only code in loaders
258
298
 
259
299
  ```tsx
260
300
  // WRONG — loader is ISOMORPHIC, runs on BOTH client and server
@@ -276,22 +316,47 @@ export const Route = createFileRoute('/posts')({
276
316
  })
277
317
  ```
278
318
 
279
- ### 2. CRITICAL: Using Next.js/Remix server patterns
319
+ ### 3. CRITICAL: Using Next.js / Remix / React Router DOM patterns
320
+
321
+ If the file lives at `src/pages/`, `app/layout.tsx`, `_app/`, or imports anything from `react-router-dom` or `next/`, it is wrong-framework code. TanStack Start uses `src/routes/` + `createFileRoute` + `createServerFn`.
280
322
 
281
323
  ```tsx
282
324
  // WRONG — "use server" is a React directive, not used in TanStack Start
283
325
  'use server'
284
326
  export async function getUser() { ... }
285
327
 
286
- // WRONG — getServerSideProps is Next.js
328
+ // WRONG — getServerSideProps is Next.js Pages Router
287
329
  export async function getServerSideProps() { ... }
288
330
 
289
- // CORRECTTanStack Start uses createServerFn
331
+ // WRONGNext.js App Router server component data fetching
332
+ export default async function Page() {
333
+ const data = await fetch(...).then(r => r.json())
334
+ return <div>{data}</div>
335
+ }
336
+
337
+ // WRONG — Remix
338
+ export async function loader({ request }) { ... }
339
+ export async function action({ request }) { ... }
340
+
341
+ // WRONG — react-router-dom (a different library)
342
+ import { Link, useNavigate } from 'react-router-dom'
343
+
344
+ // CORRECT — TanStack Start
345
+ import { createServerFn } from '@tanstack/react-start'
346
+ import { Link, useNavigate, createFileRoute } from '@tanstack/react-router'
347
+
290
348
  const getUser = createServerFn({ method: 'GET' })
291
349
  .handler(async () => { ... })
350
+
351
+ export const Route = createFileRoute('/users/$id')({
352
+ loader: ({ params }) => getUser({ data: { id: params.id } }),
353
+ component: UserPage,
354
+ })
292
355
  ```
293
356
 
294
- ### 3. HIGH: Dynamic imports for server functions
357
+ If you see `src/pages/`, `app/layout.tsx`, or `react-router-dom` in agent output, the agent is generating for the wrong framework. Build will fail or routes will conflict at runtime.
358
+
359
+ ### 4. HIGH: Dynamic imports for server functions
295
360
 
296
361
  ```tsx
297
362
  // WRONG — can cause bundler issues
@@ -301,7 +366,7 @@ const { getUser } = await import('~/utils/users.functions')
301
366
  import { getUser } from '~/utils/users.functions'
302
367
  ```
303
368
 
304
- ### 4. HIGH: Awaiting server function without calling it
369
+ ### 5. HIGH: Awaiting server function without calling it
305
370
 
306
371
  `createServerFn` returns a function — it must be invoked with `()`:
307
372
 
@@ -316,20 +381,53 @@ const data = await getItems()
316
381
  const data = await getItems({ data: { id: '1' } })
317
382
  ```
318
383
 
319
- ### 5. MEDIUM: Not using useServerFn for component calls
384
+ ### 6. CRITICAL: Caching authenticated responses with `Cache-Control: public`
385
+
386
+ `Cache-Control: public, max-age=N` tells every CDN, proxy, and shared cache between you and the user that this response can be served to anyone. If the response depends on the session (user, tenant, role), the first user's response gets cached and replayed to the next user — a cross-tenant data leak.
387
+
388
+ ```tsx
389
+ // WRONG — auth'd response, public cache, leaks to next user via CDN
390
+ const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
391
+ const session = await requireSession() // identity-dependent
392
+ setResponseHeaders({ 'Cache-Control': 'public, max-age=300' })
393
+ return db.orders.findMany({ where: { userId: session.userId } })
394
+ })
395
+
396
+ // CORRECT — private + Vary so any cache that does store it keys by identity
397
+ const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
398
+ const session = await requireSession()
399
+ setResponseHeaders({
400
+ 'Cache-Control': 'private, max-age=60',
401
+ Vary: 'Cookie, Authorization',
402
+ })
403
+ return db.orders.findMany({ where: { userId: session.userId } })
404
+ })
405
+
406
+ // ALSO CORRECT — opt out entirely for sensitive data
407
+ setResponseHeaders({ 'Cache-Control': 'no-store' })
408
+ ```
409
+
410
+ Rule of thumb: if the handler reads a session/cookie/auth header or branches on identity, the response is **not** `public`. Default to `private` (or `no-store` for sensitive data); reach for `public` only on responses that are byte-for-byte identical regardless of who asks. See also [start-core/deployment](../deployment/SKILL.md) for ISR/Cache-Control on full pages.
320
411
 
321
- When calling server functions from event handlers in components, use `useServerFn` to get proper React integration:
412
+ ### 7. MEDIUM: When to wrap with `useServerFn`
413
+
414
+ `useServerFn` is **required** when the server function uses `throw redirect()` or `throw notFound()` — the hook wires the throw into the router so the redirect actually navigates. For server functions that just return data (call them directly or via `useMutation`/`useQuery`), the hook is optional.
322
415
 
323
416
  ```tsx
324
- // WRONG — direct call doesn't integrate with React lifecycle
417
+ // Plain data — direct call is fine (also fine to pass to useMutation/useQuery)
325
418
  <button onClick={() => deletePost({ data: { id } })}>Delete</button>
419
+ useMutation({ mutationFn: deletePost })
326
420
 
327
- // CORRECTuseServerFn integrates with React
328
- const deletePostFn = useServerFn(deletePost)
329
- <button onClick={() => deletePostFn({ data: { id } })}>Delete</button>
421
+ // Throws redirect/notFound MUST wrap with useServerFn so the router handles the throw
422
+ const signupFn = useServerFn(signup) // signup throws redirect on success
423
+ <button onClick={() => signupFn({ data: form })}>Sign up</button>
330
424
  ```
331
425
 
426
+ If in doubt: wrap with `useServerFn`. It's a no-op for plain-data functions and the safe default when a function might later add a redirect.
427
+
332
428
  ## Cross-References
333
429
 
334
430
  - [start-core/execution-model](../execution-model/SKILL.md) — understanding where code runs
335
431
  - [start-core/middleware](../middleware/SKILL.md) — composing server functions with middleware
432
+ - [start-core/auth-server-primitives](../auth-server-primitives/SKILL.md) — sessions, cookies, OAuth, CSRF, rate limiting (the server-side half of auth; `getCurrentUser`/`useSession`-style helpers belong here, not at module scope)
433
+ - [router-core/auth-and-guards](../../../../router-core/skills/router-core/auth-and-guards/SKILL.md) — the routing side: route guards do NOT protect server functions, so always re-check auth in the handler or via middleware
@@ -1,11 +1,11 @@
1
1
  declare module '#tanstack-start-entry' {
2
- import type { StartEntry } from '@tanstack/start-client-core'
2
+ import type { StartEntry } from './startEntry'
3
3
 
4
4
  export const startInstance: StartEntry['startInstance']
5
5
  }
6
6
 
7
7
  declare module '#tanstack-router-entry' {
8
- import type { RouterEntry } from '@tanstack/start-client-core'
8
+ import type { RouterEntry } from './startEntry'
9
9
 
10
10
  export const getRouter: RouterEntry['getRouter']
11
11
  }
package/bin/intent.js DELETED
@@ -1,25 +0,0 @@
1
- #!/usr/bin/env node
2
- // Auto-generated by @tanstack/intent setup
3
- // Exposes the intent end-user CLI for consumers of this library.
4
- // Commit this file, then add to your package.json:
5
- // "bin": { "intent": "./bin/intent.js" }
6
- try {
7
- await import('@tanstack/intent/intent-library')
8
- } catch (e) {
9
- const isModuleNotFound =
10
- e?.code === 'ERR_MODULE_NOT_FOUND' || e?.code === 'MODULE_NOT_FOUND'
11
- const missingIntentLibrary =
12
- typeof e?.message === 'string' && e.message.includes('@tanstack/intent')
13
-
14
- if (isModuleNotFound && missingIntentLibrary) {
15
- console.error('@tanstack/intent is not installed.')
16
- console.error('')
17
- console.error('Install it as a dev dependency:')
18
- console.error(' npm add -D @tanstack/intent')
19
- console.error('')
20
- console.error('Or run directly:')
21
- console.error(' npx @tanstack/intent@latest list')
22
- process.exit(1)
23
- }
24
- throw e
25
- }