ReactProtected Routes

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:

TSX
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
}
Note
Wrap your entire app (or at least the router) in AuthProvider so that ProtectedRoute components can read auth state via useAuth().
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:

TSX
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:

TSX
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>
  )
}
Tip
For many protected routes, nest them under a single layout Route that wraps the entire section in ProtectedRoute. You write the auth check once instead of on every single route.

TSX
// 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:

TSX
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:

TSX
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:

TSX
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}</>
}
Warning
Without the isLoading guard, the ProtectedRoute renders before the auth check completes, immediately redirecting the user to login — even if they have a valid session. Always wait for auth resolution before deciding to redirect.
Summary
  • Store auth state in a Context (or Zustand) accessible to all routes.

  • The ProtectedRoute wrapper uses <Navigate replace> to redirect unauthenticated users.

  • Store location.pathname in navigation state so login can redirect back.

  • Use replace: true on all auth redirects to prevent back-button loops.

  • Add a role check for admin/subscriber-only pages inside a RoleRoute variant.

  • Guard the isLoading period to avoid flash-of-redirect on refresh.