Protected routes restrict access to pages that require authentication — the dashboard, post editor, and profile settings should only be accessible to logged-in users. React Router does not have built-in route protection, but the pattern is straightforward: a wrapper component checks authentication and either renders the protected page or redirects to login. The <Navigate> component performs the redirect, and the current URL is saved so users can return to their intended destination after logging in.
ProtectedRoute Component
// src/components/auth/ProtectedRoute.jsx
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuthStore } from "@/stores/authStore";
export default function ProtectedRoute() {
const { isLoggedIn, isLoading } = useAuthStore();
const location = useLocation();
// While checking auth status (e.g., validating stored token), show nothing
if (isLoading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin h-8 w-8 border-2 border-blue-500 rounded-full border-t-transparent" />
</div>
);
}
if (!isLoggedIn) {
// Redirect to login, preserving the intended destination in state
return (
<Navigate
to="/login"
state={{ from: location }} // ← save where the user was going
replace // ← replace history entry (no back to /dashboard)
/>
);
}
// Authenticated: render the child route
return <Outlet />;
}
// ── Public-only route: redirect logged-in users away from login/register ──────
export function PublicOnlyRoute() {
const { isLoggedIn, isLoading } = useAuthStore();
if (isLoading) return null;
if (isLoggedIn) {
return <Navigate to="/dashboard" replace />;
}
return <Outlet />;
}
Note: The
replace prop on <Navigate> replaces the current entry in the browser history rather than pushing a new one. Without replace, after logging in and being redirected to the dashboard, pressing the browser back button would take the user back to /dashboard (the redirect), which would redirect them to login again, creating an unbreakable loop. With replace, the history entry is /login → /dashboard instead of /dashboard → /login → /dashboard.Tip: Save the intended destination in
location.state (not in a cookie or localStorage) — it is temporary, scoped to the current navigation, and cleared automatically. After login, read it with const location = useLocation(); const from = location.state?.from?.pathname ?? "/dashboard"; and navigate there. This way, a user who visits /dashboard/posts/new while logged out is taken back to /dashboard/posts/new after logging in, not to a generic home page.Warning: Protected route components must handle the loading state — the brief period when the app checks whether a stored token is valid. If you render the protected page before confirming authentication (or the redirect before confirming non-authentication), the user sees a flash of protected content before being redirected, or a flash of the login page before being redirected to the dashboard. Always show a loading indicator during the auth check to prevent this flash.
Using ProtectedRoute in the Router
// src/App.jsx
import { Routes, Route } from "react-router-dom";
import ProtectedRoute, { PublicOnlyRoute } from "@/components/auth/ProtectedRoute";
import Layout from "@/components/layout/Layout";
import HomePage from "@/pages/HomePage";
import LoginPage from "@/pages/LoginPage";
import RegisterPage from "@/pages/RegisterPage";
import DashboardPage from "@/pages/DashboardPage";
import PostEditorPage from "@/pages/PostEditorPage";
import NotFoundPage from "@/pages/NotFoundPage";
export default function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
{/* Public routes */}
<Route index element={<HomePage />} />
<Route path="posts/:postId" element={<PostDetailPage />} />
{/* Public-only: redirect logged-in users away */}
<Route element={<PublicOnlyRoute />}>
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
</Route>
{/* Protected: redirect unauthenticated users to login */}
<Route element={<ProtectedRoute />}>
<Route path="dashboard" element={<DashboardPage />} />
<Route path="posts/new" element={<PostEditorPage />} />
<Route path="posts/:postId/edit" element={<PostEditorPage />} />
<Route path="profile" element={<ProfilePage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
}
Redirect Back After Login
// src/pages/LoginPage.jsx
import { useNavigate, useLocation } from "react-router-dom";
import { useAuthStore } from "@/stores/authStore";
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuthStore();
// Where to go after login — defaults to /dashboard
const from = location.state?.from?.pathname ?? "/dashboard";
async function handleSubmit(e) {
e.preventDefault();
const { email, password } = Object.fromEntries(new FormData(e.target));
try {
await login(email, password);
navigate(from, { replace: true }); // ✓ go to intended destination
} catch (err) {
setError(err.message);
}
}
// ...
}
Common Mistakes
Mistake 1 — No loading state in protected route (flash of content)
❌ Wrong — renders redirect before auth check completes:
function ProtectedRoute() {
const { isLoggedIn } = useAuthStore(); // initially false during loading!
if (!isLoggedIn) return <Navigate to="/login" />; // flashes login redirect
return <Outlet />;
}
✅ Correct — check isLoading before redirecting.
Mistake 2 — Not using replace on Navigate (back-button loop)
❌ Wrong — creates a redirect loop with the back button.
✅ Correct — always use replace when redirecting from auth routes.
Quick Reference
| Task | Code |
|---|---|
| Redirect in JSX | <Navigate to="/login" replace /> |
| Save destination | state={{ from: location }} on Navigate |
| Read destination | location.state?.from?.pathname ?? "/dashboard" |
| Wrap protected routes | Nest inside <Route element={<ProtectedRoute />}> |
| Render child routes | <Outlet /> inside ProtectedRoute |