@tanstack/router-core 1.169.0 → 1.169.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/dist/cjs/load-matches.cjs.map +1 -1
- package/dist/cjs/route.cjs.map +1 -1
- package/dist/cjs/route.d.cts +6 -3
- package/dist/esm/load-matches.js.map +1 -1
- package/dist/esm/route.d.ts +6 -3
- package/dist/esm/route.js.map +1 -1
- package/package.json +3 -7
- package/skills/router-core/auth-and-guards/SKILL.md +41 -0
- package/skills/router-core/ssr/SKILL.md +59 -5
- package/skills/router-core/type-safety/SKILL.md +37 -42
- package/src/load-matches.ts +1 -1
- package/src/route.ts +10 -7
- package/bin/intent.js +0 -25
|
@@ -21,6 +21,10 @@ sources:
|
|
|
21
21
|
|
|
22
22
|
# Auth and Guards
|
|
23
23
|
|
|
24
|
+
> **This skill covers the routing side of auth.** For the **server-side primitives** — session cookies (`HttpOnly`/`Secure`/`SameSite`), `useSession`-style helpers, OAuth `state` + PKCE, password-reset enumeration defense, CSRF, rate limiting — see [start-core/auth-server-primitives](../../../../start-client-core/skills/start-core/auth-server-primitives/SKILL.md). The two skills are designed to be used together.
|
|
25
|
+
>
|
|
26
|
+
> **CRITICAL**: A route guard (`beforeLoad`) does NOT protect a `createServerFn` declared on that route. Server functions are RPC endpoints reachable by direct POST regardless of which route renders them. See "Route guards do not protect server functions" below.
|
|
27
|
+
|
|
24
28
|
## Setup
|
|
25
29
|
|
|
26
30
|
Protect routes with `beforeLoad` + `redirect()` in a pathless layout route (`_authenticated`):
|
|
@@ -371,6 +375,41 @@ export const Route = createFileRoute('/_authenticated')({
|
|
|
371
375
|
|
|
372
376
|
## Common Mistakes
|
|
373
377
|
|
|
378
|
+
### CRITICAL: Route guards do not protect server functions
|
|
379
|
+
|
|
380
|
+
A `beforeLoad` redirect protects the **route's UI**, not the **server functions** declared on it. `createServerFn` produces an RPC endpoint reachable by direct POST regardless of which route renders the calling UI. An attacker doesn't have to load `/_authenticated/orders` — they can curl the RPC endpoint directly.
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
// WRONG — handler has no auth check; the route guard doesn't help
|
|
384
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
385
|
+
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
386
|
+
|
|
387
|
+
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
|
|
388
|
+
return db.orders.findMany() // ← anyone can hit the RPC
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
export const Route = createFileRoute('/_authenticated/orders')({
|
|
392
|
+
beforeLoad: ({ context }) => {
|
|
393
|
+
if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
|
|
394
|
+
},
|
|
395
|
+
loader: () => getMyOrders(),
|
|
396
|
+
})
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
```tsx
|
|
400
|
+
// CORRECT — auth enforced on the handler itself, via middleware
|
|
401
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
402
|
+
import { authMiddleware } from '~/server/auth-middleware'
|
|
403
|
+
|
|
404
|
+
const getMyOrders = createServerFn({ method: 'GET' })
|
|
405
|
+
.middleware([authMiddleware])
|
|
406
|
+
.handler(async ({ context }) => {
|
|
407
|
+
return db.orders.findMany({ where: { userId: context.session.userId } })
|
|
408
|
+
})
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Rule of thumb: every `createServerFn` that touches user data needs `authMiddleware` (or an equivalent in-handler check). The route guard is for the page experience; the RPC guard is for the data. See [start-core/auth-server-primitives](../../../../start-client-core/skills/start-core/auth-server-primitives/SKILL.md) for the full session/middleware pattern.
|
|
412
|
+
|
|
374
413
|
### HIGH: Auth check in component instead of beforeLoad
|
|
375
414
|
|
|
376
415
|
Component-level auth checks cause a **flash of protected content** before the redirect:
|
|
@@ -456,3 +495,5 @@ Place protected routes as children of the `_authenticated` layout route. Public
|
|
|
456
495
|
## Cross-References
|
|
457
496
|
|
|
458
497
|
- See also: **router-core/data-loading/SKILL.md** — `beforeLoad` runs before `loader`; auth context flows into loader via route context
|
|
498
|
+
- See also: **start-core/auth-server-primitives/SKILL.md** — server-side session cookies, OAuth state + PKCE, CSRF, password-reset hardening, rate limiting (the server half of authentication)
|
|
499
|
+
- See also: **start-core/middleware/SKILL.md** — `authMiddleware` factory pattern for protecting individual `createServerFn` calls
|
|
@@ -391,22 +391,74 @@ component: () => {
|
|
|
391
391
|
}
|
|
392
392
|
```
|
|
393
393
|
|
|
394
|
-
### 3. CRITICAL: Generating Next.js or
|
|
394
|
+
### 3. CRITICAL: Generating Next.js, Remix, or React Router DOM patterns
|
|
395
395
|
|
|
396
|
-
TanStack Router does NOT use `getServerSideProps`, `getStaticProps`, App Router `page.tsx`,
|
|
396
|
+
TanStack Router does NOT use `getServerSideProps`, `getStaticProps`, App Router `page.tsx`, Remix-style server-only `loader` exports, or anything from `react-router-dom`.
|
|
397
|
+
|
|
398
|
+
#### Wrong file structures
|
|
399
|
+
|
|
400
|
+
```text
|
|
401
|
+
WRONG (Next.js Pages Router):
|
|
402
|
+
src/pages/index.tsx
|
|
403
|
+
src/pages/_app.tsx
|
|
404
|
+
src/pages/posts/[id].tsx
|
|
405
|
+
|
|
406
|
+
WRONG (Next.js App Router):
|
|
407
|
+
app/layout.tsx
|
|
408
|
+
app/page.tsx
|
|
409
|
+
app/posts/[id]/page.tsx
|
|
410
|
+
|
|
411
|
+
WRONG (Next.js custom App):
|
|
412
|
+
_app/index.tsx
|
|
413
|
+
pages/_app.tsx, pages/_document.tsx
|
|
414
|
+
|
|
415
|
+
CORRECT (TanStack Router file-based routing):
|
|
416
|
+
src/routes/__root.tsx
|
|
417
|
+
src/routes/index.tsx
|
|
418
|
+
src/routes/posts/$postId.tsx
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
#### Wrong imports
|
|
397
422
|
|
|
398
423
|
```tsx
|
|
399
|
-
// WRONG —
|
|
424
|
+
// WRONG — react-router-dom is a different library
|
|
425
|
+
import {
|
|
426
|
+
Link,
|
|
427
|
+
useNavigate,
|
|
428
|
+
BrowserRouter,
|
|
429
|
+
Route,
|
|
430
|
+
Routes,
|
|
431
|
+
} from 'react-router-dom'
|
|
432
|
+
|
|
433
|
+
// WRONG — Next.js Link/router
|
|
434
|
+
import Link from 'next/link'
|
|
435
|
+
import { useRouter } from 'next/router' // Pages Router
|
|
436
|
+
import { useRouter } from 'next/navigation' // App Router
|
|
437
|
+
|
|
438
|
+
// CORRECT — everything routing-related lives in @tanstack/react-router
|
|
439
|
+
import {
|
|
440
|
+
Link,
|
|
441
|
+
useNavigate,
|
|
442
|
+
useRouter,
|
|
443
|
+
useLocation,
|
|
444
|
+
redirect,
|
|
445
|
+
} from '@tanstack/react-router'
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
#### Wrong loader/data-fetching patterns
|
|
449
|
+
|
|
450
|
+
```tsx
|
|
451
|
+
// WRONG — Next.js Pages Router
|
|
400
452
|
export async function getServerSideProps() {
|
|
401
453
|
return { props: { data: await fetchData() } }
|
|
402
454
|
}
|
|
403
455
|
|
|
404
|
-
// WRONG — Remix
|
|
456
|
+
// WRONG — Remix
|
|
405
457
|
export async function loader({ request }: LoaderFunctionArgs) {
|
|
406
458
|
return json({ data: await fetchData() })
|
|
407
459
|
}
|
|
408
460
|
|
|
409
|
-
// CORRECT — TanStack Router
|
|
461
|
+
// CORRECT — TanStack Router
|
|
410
462
|
export const Route = createFileRoute('/data')({
|
|
411
463
|
loader: async () => {
|
|
412
464
|
const data = await fetchData()
|
|
@@ -421,6 +473,8 @@ function DataPage() {
|
|
|
421
473
|
}
|
|
422
474
|
```
|
|
423
475
|
|
|
476
|
+
If you see `src/pages/`, `app/layout.tsx`, `react-router-dom`, or any of the above in agent output, the agent is generating for the wrong framework. The build will either fail or produce duplicate `/` routes that conflict at runtime.
|
|
477
|
+
|
|
424
478
|
## Tension: Client-First Loaders vs SSR
|
|
425
479
|
|
|
426
480
|
TanStack Router loaders are client-first by design. When SSR is enabled, they run in both environments. This means:
|
|
@@ -313,7 +313,9 @@ export function NavItem(props: NavItemProps): React.ReactNode {
|
|
|
313
313
|
<NavItem label="Post" linkOptions={{ to: '/posts/$postId', params: { postId: '1' } }} />
|
|
314
314
|
```
|
|
315
315
|
|
|
316
|
-
### `ValidateNavigateOptions`
|
|
316
|
+
### `ValidateNavigateOptions` and `ValidateRedirectOptions`
|
|
317
|
+
|
|
318
|
+
Same pattern as `ValidateLinkOptions` above, for `useNavigate` and `redirect`. Declare a generic public overload plus a non-generic implementation signature so the call site stays narrowed and the body works without casts:
|
|
317
319
|
|
|
318
320
|
```tsx
|
|
319
321
|
import {
|
|
@@ -332,45 +334,15 @@ export function useDelayedNavigate<
|
|
|
332
334
|
export function useDelayedNavigate(
|
|
333
335
|
options: ValidateNavigateOptions,
|
|
334
336
|
delayMs: number,
|
|
335
|
-
)
|
|
337
|
+
) {
|
|
336
338
|
const navigate = useNavigate()
|
|
337
339
|
return () => {
|
|
338
340
|
setTimeout(() => navigate(options), delayMs)
|
|
339
341
|
}
|
|
340
342
|
}
|
|
341
|
-
|
|
342
|
-
// Usage — type-safe
|
|
343
|
-
const go = useDelayedNavigate(
|
|
344
|
-
{ to: '/posts/$postId', params: { postId: '1' } },
|
|
345
|
-
500,
|
|
346
|
-
)
|
|
347
343
|
```
|
|
348
344
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
```tsx
|
|
352
|
-
import {
|
|
353
|
-
redirect,
|
|
354
|
-
type RegisteredRouter,
|
|
355
|
-
type ValidateRedirectOptions,
|
|
356
|
-
} from '@tanstack/react-router'
|
|
357
|
-
|
|
358
|
-
export async function fetchOrRedirect<
|
|
359
|
-
TRouter extends RegisteredRouter = RegisteredRouter,
|
|
360
|
-
TOptions = unknown,
|
|
361
|
-
>(
|
|
362
|
-
url: string,
|
|
363
|
-
redirectOptions: ValidateRedirectOptions<TRouter, TOptions>,
|
|
364
|
-
): Promise<unknown>
|
|
365
|
-
export async function fetchOrRedirect(
|
|
366
|
-
url: string,
|
|
367
|
-
redirectOptions: ValidateRedirectOptions,
|
|
368
|
-
): Promise<unknown> {
|
|
369
|
-
const response = await fetch(url)
|
|
370
|
-
if (!response.ok && response.status === 401) throw redirect(redirectOptions)
|
|
371
|
-
return response.json()
|
|
372
|
-
}
|
|
373
|
-
```
|
|
345
|
+
`ValidateRedirectOptions` works identically — declare a generic overload accepting `ValidateRedirectOptions<TRouter, TOptions>` and an impl signature accepting `ValidateRedirectOptions`, then call `redirect(options)` in the body.
|
|
374
346
|
|
|
375
347
|
### Render Props for Maximum Performance
|
|
376
348
|
|
|
@@ -477,21 +449,44 @@ declare module '@tanstack/react-router' {
|
|
|
477
449
|
}
|
|
478
450
|
```
|
|
479
451
|
|
|
480
|
-
### 5. CRITICAL (cross-skill):
|
|
452
|
+
### 5. CRITICAL (cross-skill): Wrong-framework imports and file structure
|
|
453
|
+
|
|
454
|
+
Wrong-framework code looks plausible (it's React) but breaks the build or produces conflicting `/` routes at runtime.
|
|
481
455
|
|
|
482
456
|
```tsx
|
|
483
|
-
// WRONG —
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
457
|
+
// WRONG — react-router-dom and next/* are different libraries
|
|
458
|
+
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
|
459
|
+
import Link from 'next/link'
|
|
460
|
+
import { useRouter, useParams } from 'next/navigation'
|
|
487
461
|
|
|
488
|
-
// CORRECT —
|
|
462
|
+
// CORRECT — all routing exports come from @tanstack/react-router
|
|
463
|
+
import {
|
|
464
|
+
Link,
|
|
465
|
+
Outlet,
|
|
466
|
+
useNavigate,
|
|
467
|
+
useRouter,
|
|
468
|
+
useLocation,
|
|
469
|
+
useParams,
|
|
470
|
+
redirect,
|
|
471
|
+
} from '@tanstack/react-router'
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
```tsx
|
|
475
|
+
// WRONG file structures + APIs:
|
|
476
|
+
// src/pages/*.tsx with getServerSideProps / getStaticProps (Next.js Pages Router)
|
|
477
|
+
// app/layout.tsx + app/page.tsx (Next.js App Router)
|
|
478
|
+
// _app/index.tsx, pages/_app.tsx, pages/_document.tsx (Next.js custom App)
|
|
479
|
+
// loader/action exports (Remix)
|
|
480
|
+
|
|
481
|
+
// CORRECT — TanStack file-based routing at src/routes/*.tsx
|
|
489
482
|
export const Route = createFileRoute('/posts')({
|
|
490
|
-
loader: async () => { ... },
|
|
491
|
-
validateSearch: zodValidator(schema),
|
|
483
|
+
loader: async () => { ... },
|
|
484
|
+
validateSearch: zodValidator(schema),
|
|
492
485
|
component: PostsComponent,
|
|
493
486
|
})
|
|
494
|
-
const search = Route.useSearch()
|
|
487
|
+
const search = Route.useSearch()
|
|
495
488
|
```
|
|
496
489
|
|
|
490
|
+
If a build error mentions `react-router-dom`, `next/`, `pages/_app`, or duplicate `/` routes, fix the import — don't paper over with type assertions.
|
|
491
|
+
|
|
497
492
|
See also: router-core (Register setup), router-core/navigation (from narrowing), router-core/code-splitting (getRouteApi).
|
package/src/load-matches.ts
CHANGED
|
@@ -713,7 +713,7 @@ const runLoader = async (
|
|
|
713
713
|
const pendingPromise = match._nonReactive.minPendingPromise
|
|
714
714
|
if (pendingPromise) await pendingPromise
|
|
715
715
|
|
|
716
|
-
// Last but not least, wait for the
|
|
716
|
+
// Last but not least, wait for the components
|
|
717
717
|
// to be preloaded before we resolve the match
|
|
718
718
|
if (route._componentsPromise) await route._componentsPromise
|
|
719
719
|
inner.updateMatch(matchId, (prev) => ({
|
package/src/route.ts
CHANGED
|
@@ -173,11 +173,13 @@ export type ResolveParams<
|
|
|
173
173
|
|
|
174
174
|
export type ParseParamsFn<in out TPath extends string, in out TParams> = (
|
|
175
175
|
rawParams: Expand<ResolveParams<TPath>>,
|
|
176
|
-
) =>
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
) => TParams | false
|
|
177
|
+
|
|
178
|
+
type ValidateParsedParams<TPath extends string, TParams> = [TParams] extends [
|
|
179
|
+
ResolveParams<TPath, any>,
|
|
180
|
+
]
|
|
181
|
+
? unknown
|
|
182
|
+
: never
|
|
181
183
|
|
|
182
184
|
export type StringifyParamsFn<in out TPath extends string, in out TParams> = (
|
|
183
185
|
params: TParams,
|
|
@@ -185,14 +187,15 @@ export type StringifyParamsFn<in out TPath extends string, in out TParams> = (
|
|
|
185
187
|
|
|
186
188
|
export type ParamsOptions<in out TPath extends string, in out TParams> = {
|
|
187
189
|
params?: {
|
|
188
|
-
parse?: ParseParamsFn<TPath, TParams>
|
|
190
|
+
parse?: ParseParamsFn<TPath, TParams> & ValidateParsedParams<TPath, TParams>
|
|
189
191
|
stringify?: StringifyParamsFn<TPath, TParams>
|
|
190
192
|
}
|
|
191
193
|
|
|
192
194
|
/**
|
|
193
195
|
@deprecated Use params.parse instead
|
|
194
196
|
*/
|
|
195
|
-
parseParams?: ParseParamsFn<TPath, TParams>
|
|
197
|
+
parseParams?: ParseParamsFn<TPath, TParams> &
|
|
198
|
+
ValidateParsedParams<TPath, TParams>
|
|
196
199
|
|
|
197
200
|
/**
|
|
198
201
|
@deprecated Use params.stringify instead
|
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
|
-
}
|