"Stop writing auth checks inside your components. TanStack Router's beforeLoad and the _authenticated layout pattern give you real route protection—at the routing level, before anything renders."
Happy Coding! — Ahmed Fahmy
Most React apps protect routes like this:
// ❌ The naive way
function Dashboard() {
const { user } = useAuth()
if (!user) {
return <Navigate to="/login" />
}
return <DashboardContent />
}
This works. Barely. But it has real problems.
First, the component renders before the check. There's a flash. The protected content might be briefly visible. Second, every protected component needs this logic. Forget it once and you've got an unprotected route. Third, it's happening at the component level—after routing, after rendering starts, after the loader already ran.
TanStack Router moves auth checks to where they belong: the routing layer. Before anything loads. Before anything renders.
TanStack Router gives you two tools for auth protection. They're different and you'll use both.
beforeLoad — runs before the loader, before the component. Redirects immediately if the user isn't authenticated. Perfect for per-route or per-subtree protection.
_authenticated layout route — a pathless layout that wraps a group of routes. Define beforeLoad once, protect everything underneath. This is the right pattern for most apps.
Before you can use auth in beforeLoad, you need to pass your auth state through route context. This is the TanStack Router way of making data available to every route without prop drilling.
// src/main.tsx
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { useAuth } from './lib/auth'
const router = createRouter({
routeTree,
context: {
auth: undefined!, // will be set below
},
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
function App() {
const auth = useAuth() // your auth hook
return <RouterProvider router={router} context={{ auth }} />
}
Now define what auth looks like in the root route:
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
interface RouterContext {
auth: {
user: User | null
isLoading: boolean
}
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
})
That createRootRouteWithContext call is what makes TypeScript understand the context shape everywhere in your route tree.
This is the pattern. Create a pathless layout route that protects everything underneath it.
src/routes/
├── __root.tsx
├── index.tsx → / (public)
├── (auth)/
│ ├── login.tsx → /login
│ └── register.tsx → /register
└── _authenticated/
├── _authenticated.tsx → layout + auth guard (no URL segment)
├── dashboard.tsx → /dashboard ✅ protected
├── settings.tsx → /settings ✅ protected
└── posts/
├── index.tsx → /posts ✅ protected
└── $postId.tsx → /posts/:postId ✅ protected
All routes under _authenticated/ are protected by one guard. That's the power of layout routes.
// src/routes/_authenticated/_authenticated.tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.user) {
throw redirect({
to: '/login',
search: {
redirect: location.href, // remember where they were going
},
})
}
},
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
return (
<div>
<Sidebar />
<main>
<Outlet />
</main>
</div>
)
}
That's the whole guard. If there's no user, redirect to /login and carry the destination URL in search params. Every route nested under _authenticated/ inherits this check automatically.
The login page should redirect authenticated users away, and redirect back to the original destination after login.
// src/routes/(auth)/login.tsx
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
import { z } from 'zod'
import { fallback } from '@tanstack/zod-adapter'
const loginSearchSchema = z.object({
redirect: fallback(z.string(), '/dashboard').default('/dashboard'),
})
export const Route = createFileRoute('/(auth)/login')({
validateSearch: loginSearchSchema,
beforeLoad: ({ context, search }) => {
// Already logged in? Send them where they were going
if (context.auth.user) {
throw redirect({ to: search.redirect })
}
},
component: LoginPage,
})
function LoginPage() {
const { redirect: redirectTo } = Route.useSearch()
const router = useRouter()
const { signIn } = Route.useRouteContext({ select: (ctx) => ctx.auth })
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
await signIn({
email: formData.get('email') as string,
password: formData.get('password') as string,
})
// Invalidate router so context re-reads the new auth state
router.invalidate()
// Navigate to original destination
router.navigate({ to: redirectTo })
}
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Sign In</button>
</form>
)
}
The router.invalidate() call is important. It tells TanStack Router to re-run all active loaders and beforeLoad checks. Since your auth context is now updated, the _authenticated guard will pass.
Not all authenticated routes are equal. Some require admin access. beforeLoad handles this too.
// src/routes/_authenticated/admin.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated/admin')({
beforeLoad: ({ context }) => {
// _authenticated already checked for user existence
// here we check the role
if (context.auth.user?.role !== 'admin') {
throw redirect({ to: '/dashboard' })
}
},
component: AdminPage,
})
function AdminPage() {
return <h1>Admin Panel</h1>
}
You can stack beforeLoad guards. The parent layout checks for authentication, the child route checks for the specific role. Clean separation of concerns.
If your auth check is async (checking a session with the server, for example), you don't want the whole page to hang. Use pendingComponent on the root route:
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => <Outlet />,
pendingComponent: () => (
<div className="flex items-center justify-center h-screen">
<span>Loading...</span>
</div>
),
})
Or use pendingMs to only show the spinner if it takes longer than a threshold:
const router = createRouter({
routeTree,
context: { auth: undefined! },
defaultPendingMs: 200, // only show pending UI if load takes > 200ms
defaultPendingMinMs: 500, // keep it visible at least 500ms to avoid flicker
})
Here's a complete setup that ties everything together.
src/
├── lib/
│ └── auth.ts → useAuth hook, getCurrentUser
├── routes/
│ ├── __root.tsx → root with context
│ ├── index.tsx → /
│ ├── (auth)/
│ │ ├── login.tsx → /login
│ │ └── register.tsx → /register
│ └── _authenticated/
│ ├── _authenticated.tsx → auth guard
│ ├── dashboard.tsx → /dashboard
│ ├── settings.tsx → /settings
│ └── admin.tsx → /admin (role check)
// src/lib/auth.ts
import { useState, useEffect } from 'react'
export interface User {
id: string
name: string
email: string
role: 'user' | 'admin'
}
export function useAuth() {
const [user, setUser] = useState<User | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
getCurrentUser()
.then(setUser)
.finally(() => setIsLoading(false))
}, [])
async function signIn(credentials: { email: string; password: string }) {
const user = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
}).then(r => r.json())
setUser(user)
return user
}
async function signOut() {
await fetch('/api/auth/logout', { method: 'POST' })
setUser(null)
}
return { user, isLoading, signIn, signOut }
}
async function getCurrentUser(): Promise<User | null> {
try {
return await fetch('/api/auth/me').then(r => r.json())
} catch {
return null
}
}
// src/routes/_authenticated/_authenticated.tsx
import { createFileRoute, redirect, Outlet, Link } from '@tanstack/react-router'
import { useRouter } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (context.auth.isLoading) return // still checking, don't redirect yet
if (!context.auth.user) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
const { user, signOut } = Route.useRouteContext({ select: (ctx) => ctx.auth })
const router = useRouter()
async function handleSignOut() {
await signOut()
router.invalidate()
router.navigate({ to: '/login' })
}
return (
<div className="flex h-screen">
<aside className="w-64 border-r p-4">
<nav className="flex flex-col gap-2">
<Link to="/dashboard" activeProps={{ className: 'font-bold' }}>
Dashboard
</Link>
<Link to="/settings" activeProps={{ className: 'font-bold' }}>
Settings
</Link>
{user?.role === 'admin' && (
<Link to="/admin" activeProps={{ className: 'font-bold' }}>
Admin
</Link>
)}
</nav>
<button onClick={handleSignOut} className="mt-auto">
Sign Out
</button>
</aside>
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
)
}
If you're using TanStack Router with SSR (via TanStack Start), you can run beforeLoad on the server too. The pattern is the same—check the session cookie, redirect if missing. The difference is that context.auth needs to be hydrated server-side before the router runs.
// With TanStack Start (SSR)
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ context }) => {
// context.auth is populated server-side from the session
if (!context.auth.user) {
throw redirect({ to: '/login' })
}
},
})
For pure SPA apps, the pattern shown above (with useAuth hook) is the right approach.
| Approach | Component Guard | Route Guard (beforeLoad) |
|---|---|---|
| Check happens | After render starts | Before loader runs |
| Flash of protected content | Possible | No |
| Auth logic location | Inside component | In the route definition |
| Apply to N routes | Copy-paste N times | One layout route |
| Redirect carries destination | Manual | Built-in with search params |
| TypeScript safety | Weak | Full context inference |
Use createRootRouteWithContext: Never pass auth as a prop. Route context is the right channel.
Carry the redirect destination: Always pass location.href in search params when redirecting to login. Users hate losing their place.
Call router.invalidate() after login/logout: This re-runs beforeLoad everywhere. Without it, the guard might use stale auth state.
Don't block on isLoading: If auth state is loading, return early from beforeLoad rather than redirecting. Let the pending UI handle it.
Separate auth from role checks: One layout for authentication, specific beforeLoad per route for role requirements. Mixing them creates confusion.
Component-level auth checks are a band-aid. They work, but they're fragile, repetitive, and too late—the routing has already happened by the time they run.
TanStack Router's beforeLoad moves protection where it belongs: the routing layer. Define it once in a layout route, and every child route inherits it automatically. No copy-paste. No forgotten checks. No flash of protected content.
That's what real route protection looks like.
Sources: