Protected Routes
Most applications have areas that only signed-in users should reach: a dashboard, account settings, an admin panel. A protected route is one that checks an authentication condition before rendering and, if the check fails, redirects the visitor somewhere safe — usually a login page. Done well, this guard also remembers where the user was headed so they land back there after signing in, and it can extend beyond “are you logged in?” to “are you allowed?” with role-based access. This page shows how to build that guard with React Router in a way that is composable and easy to reason about.
How a guard works
A route guard is just a component that decides whether to render its children or redirect. React Router gives you two tools for this: the <Navigate> component for declarative redirects, and <Outlet /> so a single guard can wrap many routes. The guard reads the current auth state, and based on it either renders the protected content or sends the user to /login. Because it lives in the route tree, every child route inherits the protection automatically.
The other half is preserving the intended destination. When you redirect to login, you stash the location the user wanted in router state (or a query string), so after a successful login you can send them straight back instead of dumping them on a generic home page.
A reusable auth context
First, model auth state in a context so any component can read it. In a real app login would call your API; here it is simplified but fully runnable.
// auth/AuthContext.jsx
import { createContext, useContext, useState } from "react";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
async function login(email) {
// Replace with a real API call returning a user + roles.
const signedIn = { email, roles: ["user"] };
setUser(signedIn);
return signedIn;
}
function logout() {
setUser(null);
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
Building the guard
The guard reads user from context. If there is no user, it redirects to /login and records the current location in route state. The replace prop keeps the login redirect out of the history stack so the back button behaves sensibly.
// auth/RequireAuth.jsx
import { Navigate, useLocation, Outlet } from "react-router-dom";
import { useAuth } from "./AuthContext.jsx";
export default function RequireAuth() {
const { user } = useAuth();
const location = useLocation();
if (!user) {
// Remember where the user wanted to go.
return <Navigate to="/login" replace state={{ from: location }} />;
}
// Authenticated: render the matched child route.
return <Outlet />;
}
Wiring it into the route tree
Wrap protected routes inside a pathless guard route. Public routes sit outside it.
// App.jsx
import { Routes, Route } from "react-router-dom";
import RequireAuth from "./auth/RequireAuth.jsx";
import Home from "./pages/Home.jsx";
import Login from "./pages/Login.jsx";
import Dashboard from "./pages/Dashboard.jsx";
import Settings from "./pages/Settings.jsx";
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
{/* Everything below requires authentication */}
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
);
}
Redirecting back after login
The login page reads the saved location and navigates there once authentication succeeds. If there is no saved location, fall back to a default.
// pages/Login.jsx
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext.jsx";
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const from = location.state?.from?.pathname || "/dashboard";
async function handleSubmit(event) {
event.preventDefault();
await login(email);
navigate(from, { replace: true });
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
/>
<button type="submit">Sign in</button>
</form>
);
}
A user who hits /settings while logged out is bounced to /login; after signing in, navigate(from) returns them to /settings.
Output:
1. Visit /settings (logged out) -> redirected to /login
2. Submit login form -> auth state updated
3. navigate("/settings") -> lands on the originally requested page
Role-based access
Authentication answers “who are you?”; authorization answers “what may you do?” Extend the same pattern with a guard that checks roles, redirecting to a “forbidden” page when the user lacks permission.
// auth/RequireRole.jsx
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "./AuthContext.jsx";
export default function RequireRole({ allow }) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />;
if (!allow.some((role) => user.roles.includes(role))) {
return <Navigate to="/forbidden" replace />;
}
return <Outlet />;
}
// App.jsx (admin section)
<Route element={<RequireRole allow={["admin"]} />}>
<Route path="/admin" element={<AdminPanel />} />
</Route>
| Concern | Guard | Failure redirect |
|---|---|---|
| Signed in? | RequireAuth | /login (remembers origin) |
| Has a role? | RequireRole | /forbidden |
| Already signed in? | reverse guard | /dashboard |
The client-side guard is a UX convenience, not a security boundary. Anyone can edit the bundle, so always enforce authentication and authorization on the server for every protected request.
Best Practices
- Put guards in the route tree as pathless wrapper routes so child routes inherit protection automatically.
- Use
<Navigate replace>for redirects to avoid polluting the browser history. - Preserve the intended destination in
location.stateand return the user there after login. - Separate authentication (
RequireAuth) from authorization (RequireRole) so each stays focused. - While auth state is still loading, render a spinner rather than redirecting, or you will bounce signed-in users to login on refresh.
- Never trust the client guard alone — validate the session and permissions on the server.