@tanstack/start-client-core 1.168.1 → 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 +4 -8
- package/skills/start-core/SKILL.md +11 -7
- package/skills/start-core/auth-server-primitives/SKILL.md +410 -0
- package/skills/start-core/deployment/SKILL.md +9 -0
- package/skills/start-core/execution-model/SKILL.md +62 -13
- package/skills/start-core/middleware/SKILL.md +37 -4
- package/skills/start-core/server-functions/SKILL.md +115 -17
- package/src/start-entry.d.ts +2 -2
- package/bin/intent.js +0 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/start-client-core",
|
|
3
|
-
"version": "1.168.
|
|
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.
|
|
73
|
-
"@tanstack/router-core": "1.169.
|
|
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.
|
|
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
|
|
28
|
-
|
|
|
29
|
-
| Type-safe RPCs, data fetching, mutations
|
|
30
|
-
| Request/function middleware, context, auth
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
|
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
|
|
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
|
|
30
|
-
|
|
|
31
|
-
| `createServerFn()`
|
|
32
|
-
| `createServerOnlyFn(fn)`
|
|
33
|
-
| `createClientOnlyFn(fn)`
|
|
34
|
-
| `createIsomorphicFn()`
|
|
35
|
-
| `<ClientOnly>`
|
|
36
|
-
| `useHydrated()`
|
|
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:
|
|
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 —
|
|
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 —
|
|
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**:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
###
|
|
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
|
-
//
|
|
331
|
+
// WRONG — Next.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
|
-
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
328
|
-
const
|
|
329
|
-
<button onClick={() =>
|
|
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
|
package/src/start-entry.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
declare module '#tanstack-start-entry' {
|
|
2
|
-
import type { StartEntry } from '
|
|
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 '
|
|
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
|
-
}
|