This is an updated rewrite of my 2021 article on protected routes. A lot has changed in the React ecosystem since then. React Router moved from v5 to v7, class components have faded out, and the patterns we use for authentication state have matured. This version reflects how protected routes are built in modern React applications.
Almost every web application requires some form of authentication to prevent unauthorized users from accessing parts of the application meant for signed-in users only.
In this tutorial, I'll show how to set up an authentication flow and protect routes from unauthorized access using modern React patterns: function components, hooks, React Router v6+, and the Context API.
First things first
Install the dependency:
npm i react-router-dom
That's it. React Router v6 and above ships as a single package, so you no longer need to install react-router and react-router-dom separately.
It is worthy of note that we will not be using Redux for authentication state in this version. For something as simple as "is the user logged in?", React's built-in Context API is the standard approach today. Redux still has its place, but it is overkill here.
The Auth Context
Instead of writing to localStorage directly from components and reading it in random places, we centralize authentication state in a context. This gives us a single source of truth and a clean useAuth() hook we can call anywhere in the app.
Create ./src/auth/AuthContext.jsx:
import { createContext, useContext, useState } from "react";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(() => {
// Rehydrate on page refresh
const saved = localStorage.getItem("user");
return saved ? JSON.parse(saved) : null;
});
const login = async (username, password) => {
// In a real app, this is an API call to your backend.
// We simulate it here with hardcoded credentials.
if (username.toLowerCase() === "admin" && password === "123456") {
const loggedInUser = { name: "Admin" };
setUser(loggedInUser);
localStorage.setItem("user", JSON.stringify(loggedInUser));
return { success: true };
}
return { success: false, message: "Invalid username/password" };
};
const logout = () => {
setUser(null);
localStorage.removeItem("user");
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
A few things to note here:
- The
useStateinitializer reads fromlocalStorageonce, so the user stays logged in across page refreshes. -
loginandlogoutare the only functions allowed to touch storage. Components never do it directly. - The
useAuthhook throws if used outside the provider, which catches wiring mistakes early.
The Protected Route component
In v6 and v7, <Redirect> is gone, replaced by <Navigate>, and routes render elements directly. The modern pattern is a layout route that renders an <Outlet /> for its children when the user is authenticated.
Create ./src/auth/ProtectedRoute.jsx:
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "./AuthContext";
export default function ProtectedRoute() {
const { user } = useAuth();
const location = useLocation();
if (!user) {
// Remember where the user was heading so we can
// send them back there after they sign in
return <Navigate to="/signin" state={{ from: location }} replace />;
}
return <Outlet />;
}
Three improvements over the old version:
-
<Outlet />instead of a component prop. OneProtectedRoutecan now guard any number of child routes. No more passing components around as props. -
state={{ from: location }}records the page the user tried to visit, so after login we can redirect them back instead of always dumping them on the homepage. -
replacekeeps the redirect out of the browser history, so the back button behaves sanely.
The Signin component
Create ./src/views/Signin.jsx:
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export default function Signin() {
const [formData, setFormData] = useState({ username: "", password: "" });
const [error, setError] = useState("");
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// Where the user was trying to go before being redirected here
const from = location.state?.from?.pathname || "/";
const handleInputChange = (e) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.username || !formData.password) {
setError("Empty username/password field");
return;
}
const result = await login(formData.username, formData.password);
if (result.success) {
navigate(from, { replace: true });
} else {
setError(result.message);
}
};
return (
<div className="signin-container">
<h2>Sign In</h2>
<p className="hint">
Demo credentials — username: <code>admin</code>, password:{" "}
<code>123456</code>
</p>
{error && <p className="error">{error}</p>}
<form onSubmit={handleSubmit}>
<input
type="text"
name="username"
placeholder="Username"
value={formData.username}
onChange={handleInputChange}
/>
<input
type="password"
name="password"
placeholder="Password"
value={formData.password}
onChange={handleInputChange}
/>
<button type="submit">Sign In</button>
</form>
</div>
);
}
Notice we use useNavigate() for redirection. It does a proper client-side navigation.
The Home component
Create ./src/views/Home.jsx:
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export default function Home() {
const [users, setUsers] = useState([]);
const { user, logout } = useAuth();
const navigate = useNavigate();
useEffect(() => {
const controller = new AbortController();
fetch("https://jsonplaceholder.typicode.com/users", {
signal: controller.signal,
})
.then((res) => res.json())
.then(setUsers)
.catch((err) => {
if (err.name !== "AbortError") console.error(err);
});
return () => controller.abort();
}, []);
return (
<div className="home-container">
<header>
<h1>Welcome, {user.name}</h1>
<button onClick={logout}>Logout</button>
</header>
<ul className="user-list">
{users.map((u) => (
<li key={u.id}>
<strong>{u.name}</strong>
<span>{u.email}</span>
</li>
))}
</ul>
<button onClick={() => navigate("dashboard")}>
Go to Dashboard
</button>
</div>
);
}
-
useEffecthandles data fetching The
AbortControllercleanup prevents state updates if the component unmounts before the request finishes.We call
logout()from the context, theuserstate becomesnull, andProtectedRoutereacts automatically by redirecting to the signin page. The auth state drives the routing, not the other way round.
The Dashboard component
To show the layout route pattern in action with multiple pages, let's add a Dashboard view.
Create ./src/views/Dashboard.jsx:
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
export default function Dashboard() {
const { user, logout } = useAuth();
const navigate = useNavigate();
return (
<div className="home-container">
<header>
<h1>{user.name} Dashboard</h1>
<button onClick={logout}>Logout</button>
</header>
<div>
<h3>Dashboard here</h3>
</div>
<button onClick={() => navigate("/")}>
Go Home
</button>
</div>
);
}
This requires zero changes to ProtectedRoute — nesting it under the same guard in App.jsx is all it takes. Any number of views can share the same authentication wrapper this way.
Wiring it all together in App.jsx
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AuthProvider } from "./auth/AuthContext";
import ProtectedRoute from "./auth/ProtectedRoute";
import Home from "./views/Home";
import Signin from "./views/Signin";
import Dashboard from "./views/Dashboard";
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/signin" element={<Signin />} />
{/* Everything nested here requires authentication */}
<Route element={<ProtectedRoute />}>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
{/* Add more protected routes here */}
{/* Redirects every other path to the homepage */}
<Route path="*" element={<Home />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
The catch-all path="*" at the end is also worth noting: because it lives inside the ProtectedRoute wrapper, unknown paths will redirect unauthenticated visitors to sign in first, then land them on Home after login — instead of showing a 404.
Public Repository
Here is a link to the code on Github
Test run the application
When the application is launched and you visit /, the ProtectedRoute finds no user and redirects you to /signin. The sign-in form displays the demo credentials as a hint, so you don't have to look them up.
After a successful login (username: admin, password: 123456), you are redirected back to the page you originally tried to visit. Try navigating directly to http://localhost:3000/dashboard in a fresh private window — you will be sent to sign in first, then land on the Dashboard after authenticating.
From Home, click Go to Dashboard to navigate to /dashboard. From Dashboard, click Go Home to return. Both transitions are client-side with no page reload.
Try any made-up path like /foo — the catch-all route inside ProtectedRoute will redirect unauthenticated visitors to sign in, and authenticated users to the Home page.
Click Logout from either page, and you are immediately back on the signin page.
An important note on security
The most upvoted comment on the original article asked a great question: what stops an attacker from opening dev tools and setting the auth value in localStorage themselves?
The honest answer is: nothing, and that is fine, because client-side route protection is a UX feature, not a security boundary. Anything shipped to the browser can be manipulated by the user. The actual security must live on your backend:
- Protected routes hide pages from users who shouldn't see them, providing a clean experience.
- The real data behind those pages must come from API endpoints that validate a session cookie or token on the server with every request.
If an attacker fakes the localStorage value, they will see an empty dashboard shell, because every API call will return 401 Unauthorized. In production, prefer httpOnly cookies for session tokens over localStorage, since httpOnly cookies cannot be read by JavaScript at all, which protects you against XSS token theft.
Conclusion
Here is a summary of what changed from the 2021 version:
| 2021 (React Router v5) | 2026 (React Router v6/v7) |
|---|---|
<Redirect to="/signin" /> |
<Navigate to="/signin" replace /> |
Render props on <Route>
|
element prop and <Outlet />
|
| One guard per route | One guard wrapping many nested routes |
window.location.pathname = "/" |
useNavigate() |
componentDidMount |
useEffect |
Raw localStorage flag checked everywhere |
AuthContext + useAuth() hook |
I hope this is helpful to someone. Like, share and bookmark. :)


Top comments (0)