Protected Routes
Most apps have pages that should only be visible to authenticated users — dashboards, account settings, admin panels. Protected routes intercept navigation to these pages and redirect unauthenticated visitors to the login page. After login, the user is sent back to the page they originally tried to reach.
The AuthContext
Protected routes need a reliable source of auth state. Create an AuthContext that provides user, login, and logout to the entire app:
import { createContext, useContext, useState, ReactNode } from 'react'
interface User {
id: number
name: string
email: string
role: 'user' | 'admin'
}
interface AuthContextType {
user: User | null
login: (email: string, password: string) => Promise<void>
logout: () => void
isLoggedIn: boolean
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const login = async (email: string, password: string) => {
// Replace with your real API call
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) throw new Error('Invalid credentials')
const data = await res.json()
setUser(data.user)
}
const logout = () => {
setUser(null)
// Optionally clear tokens, call /api/auth/logout, etc.
}
return (
<AuthContext.Provider value={{ user, login, logout, isLoggedIn: !!user }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
return ctx
}ProtectedRoute Component
The ProtectedRoute wrapper checks auth state and either renders its children or redirects to /login. It stores the attempted URL in navigation state so the login page can redirect back after a successful sign-in:
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthContext'
interface ProtectedRouteProps {
children: React.ReactNode
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isLoggedIn } = useAuth()
const location = useLocation()
if (!isLoggedIn) {
// Preserve the intended destination in state
return (
<Navigate
to="/login"
state={{ from: location.pathname + location.search }}
replace
/>
)
}
return <>{children}</>
}Wiring Up Protected Routes
Wrap private routes with ProtectedRoute in your route tree. Public routes (login, register, marketing pages) sit outside the wrapper:
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from './AuthContext'
import { ProtectedRoute } from './ProtectedRoute'
function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
{/* Public routes — no auth required */}
<Route path="/" element={<Home />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/pricing" element={<Pricing />} />
{/* Protected routes — redirect to /login if not authenticated */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
</Routes>
</AuthProvider>
</BrowserRouter>
)
}// Cleaner: protect an entire section with one wrapper
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<LoginPage />} />
{/* All children of this route are protected */}
<Route
element={
<ProtectedRoute>
<AppShell /> {/* renders <Outlet /> inside */}
</ProtectedRoute>
}
>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
</Route>
</Routes>Login Page with Redirect Back
After a successful login, navigate the user back to the page they originally tried to visit. Read location.state.from and fall back to /dashboard if it is absent:
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthContext'
import { useState } from 'react'
function LoginPage() {
const navigate = useNavigate()
const location = useLocation()
const { login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
// Where to redirect after successful login
const from = (location.state as { from?: string })?.from ?? '/dashboard'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
await login(email, password)
// replace: true prevents the login page appearing in history
navigate(from, { replace: true })
} catch {
setError('Invalid email or password. Please try again.')
} finally {
setIsLoading(false)
}
}
return (
<div style={{ maxWidth: 400, margin: '80px auto' }}>
<h1>Sign In</h1>
{from !== '/dashboard' && (
<p>You must be logged in to view that page.</p>
)}
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit" disabled={isLoading}>
{isLoading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
)
}Role-Based Access Control
Some pages should only be accessible to users with a specific role (admin, moderator, subscriber). Extend ProtectedRoute to accept a required role:
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthContext'
interface RoleRouteProps {
children: React.ReactNode
requiredRole: 'user' | 'admin'
}
export function RoleRoute({ children, requiredRole }: RoleRouteProps) {
const { isLoggedIn, user } = useAuth()
const location = useLocation()
// Not logged in → go to login
if (!isLoggedIn) {
return <Navigate to="/login" state={{ from: location.pathname }} replace />
}
// Logged in but wrong role → show 403
if (user?.role !== requiredRole) {
return <Navigate to="/403" replace />
}
return <>{children}</>
}
// Usage in route tree:
<Route
path="/admin"
element={
<RoleRoute requiredRole="admin">
<AdminPanel />
</RoleRoute>
}
/>Handling the Loading State
If auth state is loaded asynchronously (from a cookie check or token refresh on mount), show a loading indicator while the check is in progress to avoid a flash of the redirect:
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isLoggedIn, isLoading } = useAuth()
const location = useLocation()
// Auth check in progress — render nothing (or a spinner)
if (isLoading) {
return <div>Checking authentication…</div>
}
if (!isLoggedIn) {
return (
<Navigate
to="/login"
state={{ from: location.pathname }}
replace
/>
)
}
return <>{children}</>
}Summary
Store auth state in a
Context(or Zustand) accessible to all routes.The
ProtectedRoutewrapper uses<Navigate replace>to redirect unauthenticated users.Store
location.pathnamein navigation state so login can redirect back.Use
replace: trueon all auth redirects to prevent back-button loops.Add a role check for admin/subscriber-only pages inside a
RoleRoutevariant.Guard the
isLoadingperiod to avoid flash-of-redirect on refresh.