Protected Routes and Navigation Guards

Some pages in the MERN Blog โ€” creating a post, editing a post, viewing the dashboard โ€” should only be accessible to authenticated users. If an unauthenticated user tries to visit /posts/new they should be redirected to the login page, not shown an error or a blank page. This is a protected route, also called a navigation guard. React Router does not include this feature out of the box โ€” you build it as a wrapper component that checks the auth state and either renders the protected content or redirects. In this lesson you will build a reusable ProtectedRoute component that handles all edge cases correctly, including the loading state while auth status is being verified.

The ProtectedRoute Pattern

Request: User navigates to /posts/new

ProtectedRoute checks auth state:
  โ”œโ”€โ”€ Loading (checking token/session)
  โ”‚   โ””โ”€โ”€ Show spinner โ€” do not redirect yet (would flash login page)
  โ”œโ”€โ”€ Authenticated (user is logged in)
  โ”‚   โ””โ”€โ”€ Render the protected page (CreatePostPage)
  โ””โ”€โ”€ Not authenticated
      โ””โ”€โ”€ Redirect to /login
          โ””โ”€โ”€ Pass the original URL as state so login can redirect back
Note: The loading state check in ProtectedRoute is critical. When the app first loads, it reads the JWT from localStorage and verifies it with the server. This takes a moment. If ProtectedRoute immediately redirects to login during this verification period, the user sees a flash of the login page even when they are actually logged in. Always show a spinner or null while auth is being determined, and only redirect once you know the user is definitely not authenticated.
Tip: Store the original requested URL as navigation state when redirecting to login: navigate('/login', { state: { from: location.pathname } }). After successful login, read this state and redirect the user back to where they originally wanted to go: navigate(location.state?.from || '/dashboard', { replace: true }). This creates a seamless “redirect after login” experience.
Warning: Client-side route protection is a UX feature, not a security boundary. Even if your React app hides the Create Post page from unauthenticated users, a determined user could bypass the protection by calling the Express API directly. Real security lives on the server โ€” your Express routes must always verify the JWT token regardless of what the React client does. Protected routes just improve the user experience.

Building the ProtectedRoute Component

// src/components/auth/ProtectedRoute.jsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/context/AuthContext'; // covered in Chapter 21
import Spinner from '@/components/ui/Spinner';

function ProtectedRoute({ children, requiredRole }) {
  const { user, loading } = useAuth();
  const location = useLocation();

  // While auth state is loading โ€” show spinner, do not redirect yet
  if (loading) {
    return <Spinner message="Checking authentication..." />;
  }

  // Not authenticated โ€” redirect to login with the original URL in state
  if (!user) {
    return (
      <Navigate
        to="/login"
        state={{ from: location.pathname }}
        replace  // replace prevents the /login entry from appearing in history
      />
    );
  }

  // Optional role check โ€” redirect if user lacks required role
  if (requiredRole && user.role !== requiredRole) {
    return <Navigate to="/dashboard" replace />;
  }

  // Authenticated (and has role if required) โ€” render the protected content
  return children;
}

export default ProtectedRoute;

Using ProtectedRoute in App.jsx

// src/App.jsx
import ProtectedRoute from '@/components/auth/ProtectedRoute';

function App() {
  return (
    <PageLayout>
      <Routes>
        {/* Public routes */}
        <Route path="/"          element={<HomePage />} />
        <Route path="/posts/:id" element={<PostDetailPage />} />
        <Route path="/login"     element={<LoginPage />} />
        <Route path="/register"  element={<RegisterPage />} />

        {/* Protected โ€” any authenticated user */}
        <Route
          path="/dashboard"
          element={
            <ProtectedRoute>
              <DashboardPage />
            </ProtectedRoute>
          }
        />
        <Route
          path="/posts/new"
          element={
            <ProtectedRoute>
              <CreatePostPage />
            </ProtectedRoute>
          }
        />
        <Route
          path="/posts/:id/edit"
          element={
            <ProtectedRoute>
              <EditPostPage />
            </ProtectedRoute>
          }
        />

        {/* Admin-only route */}
        <Route
          path="/admin"
          element={
            <ProtectedRoute requiredRole="admin">
              <AdminPage />
            </ProtectedRoute>
          }
        />

        {/* 404 */}
        <Route path="*" element={<NotFoundPage />} />
      </Routes>
    </PageLayout>
  );
}

Redirect Back After Login

// src/pages/LoginPage.jsx
import { useNavigate, useLocation } from 'react-router-dom';

function LoginPage() {
  const navigate  = useNavigate();
  const location  = useLocation();
  const { login } = useAuth();

  // Where to go after login โ€” default to /dashboard
  const from = location.state?.from || '/dashboard';

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      await login(formData.email, formData.password);
      // Replace the history entry so the user cannot go "back" to the login page
      navigate(from, { replace: true });
    } catch (err) {
      setError(err.message);
    }
  };

  return (
    <div className="login-page">
      <h1>Log In</h1>
      {location.state?.from && (
        <p className="login-page__notice">
          Please log in to access {location.state.from}
        </p>
      )}
      <form onSubmit={handleSubmit}>...</form>
    </div>
  );
}

Guest-Only Routes โ€” Redirect if Already Logged In

// Prevent authenticated users from visiting /login or /register
function GuestRoute({ children }) {
  const { user, loading } = useAuth();

  if (loading) return <Spinner />;
  if (user)    return <Navigate to="/dashboard" replace />;
  return children;
}

// Usage
<Route path="/login"    element={<GuestRoute><LoginPage /></GuestRoute>} />
<Route path="/register" element={<GuestRoute><RegisterPage /></GuestRoute>} />

Common Mistakes

Mistake 1 โ€” Not handling the auth loading state

โŒ Wrong โ€” immediately redirecting before auth is checked:

function ProtectedRoute({ children }) {
  const { user } = useAuth();
  if (!user) return <Navigate to="/login" replace />;
  // If auth is still loading, user is null โ†’ always redirects on first load!
  return children;
}

โœ… Correct โ€” wait for loading to complete:

function ProtectedRoute({ children }) {
  const { user, loading } = useAuth();
  if (loading) return <Spinner />;    // โœ“ wait โ€” do not redirect yet
  if (!user)   return <Navigate to="/login" replace />;
  return children;
}

Mistake 2 โ€” Not using replace on login redirect

โŒ Wrong โ€” login page remains in browser history:

navigate('/dashboard'); // user can press Back and return to login page

โœ… Correct โ€” replace the history entry:

navigate('/dashboard', { replace: true }); // โœ“ Back button skips login page

Mistake 3 โ€” Relying only on frontend protection for sensitive data

โŒ Wrong โ€” Express API routes with no authentication middleware:

router.delete('/api/posts/:id', deletePost); // no protect middleware!

โœ… Correct โ€” every protected API route must verify the JWT server-side:

router.delete('/api/posts/:id', protect, deletePost); // โœ“ JWT required

Quick Reference

Pattern Code
Basic protected route <ProtectedRoute><Page /></ProtectedRoute>
Role-restricted route <ProtectedRoute requiredRole="admin"><AdminPage /></ProtectedRoute>
Render-time redirect <Navigate to="/login" state={{ from: location.pathname }} replace />
Read redirect target after login location.state?.from || '/dashboard'
Navigate after login (no back) navigate(from, { replace: true })
Guest route (auth โ†’ redirect) Check user โ†’ <Navigate to="/dashboard" />

🧠 Test Yourself

A logged-in user visits /posts/new, gets redirected to /login briefly, then is sent to /dashboard instead of /posts/new. They are correctly authenticated. What is the most likely cause?