Protected Routes — Authentication-Based Navigation

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

🧠 Test Yourself

An unauthenticated user visits /dashboard/posts/new. They are redirected to /login. After logging in, where should they go and how do you send them there?