Protecting routes in a single-page app is essential when parts of your UI must only be available to authenticated users. This article shows a simple, pragmatic setup for React Router v6+ that scales from small apps to production-ready patterns (redirects, role checks, persisted auth, and lazy loading).
Why protected routes?
Protected routes keep unauthorized users out of certain views—dashboards, account settings, admin panels—while letting you control where users go after login. With React Router v6, navigation and redirects are declarative, and the library’s hooks make implementing guards straightforward.
Core concepts
- Authentication state: where you keep whether the user is logged in and any metadata (roles, token).
- Route guard: a component or function that decides whether to render the requested route or redirect to login.
- Redirect with intent: remember the page a user wanted and send them back after successful login.
Minimal auth context (TypeScript)
Below is a small AuthContext that stores a token and user info. It exposes signIn/signOut helpers and a boolean isAuthenticated.
// src/auth/AuthContext.tsx
import React, { createContext, useContext, useState } from "react";
type User = { id: string; name: string; role?: string } | null;
type AuthContextType = {
user: User;
token: string | null;
signIn: (token: string, user: User) => void;
signOut: () => void;
isAuthenticated: boolean;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [token, setToken] = useState<string | null>(() => localStorage.getItem("token"));
const [user, setUser] = useState<User>(() => {
const raw = localStorage.getItem("user");
return raw ? JSON.parse(raw) : null;
});
const signIn = (newToken: string, newUser: User) => {
setToken(newToken);
setUser(newUser);
localStorage.setItem("token", newToken);
localStorage.setItem("user", JSON.stringify(newUser));
};
const signOut = () => {
setToken(null);
setUser(null);
localStorage.removeItem("token");
localStorage.removeItem("user");
};
const value: AuthContextType = {
user,
token,
signIn,
signOut,
isAuthenticated: Boolean(token),
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
return ctx;
};
Simple ProtectedRoute using Navigate (React Router v6)
This component checks auth and either renders its children or redirects to /login. The current location is preserved so we can return post-login.
// src/routes/ProtectedRoute.tsx
import React from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export const ProtectedRoute: React.FC<{ children: JSX.Element }> = ({ children }) => {
const auth = useAuth();
const location = useLocation();
if (!auth.isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
Router setup with protected paths
Wrap the app with AuthProvider and guard routes where needed.
// src/App.tsx
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import { ProtectedRoute } from "./routes/ProtectedRoute";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
Login: redirecting back after auth
When navigating to /login, React Router will set location.state.from. Read that on login to redirect back.
// src/pages/Login.tsx
import React from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export default function Login() {
const auth = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as any)?.from?.pathname || "/";
const handleLogin = async () => {
// call your API -> get token + user
const fakeToken = "abc123";
const fakeUser = { id: "1", name: "Alice" };
auth.signIn(fakeToken, fakeUser);
navigate(from, { replace: true });
};
return (
<div>
<h2>Login</h2>
<button onClick={handleLogin}>Sign in</button>
</div>
);
}
Nested routes and Outlet-based guard
If you have many child routes under a protected layout (e.g., /app/*), it’s cleaner to protect the parent and render children via Outlet.
// src/routes/AppLayout.tsx
import React from "react";
import { Outlet, Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export default function AppLayout() {
const auth = useAuth();
const location = useLocation();
if (!auth.isAuthenticated) return <Navigate to="/login" state={{ from: location }} replace />;
return (
<div>
<nav>/* ... */</nav>
<main>
<Outlet />
</main>
</div>
);
}
Then in routes:
// inside <Routes>
// <Route path="/app" element={<AppLayout />}>
// <Route index element={<AppDashboard />} />
// <Route path="settings" element={<Settings />} />
// </Route>
Role-based guards
Sometimes you need to allow only users with certain roles. Extend the pattern with a RoleGuard.
// src/routes/RoleGuard.tsx
import React from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export const RoleGuard: React.FC<{ role: string; children: JSX.Element }> = ({ role, children }) => {
const { user } = useAuth();
if (!user || user.role !== role) return <Navigate to="/unauthorized" replace />;
return children;
};
Usage:
// <Route path="/admin" element={<RoleGuard role="admin"><AdminPanel /></RoleGuard>} />
Tips for production
- Token refresh: handle token expiry and silent refresh; store minimal data in localStorage and refresh tokens securely.
- Protect API calls: a protected route in the UI is not enough—enforce auth server-side for APIs.
- Avoid storing sensitive data in localStorage if XSS is a concern—consider httpOnly cookies.
- Loading state: while you check an existing session (e.g., validate token on app load), render a loading indicator instead of redirecting immediately.
- Error states: show clear unauthorized/forbidden screens for better UX.
Testing protected routes
- Unit test ProtectedRoute: mock useAuth to return isAuthenticated true/false and assert rendering vs redirect behavior.
- Integration (E2E): simulate unauthenticated user trying to access a protected URL, confirm redirect to login and correct final redirect.
Summary
Protected routes with React Router v6 are straightforward: maintain an auth state (context or global store), create a small guard component that uses Navigate and useLocation, and wire routes so unauthorized users are navigated to /login while preserving the intended destination. From there you can add role checks, token persistence, and refresh logic to make the system robust.
If you want, I can provide a full starter repo template (TypeScript + React Router v6 + simple fake API) you can clone and run.
