| name | router-core/auth-and-guards |
| description | Route protection with beforeLoad, redirect()/throw redirect(), isRedirect helper, authenticated layout routes (_authenticated), non-redirect auth (inline login), RBAC with roles and permissions, auth provider integration (Auth0, Clerk, Supabase), router context for auth state. |
| type | sub-skill |
| library | tanstack-router |
| library_version | 1.166.2 |
| requires | ["router-core","router-core/data-loading"] |
| sources | ["TanStack/router:docs/router/guide/authenticated-routes.md","TanStack/router:docs/router/how-to/setup-authentication.md","TanStack/router:docs/router/how-to/setup-auth-providers.md","TanStack/router:docs/router/how-to/setup-rbac.md"] |
Auth and Guards
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. The two skills are designed to be used together.
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.
Setup
Protect routes with beforeLoad + redirect() in a pathless layout route (_authenticated):
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: {
redirect: location.href,
},
})
}
},
})
Any route file placed under src/routes/_authenticated/ is automatically protected:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/dashboard')({
component: DashboardComponent,
})
function DashboardComponent() {
const { auth } = Route.useRouteContext()
return <div>Welcome, {auth.user?.username}</div>
}
Core Patterns
Router Context for Auth State
Auth state flows into the router via createRootRouteWithContext and RouterProvider's context prop:
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
interface AuthState {
isAuthenticated: boolean
user: { id: string; username: string; email: string } | null
login: (username: string, password: string) => Promise<void>
logout: () => void
}
interface MyRouterContext {
auth: AuthState
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
component: () => <Outlet />,
})
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({
routeTree,
context: {
auth: undefined!,
},
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
import { RouterProvider } from '@tanstack/react-router'
import { AuthProvider, useAuth } from './auth'
import { router } from './router'
function InnerApp() {
const auth = useAuth()
return <RouterProvider router={router} context={{ auth }} />
}
function App() {
return (
<AuthProvider>
<InnerApp />
</AuthProvider>
)
}
The router is created once with a placeholder. RouterProvider's context prop injects the live auth state on each render — this avoids recreating the router on auth changes (which would reset caches and rebuild the route tree).
Redirect-Based Auth with Redirect-Back
Save the current location in search params so you can redirect back after login:
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
})
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState, type FormEvent } from 'react'
function sanitizeRedirect(url: unknown): string {
if (typeof url !== 'string' || !url.startsWith('/') || url.startsWith('//')) {
return '/'
}
return url
}
export const Route = createFileRoute('/login')({
validateSearch: (search) => ({
redirect: sanitizeRedirect(search.redirect),
}),
beforeLoad: ({ context, search }) => {
if (context.auth.isAuthenticated) {
throw redirect({ to: search.redirect })
}
},
component: LoginComponent,
})
function LoginComponent() {
const { auth } = Route.useRouteContext()
const search = Route.useSearch()
const navigate = Route.useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
try {
await auth.login(username, password)
navigate({ to: search.redirect })
} catch {
setError('Invalid credentials')
}
}
return (
<form onSubmit={handleSubmit}>
{error && <div>{error}</div>}
<input value={username} onChange={(e) => setUsername(e.target.value)} />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Sign In</button>
</form>
)
}
Non-Redirect Auth (Inline Login)
Instead of redirecting, show a login form in place of the Outlet:
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
const { auth } = Route.useRouteContext()
if (!auth.isAuthenticated) {
return <LoginForm />
}
return <Outlet />
}
This keeps the URL unchanged — the user stays on the same page and sees a login form instead of protected content. After authentication, <Outlet /> renders and child routes appear.
RBAC with Roles and Permissions
Extend auth state with role/permission helpers, then check in beforeLoad:
interface User {
id: string
username: string
email: string
roles: string[]
permissions: string[]
}
interface AuthState {
isAuthenticated: boolean
user: User | null
hasRole: (role: string) => boolean
hasAnyRole: (roles: string[]) => boolean
hasPermission: (permission: string) => boolean
hasAnyPermission: (permissions: string[]) => boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}
Admin-only layout route:
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/_admin')({
beforeLoad: ({ context, location }) => {
if (!context.auth.hasRole('admin')) {
throw redirect({
to: '/unauthorized',
search: { redirect: location.href },
})
}
},
})
Multi-role access:
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/_moderator')({
beforeLoad: ({ context, location }) => {
if (!context.auth.hasAnyRole(['admin', 'moderator'])) {
throw redirect({
to: '/unauthorized',
search: { redirect: location.href },
})
}
},
})
Permission-based:
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/_users')({
beforeLoad: ({ context, location }) => {
if (!context.auth.hasAnyPermission(['users:read', 'users:write'])) {
throw redirect({
to: '/unauthorized',
search: { redirect: location.href },
})
}
},
})
Page-level permission check (nested under an already-role-protected layout):
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/_users/manage')({
beforeLoad: ({ context }) => {
if (!context.auth.hasPermission('users:write')) {
throw new Error('Write permission required')
}
},
component: UserManagement,
})
function UserManagement() {
const { auth } = Route.useRouteContext()
const canDelete = auth.hasPermission('users:delete')
return (
<div>
<h1>User Management</h1>
{canDelete && <button>Delete User</button>}
</div>
)
}
Handling Auth Check Failures (isRedirect)
When beforeLoad has a try/catch, redirects (which work by throwing) can get swallowed. Use isRedirect to re-throw:
import { createFileRoute, redirect, isRedirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ context, location }) => {
try {
const user = await verifySession(context.auth)
if (!user) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
return { user }
} catch (error) {
if (isRedirect(error)) throw error
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
})
Common Mistakes
CRITICAL: Route guards do not protect server functions
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.
import { createServerFn } from '@tanstack/react-start'
import { createFileRoute, redirect } from '@tanstack/react-router'
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
return db.orders.findMany()
})
export const Route = createFileRoute('/_authenticated/orders')({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
},
loader: () => getMyOrders(),
})
import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'
const getMyOrders = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.handler(async ({ context }) => {
return db.orders.findMany({ where: { userId: context.session.userId } })
})
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 for the full session/middleware pattern.
HIGH: Auth check in component instead of beforeLoad
Component-level auth checks cause a flash of protected content before the redirect:
export const Route = createFileRoute('/_authenticated/dashboard')({
component: () => {
const auth = useAuth()
if (!auth.isAuthenticated) return <Navigate to="/login" />
return <Dashboard />
},
})
export const Route = createFileRoute('/_authenticated/dashboard')({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
},
component: Dashboard,
})
beforeLoad runs before any component rendering and before the loader. It completely prevents the flash.
HIGH: Not re-throwing redirects in try/catch
redirect() works by throwing. If beforeLoad has a try/catch, the redirect gets swallowed:
beforeLoad: async ({ context }) => {
try {
await validateSession(context.auth)
} catch (e) {
console.error(e)
}
}
import { isRedirect } from '@tanstack/react-router'
beforeLoad: async ({ context }) => {
try {
await validateSession(context.auth)
} catch (e) {
if (isRedirect(e)) throw e
console.error(e)
}
}
MEDIUM: Conditionally rendering root route component
The root route always renders regardless of auth state. You cannot conditionally render its component:
export const Route = createRootRoute({
component: () => {
if (!isAuthenticated()) return <Login />
return <Outlet />
},
})
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
},
})
Place protected routes as children of the _authenticated layout route. Public routes (login, home, etc.) live outside it.
Cross-References
- See also: router-core/data-loading/SKILL.md —
beforeLoad runs before loader; auth context flows into loader via route context
- 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)
- See also: start-core/middleware/SKILL.md —
authMiddleware factory pattern for protecting individual createServerFn calls