Code To Learn logo

Code To Learn

M7: Forms & Authentication

L13: Create Route Guard Component

Protect routes that require authentication

Let's build a reusable component to protect routes that require authentication! 🔒

Why Route Guards?

Problem without guards:

// Anyone can access favorites page
<Route path="/favorites" element={<FavoritesPage />} />

// Unauthenticated user visits /favorites
→ Page loads
→ API calls fail (no token)
→ Error messages everywhere
→ Poor user experience ❌

Solution with guards:

// Only authenticated users can access
<Route 
  path="/favorites" 
  element={<Protected><FavoritesPage /></Protected>} 
/>

// Unauthenticated user visits /favorites
→ Guard checks authentication
→ No user found
→ Redirect to /sign-in
→ After sign-in, redirect back to /favorites
→ Great user experience ✅

Understanding Route Protection

Three states to handle:

1. Loading (checking authentication)
   → Show loading spinner
   
2. Authenticated (user signed in)
   → Show protected component
   
3. Not authenticated (no user)
   → Redirect to sign-in page

Flow diagram:

User visits /favorites
  ↓
<Protected> component renders
  ↓
Is loading? → Yes → Show spinner
  ↓ No
Is authenticated? → Yes → Show FavoritesPage ✅
  ↓ No
Redirect to /sign-in ➡️
  ↓
Save intended destination (/favorites)
  ↓
After sign-in, redirect to /favorites

Create Protected Component

Create a reusable route guard:

src/components/Protected.jsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';

function Protected({ children }) {
  const { user, isLoading } = useAuth();
  const location = useLocation();

  // Still checking authentication
  if (isLoading) {
    return (
      <div className="loading-container">
        <div className="spinner"></div>
        <p>Verifying authentication...</p>
      </div>
    );
  }

  // Not authenticated, redirect to sign-in
  if (!user) {
    // Save the location they were trying to access
    return <Navigate to="/sign-in" state={{ from: location }} replace />;
  }

  // Authenticated, render protected content
  return children;
}

export default Protected;

Understanding the Code

React Router's <Navigate> component:

<Navigate to="/sign-in" />

What it does:

Renders <Navigate>
  ↓
Immediately redirects to /sign-in
  ↓
Component doesn't render children
  ↓
URL changes to /sign-in

Similar to:

// In a useEffect
useEffect(() => {
  navigate('/sign-in');
}, []);

// But Navigate is declarative (better)
return <Navigate to="/sign-in" />;

Navigate vs navigate():

// ✅ Navigate component (declarative)
if (!user) {
  return <Navigate to="/sign-in" />;
}
return <FavoritesPage />;

// ❌ navigate() in render (causes issues)
if (!user) {
  navigate('/sign-in');  // Don't do this!
}
return <FavoritesPage />;

// ✅ navigate() in event handler (correct use)
const handleClick = () => {
  navigate('/sign-in');
};

Why declarative is better:

Imperative (navigate):
  "When this condition is true, go to /sign-in"
  → Can cause side effects in render
  → React warns about this

Declarative (Navigate):
  "When this condition is true, the UI is a redirect"
  → Pure component behavior
  → React-friendly pattern

Passing state with navigation:

<Navigate 
  to="/sign-in" 
  state={{ from: location }} 
  replace 
/>

What state contains:

location = {
  pathname: '/favorites',
  search: '?sort=recent',
  hash: '#top',
  state: null,
  key: 'abc123'
};

// Passed as state to /sign-in
state = { 
  from: {
    pathname: '/favorites',
    search: '?sort=recent',
    // ...
  }
};

Access in SignInPage:

import { useLocation, useNavigate } from 'react-router-dom';

function SignInPage() {
  const location = useLocation();
  const navigate = useNavigate();

  // Get the page they were trying to access
  const from = location.state?.from?.pathname || '/';

  const handleSignIn = async () => {
    await signIn(email, password);
    
    // Redirect back to where they came from
    navigate(from, { replace: true });
  };

  return <SignInForm onSubmit={handleSignIn} />;
}

User flow:

1. User visits /favorites (not signed in)
2. Protected redirects to /sign-in with state={{ from: '/favorites' }}
3. User signs in
4. SignInPage reads state.from = '/favorites'
5. Navigates to /favorites
6. User sees favorites page ✅

Without state:

1. User visits /favorites
2. Redirects to /sign-in
3. User signs in
4. No idea where they wanted to go
5. Navigates to / (homepage)
6. User has to navigate to /favorites again 😞

Why check isLoading?

// Initial state
user: null
isLoading: true

// Problem without loading check
if (!user) {
  return <Navigate to="/sign-in" />;
}
// Always redirects during loading! ❌

// Solution: Wait for loading to complete
if (isLoading) {
  return <LoadingSpinner />;
}
if (!user) {
  return <Navigate to="/sign-in" />;
}
// Only redirects if actually not authenticated ✅

Timeline:

0ms: Component mounts
  isLoading: true
  user: null
  → Show loading spinner

500ms: Token fetch completes
  isLoading: false
  user: { data }
  → Show protected content

Alternative (no session):
500ms: Token fetch fails
  isLoading: false
  user: null
  → Redirect to /sign-in

Loading UI variations:

// Simple spinner
if (isLoading) {
  return <div className="spinner"></div>;
}

// Spinner with message
if (isLoading) {
  return (
    <div className="loading-container">
      <div className="spinner"></div>
      <p>Verifying authentication...</p>
    </div>
  );
}

// Skeleton screen (better UX)
if (isLoading) {
  return <FavoritesPageSkeleton />;
}

// Progress bar
if (isLoading) {
  return <LoadingBar progress={50} />;
}

Understanding children prop:

function Protected({ children }) {
  // children = whatever is inside <Protected>...</Protected>
  
  if (authenticated) {
    return children;  // Render the children
  }
  return <Navigate to="/sign-in" />;
}

How it works:

// JSX
<Protected>
  <FavoritesPage />
</Protected>

// Equivalent to
<Protected children={<FavoritesPage />} />

// Inside Protected component
const Protected = ({ children }) => {
  // children = <FavoritesPage />
  return children;  // Renders <FavoritesPage />
};

Multiple children:

<Protected>
  <h1>Title</h1>
  <p>Content</p>
  <FavoritesPage />
</Protected>

// children is an array:
// [<h1>Title</h1>, <p>Content</p>, <FavoritesPage />]

// Rendered as-is:
return children;
// All three elements render

Why children pattern?

// ❌ Without children (not reusable)
function ProtectedFavorites() {
  if (!user) return <Navigate to="/sign-in" />;
  return <FavoritesPage />;
}

function ProtectedProfile() {
  if (!user) return <Navigate to="/sign-in" />;
  return <ProfilePage />;
}
// Repeat for every protected page!

// ✅ With children (reusable)
function Protected({ children }) {
  if (!user) return <Navigate to="/sign-in" />;
  return children;
}

// Use anywhere
<Protected><FavoritesPage /></Protected>
<Protected><ProfilePage /></Protected>
<Protected><AnyPage /></Protected>

Add Styling for Loading State

Ensure loading spinner is styled:

src/app/global.css
/* Loading Container (full screen center) */
.loading-container {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1rem;
}

/* Spinner Animation */
.spinner {
  width: 48px;
  height: 48px;
  border: 4px solid #e5e7eb;
  border-top-color: #3b82f6;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

/* Loading Text */
.loading-container p {
  color: #6b7280;
  font-size: 1rem;
  margin: 0;
}

Usage Examples

Protect a single route:

src/App.jsx
import { Routes, Route } from 'react-router-dom';
import Protected from '@/components/Protected';
import HomePage from '@/pages/HomePage';
import FavoritesPage from '@/pages/FavoritesPage';
import SignInPage from '@/pages/SignInPage';

function App() {
  return (
    <Routes>
      {/* Public routes */}
      <Route path="/" element={<HomePage />} />
      <Route path="/sign-in" element={<SignInPage />} />
      
      {/* Protected routes */}
      <Route 
        path="/favorites" 
        element={
          <Protected>
            <FavoritesPage />
          </Protected>
        } 
      />
    </Routes>
  );
}

User flow:

Authenticated user:
  /favorites → Shows FavoritesPage ✅

Unauthenticated user:
  /favorites → Redirects to /sign-in ➡️

Protect multiple routes at once:

<Routes>
  {/* Public */}
  <Route path="/" element={<HomePage />} />
  <Route path="/sign-in" element={<SignInPage />} />
  
  {/* Protected group */}
  <Route element={<Protected><Outlet /></Protected>}>
    <Route path="/favorites" element={<FavoritesPage />} />
    <Route path="/bookings" element={<BookingsPage />} />
    <Route path="/profile" element={<ProfilePage />} />
  </Route>
</Routes>

All nested routes are protected:

/favorites → Protected
/bookings  → Protected
/profile   → Protected

With layout:

<Route element={<Protected><DashboardLayout /></Protected>}>
  <Route path="/dashboard" element={<DashboardHome />} />
  <Route path="/dashboard/settings" element={<Settings />} />
  <Route path="/dashboard/profile" element={<Profile />} />
</Route>

All dashboard routes share DashboardLayout and protection!

Protect parts of a component:

function HomePage() {
  return (
    <div className="home-page">
      <h1>Welcome to Holidaze</h1>
      
      {/* Public content */}
      <section className="venues">
        <VenueGrid venues={allVenues} />
      </section>
      
      {/* Protected content */}
      <Protected>
        <section className="personalized">
          <h2>Your Recommendations</h2>
          <VenueGrid venues={recommendedVenues} />
        </section>
      </Protected>
    </div>
  );
}

Behavior:

Unauthenticated:
  → Shows public content
  → Hides recommendations
  → Stays on same page

Authenticated:
  → Shows all content

Note: This redirects the whole page. For hiding content, use conditional rendering instead:

{user && (
  <section className="personalized">
    <h2>Your Recommendations</h2>
  </section>
)}

Multiple levels of protection:

// Admin protection
function AdminProtected({ children }) {
  const { user } = useAuth();
  
  if (!user) {
    return <Navigate to="/sign-in" />;
  }
  
  if (!user.isAdmin) {
    return <Navigate to="/" />;
  }
  
  return children;
}

// Usage
<Routes>
  {/* User must be signed in */}
  <Route 
    path="/dashboard" 
    element={<Protected><Dashboard /></Protected>} 
  />
  
  {/* User must be admin */}
  <Route 
    path="/admin" 
    element={
      <Protected>
        <AdminProtected>
          <AdminPanel />
        </AdminProtected>
      </Protected>
    } 
  />
</Routes>

Checks in order:

/admin
  ↓
Protected checks: Signed in? → Yes
  ↓
AdminProtected checks: Is admin? → Yes
  ↓
Render AdminPanel ✅

/admin (non-admin user)
  ↓
Protected checks: Signed in? → Yes
  ↓
AdminProtected checks: Is admin? → No
  ↓
Redirect to / ➡️

Testing Protected Routes

Test unauthenticated access:

  1. Ensure you're signed out
  2. Visit /favorites directly
  3. Should:
    • Show loading spinner briefly
    • Redirect to /sign-in
    • URL changes to /sign-in

✅ Redirect working!

Test authenticated access:

  1. Sign in
  2. Visit /favorites
  3. Should:
    • Show loading spinner briefly
    • Display FavoritesPage
    • Stay on /favorites

✅ Protected route accessible!

Test redirect back:

  1. Sign out
  2. Visit /favorites (gets redirected to /sign-in)
  3. Sign in
  4. Should automatically redirect to /favorites

✅ Redirect back working!

Test loading state:

  1. Slow down network in DevTools (Slow 3G)
  2. Refresh page on protected route
  3. Should see loading spinner longer
  4. Eventually shows page or redirects

✅ Loading state handled!

Advanced Patterns

What's Next?

In Lesson 14, we'll:

  1. Update all routes with protection
  2. Organize public vs protected routes
  3. Add proper navigation structure
  4. Handle edge cases

✅ Lesson Complete! Route guard component created!

Key Takeaways

  • ✅ <Protected> component wraps routes requiring authentication
  • ✅ <Navigate> component handles redirects declaratively
  • ✅ Location state preserves intended destination
  • ✅ Loading check prevents premature redirects
  • ✅ Children prop makes component reusable
  • ✅ replace prop prevents back-button issues
  • ✅ Role-based protection extends basic pattern
  • ✅ Nested protection allows multiple checks
  • ✅ Skeleton screens improve perceived performance
  • ✅ User experience is seamless with proper loading states