@tanstack/start-client-core 1.168.1 → 1.169.0

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.
@@ -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
@@ -0,0 +1,197 @@
1
+ import { createIsomorphicFn } from '@tanstack/start-fn-stubs'
2
+ import { createMiddleware } from './createMiddleware'
3
+ import type {
4
+ RequestMiddlewareAfterServer,
5
+ RequestServerOptions,
6
+ } from './createMiddleware'
7
+ import type { Register } from '@tanstack/router-core'
8
+
9
+ export const csrfSymbol = Symbol.for('tanstack-start:csrf-middleware')
10
+
11
+ export type CsrfSecFetchSite =
12
+ | 'same-origin'
13
+ | 'same-site'
14
+ | 'cross-site'
15
+ | 'none'
16
+
17
+ export type CsrfMatcher<TValue, TRegister = Register, TMiddlewares = unknown> =
18
+ | TValue
19
+ | Array<TValue>
20
+ | ((
21
+ value: TValue | (string & {}),
22
+ ctx: RequestServerOptions<TRegister, TMiddlewares>,
23
+ ) => boolean | Promise<boolean>)
24
+
25
+ export interface CsrfMiddlewareOptions<
26
+ TRegister = Register,
27
+ TMiddlewares = unknown,
28
+ > {
29
+ /**
30
+ * Return `true` to validate this request, or `false` to skip validation.
31
+ *
32
+ * @default undefined, which validates every request handled by this middleware.
33
+ */
34
+ filter?: (
35
+ ctx: RequestServerOptions<TRegister, TMiddlewares>,
36
+ ) => boolean | Promise<boolean>
37
+ /**
38
+ * Allowed Origin values. Defaults to the trusted request origin.
39
+ */
40
+ origin?: CsrfMatcher<string, TRegister, TMiddlewares>
41
+ /**
42
+ * Allowed Sec-Fetch-Site values.
43
+ *
44
+ * @default 'same-origin'
45
+ */
46
+ secFetchSite?: CsrfMatcher<CsrfSecFetchSite, TRegister, TMiddlewares>
47
+ /**
48
+ * Whether to use Referer as a fallback when Sec-Fetch-Site and Origin are absent.
49
+ *
50
+ * @default true
51
+ */
52
+ referer?:
53
+ | boolean
54
+ | ((
55
+ referer: string,
56
+ ctx: RequestServerOptions<TRegister, TMiddlewares>,
57
+ ) => boolean | Promise<boolean>)
58
+ /**
59
+ * Allow requests when Sec-Fetch-Site, Origin, and Referer are all missing.
60
+ *
61
+ * @default false
62
+ */
63
+ allowRequestsWithoutOriginCheck?: boolean
64
+ /**
65
+ * Optional response returned when CSRF validation fails.
66
+ *
67
+ * @default new Response('Forbidden', { status: 403 })
68
+ */
69
+ failureResponse?:
70
+ | Response
71
+ | ((
72
+ ctx: RequestServerOptions<TRegister, TMiddlewares>,
73
+ ) => Response | Promise<Response>)
74
+ }
75
+
76
+ type CreateCsrfMiddleware = <TRegister, TMiddlewares>(
77
+ opts?: CsrfMiddlewareOptions<TRegister, TMiddlewares>,
78
+ ) => RequestMiddlewareAfterServer<{}, undefined, undefined>
79
+
80
+ const innerCreateCsrfMiddleware: CreateCsrfMiddleware = (opts = {}) => {
81
+ const middleware = createMiddleware().server(async (ctx) => {
82
+ const csrfCtx = ctx as RequestServerOptions<any, any>
83
+
84
+ if (opts.filter && !(await opts.filter(csrfCtx))) {
85
+ return ctx.next()
86
+ }
87
+
88
+ if (await isCsrfRequestAllowed(opts, csrfCtx)) {
89
+ return ctx.next()
90
+ }
91
+
92
+ return getFailureResponse(opts, csrfCtx)
93
+ })
94
+
95
+ if (process.env.NODE_ENV !== 'production') {
96
+ Object.defineProperty(middleware, csrfSymbol, { value: true })
97
+ }
98
+
99
+ return middleware
100
+ }
101
+
102
+ export const createCsrfMiddleware: CreateCsrfMiddleware =
103
+ createIsomorphicFn().server(innerCreateCsrfMiddleware) as CreateCsrfMiddleware
104
+
105
+ export async function isCsrfRequestAllowed<TRegister, TMiddlewares>(
106
+ opts: CsrfMiddlewareOptions<TRegister, TMiddlewares>,
107
+ ctx: RequestServerOptions<TRegister, TMiddlewares>,
108
+ ): Promise<boolean> {
109
+ const result = await getCsrfRequestValidationResult(opts, ctx)
110
+ return (
111
+ result === true ||
112
+ (result === undefined && opts.allowRequestsWithoutOriginCheck === true)
113
+ )
114
+ }
115
+
116
+ export async function getCsrfRequestValidationResult<TRegister, TMiddlewares>(
117
+ opts: CsrfMiddlewareOptions<TRegister, TMiddlewares>,
118
+ ctx: RequestServerOptions<TRegister, TMiddlewares>,
119
+ ): Promise<boolean | undefined> {
120
+ const fetchSite = ctx.request.headers.get('Sec-Fetch-Site')
121
+ if (fetchSite !== null) {
122
+ return matchValue(opts.secFetchSite ?? 'same-origin', fetchSite, ctx)
123
+ }
124
+
125
+ const origin = ctx.request.headers.get('Origin')
126
+ if (origin !== null) {
127
+ if (opts.origin) {
128
+ return matchValue(opts.origin, origin, ctx)
129
+ }
130
+
131
+ return origin === new URL(ctx.request.url).origin
132
+ }
133
+
134
+ const referer = ctx.request.headers.get('Referer')
135
+ if (referer === null || opts.referer === false) {
136
+ return undefined
137
+ }
138
+
139
+ if (typeof opts.referer === 'function') {
140
+ return opts.referer(referer, ctx)
141
+ }
142
+
143
+ if (opts.origin) {
144
+ const refererOrigin = getOriginFromUrl(referer)
145
+ return (
146
+ refererOrigin !== undefined && matchValue(opts.origin, refererOrigin, ctx)
147
+ )
148
+ }
149
+
150
+ return isRefererSameOrigin(referer, new URL(ctx.request.url).origin)
151
+ }
152
+
153
+ async function matchValue<TValue extends string, TRegister, TMiddlewares>(
154
+ matcher: CsrfMatcher<TValue, TRegister, TMiddlewares>,
155
+ value: string,
156
+ ctx: RequestServerOptions<TRegister, TMiddlewares>,
157
+ ): Promise<boolean> {
158
+ if (typeof matcher === 'function') {
159
+ return matcher(value, ctx)
160
+ }
161
+
162
+ if (Array.isArray(matcher)) {
163
+ // typescript is dumb for array.includes()
164
+ return matcher.includes(value as TValue)
165
+ }
166
+
167
+ return value === matcher
168
+ }
169
+
170
+ function getOriginFromUrl(url: string): string | undefined {
171
+ try {
172
+ return new URL(url).origin
173
+ } catch {
174
+ return undefined
175
+ }
176
+ }
177
+
178
+ function isRefererSameOrigin(referer: string, requestOrigin: string): boolean {
179
+ if (referer === requestOrigin) return true
180
+ if (!referer.startsWith(requestOrigin)) return false
181
+ if (referer.length === requestOrigin.length) return true
182
+ const code = referer.charCodeAt(requestOrigin.length)
183
+ return code === 47 /* '/' */ || code === 63 /* '?' */ || code === 35 /* '#' */
184
+ }
185
+
186
+ async function getFailureResponse<TRegister, TMiddlewares>(
187
+ opts: CsrfMiddlewareOptions<TRegister, TMiddlewares>,
188
+ ctx: RequestServerOptions<TRegister, TMiddlewares>,
189
+ ): Promise<Response> {
190
+ if (typeof opts.failureResponse === 'function') {
191
+ return opts.failureResponse(ctx)
192
+ }
193
+
194
+ return (
195
+ opts.failureResponse?.clone() ?? new Response('Forbidden', { status: 403 })
196
+ )
197
+ }
@@ -770,6 +770,10 @@ export interface RequestServerOptions<TRegister, TMiddlewares> {
770
770
  pathname: string
771
771
  context: Expand<AssignAllServerRequestContext<TRegister, TMiddlewares>>
772
772
  next: RequestServerNextFn<TRegister, TMiddlewares>
773
+ /**
774
+ * Type of Start handler currently processing this request.
775
+ */
776
+ handlerType: 'serverFn' | 'router'
773
777
  /**
774
778
  * Metadata about the server function being invoked.
775
779
  * This is only present when the request is handling a server function call.
package/src/index.tsx CHANGED
@@ -84,6 +84,17 @@ export {
84
84
  flattenMiddlewares,
85
85
  executeMiddleware,
86
86
  } from './createServerFn'
87
+ export {
88
+ createCsrfMiddleware,
89
+ csrfSymbol,
90
+ getCsrfRequestValidationResult,
91
+ isCsrfRequestAllowed,
92
+ } from './createCsrfMiddleware'
93
+ export type {
94
+ CsrfMatcher,
95
+ CsrfMiddlewareOptions,
96
+ CsrfSecFetchSite,
97
+ } from './createCsrfMiddleware'
87
98
 
88
99
  export {
89
100
  TSS_FORMDATA_CONTEXT,
@@ -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
  }
@@ -675,6 +675,7 @@ test('createMiddleware with type request, no middleware or context', () => {
675
675
  next: RequestServerNextFn<{}, undefined>
676
676
  pathname: string
677
677
  context: undefined
678
+ handlerType: 'serverFn' | 'router'
678
679
  serverFnMeta?: ServerFnMeta
679
680
  }>()
680
681
 
@@ -698,6 +699,7 @@ test('createMiddleware with type request, no middleware with context', () => {
698
699
  next: RequestServerNextFn<{}, undefined>
699
700
  pathname: string
700
701
  context: undefined
702
+ handlerType: 'serverFn' | 'router'
701
703
  serverFnMeta?: ServerFnMeta
702
704
  }>()
703
705
 
@@ -722,6 +724,7 @@ test('createMiddleware with type request, middleware and context', () => {
722
724
  next: RequestServerNextFn<{}, undefined>
723
725
  pathname: string
724
726
  context: undefined
727
+ handlerType: 'serverFn' | 'router'
725
728
  serverFnMeta?: ServerFnMeta
726
729
  }>()
727
730
 
@@ -746,6 +749,7 @@ test('createMiddleware with type request, middleware and context', () => {
746
749
  next: RequestServerNextFn<{}, undefined>
747
750
  pathname: string
748
751
  context: { a: string }
752
+ handlerType: 'serverFn' | 'router'
749
753
  serverFnMeta?: ServerFnMeta
750
754
  }>()
751
755
 
@@ -769,6 +773,7 @@ test('createMiddleware with type request can return Response directly', () => {
769
773
  next: RequestServerNextFn<{}, undefined>
770
774
  pathname: string
771
775
  context: undefined
776
+ handlerType: 'serverFn' | 'router'
772
777
  serverFnMeta?: ServerFnMeta
773
778
  }>()
774
779
 
@@ -789,6 +794,7 @@ test('createMiddleware with type request can return Promise<Response>', () => {
789
794
  next: RequestServerNextFn<{}, undefined>
790
795
  pathname: string
791
796
  context: undefined
797
+ handlerType: 'serverFn' | 'router'
792
798
  serverFnMeta?: ServerFnMeta
793
799
  }>()
794
800
 
@@ -804,6 +810,7 @@ test('createMiddleware with type request can return sync Response', () => {
804
810
  next: RequestServerNextFn<{}, undefined>
805
811
  pathname: string
806
812
  context: undefined
813
+ handlerType: 'serverFn' | 'router'
807
814
  serverFnMeta?: ServerFnMeta
808
815
  }>()
809
816
 
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
- }