A few years ago I wrote about protected routes in React Router v6, and it’s still my most-read post. React Router v7 is now the standard, so here’s the updated version.

The good news: if you’re coming from v6, almost nothing breaks. The main change is that everything now imports from react-router instead of react-router-dom.
Install
npm install react-router
Step 1: An auth context
We need one place that knows whether the user is logged in.
// src/auth/AuthContext.tsx
import { createContext, useContext, useState, type ReactNode } from "react";
type User = { id: string; name: string; role: "user" | "admin" };type AuthContextType = {
user: User | null;
login: (user: User) => void;
logout: () => void;
};const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
return (
<AuthContext.Provider value={{ user, login: setUser, logout: () => setUser(null) }}>
{children}
</AuthContext.Provider>
);
}export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}Step 2: The protected route component
This is the same idea as v6. If there’s no user, redirect to login and remember where they were going.
// src/auth/ProtectedRoute.tsx
import { Navigate, Outlet, useLocation } from "react-router";
import { useAuth } from "./AuthContext";
export function ProtectedRoute({ role }: { role?: "admin" }) {
const { user } = useAuth();
const location = useLocation(); if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
if (role && user.role !== role) {
return <Navigate to="/unauthorized" replace />;
}
return <Outlet />;
}Step 3: Wrap your routes

// src/main.tsx
import { BrowserRouter, Routes, Route } from "react-router";
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
</Route> <Route element={<ProtectedRoute role="admin" />}>
<Route path="/admin" element={<Admin />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>After login, send the user back where they started:
const location = useLocation();
const navigate = useNavigate();
const from = location.state?.from?.pathname ?? "/dashboard";
function handleLogin(user: User) {
login(user);
navigate(from, { replace: true });
}The v7 way: redirect in a loader

If you use createBrowserRouter, you can check auth before the page even renders. Throw a redirect from the loader and the component never loads.
import { createBrowserRouter, redirect, RouterProvider } from "react-router";async function requireUser() {
const res = await fetch("/api/me", { credentials: "include" });
if (!res.ok) throw redirect("/login");
return res.json();
}const router = createBrowserRouter([
{ path: "/login", element: <Login /> },
{
path: "/dashboard",
loader: requireUser,
element: <Dashboard />,
},
]);
<RouterProvider router={router} />;Inside Dashboard, useLoaderData() gives you the user. No flash of protected content, no extra loading state.
React Router also has a middleware API for running auth checks across many routes. It started as an experimental feature, so check the docs for your exact version before using it.
One important warning
Protected routes are a UX feature, not security. Anyone can open DevTools and change client state. Your API must check the token or session on every request. The frontend guard just keeps people from seeing pages they can’t use.
Quick summary
- Import from react-router in v7
- Use an Outlet guard for simple apps
- Use loader redirects with createBrowserRouter to avoid content flashes
- Always enforce auth on the server too
Next up: storing the token safely with refresh tokens and httpOnly cookies.
