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.
Protect routes with beforeLoad + redirect() in a pathless layout route (_authenticated):
// src/routes/_authenticated.tsx
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,
},
})
}
},
// component defaults to Outlet — no need to declare it
})
Any route file placed under src/routes/_authenticated/ is automatically protected:
// src/routes/_authenticated/dashboard.tsx
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>
}
Auth state flows into the router via createRootRouteWithContext and RouterProvider's context prop:
// src/routes/__root.tsx
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 />,
})
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export const router = createRouter({
routeTree,
context: {
auth: undefined!, // placeholder — filled by RouterProvider context prop
},
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// src/App.tsx
import { RouterProvider } from '@tanstack/react-router'
import { AuthProvider, useAuth } from './auth'
import { router } from './router'
function InnerApp() {
const auth = useAuth()
// context prop injects live auth state WITHOUT recreating the router
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).
Save the current location in search params so you can redirect back after login:
// src/routes/_authenticated.tsx
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 },
})
}
},
})
// src/routes/login.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState, type FormEvent } from 'react'
// Validate redirect target to prevent open redirect attacks
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>
)
}
Instead of redirecting, show a login form in place of the Outlet:
// src/routes/_authenticated.tsx
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.
Extend auth state with role/permission helpers, then check in beforeLoad:
// src/auth.tsx
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:
// src/routes/_authenticated/_admin.tsx
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:
// src/routes/_authenticated/_moderator.tsx
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:
// src/routes/_authenticated/_users.tsx
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):
// src/routes/_authenticated/_users/manage.tsx
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>
)
}
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 // re-throw redirect, don't swallow it
// Actual error — redirect to login
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
})
Component-level auth checks cause a flash of protected content before the redirect:
// WRONG — protected content renders briefly before redirect
export const Route = createFileRoute('/_authenticated/dashboard')({
component: () => {
const auth = useAuth()
if (!auth.isAuthenticated) return <Navigate to="/login" />
return <Dashboard />
},
})
// CORRECT — beforeLoad runs before any rendering
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.
redirect() works by throwing. If beforeLoad has a try/catch, the redirect gets swallowed:
// WRONG — redirect is caught and swallowed
beforeLoad: async ({ context }) => {
try {
await validateSession(context.auth)
} catch (e) {
console.error(e) // swallows the redirect!
}
}
// CORRECT — use isRedirect to distinguish intentional redirects from errors
import { isRedirect } from '@tanstack/react-router'
beforeLoad: async ({ context }) => {
try {
await validateSession(context.auth)
} catch (e) {
if (isRedirect(e)) throw e
console.error(e)
}
}
The root route always renders regardless of auth state. You cannot conditionally render its component:
// WRONG — root route always renders, this doesn't protect anything
export const Route = createRootRoute({
component: () => {
if (!isAuthenticated()) return <Login />
return <Outlet />
},
})
// CORRECT — use a pathless layout route for auth boundaries
// src/routes/_authenticated.tsx
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.
beforeLoad runs before loader; auth context flows into loader via route context