React Login Series - Route Guards Auth Flow | Part 3b
Introduction
After our last post Post 3 State Hooks on State Hooks we set up our authContext for our different login form submissions.
Why Route Guards Are a Separate Concern
By the time an authentication flow reaches this point, most of the visible work is done:
- Forms validate correctly
- Password strength is enforced
- Success and error messages render as expected
But none of that actually prevents a user from typing a URL directly into the address bar.
If /profile is publicly accessible, the app looks authenticated — but it isn’t.
This is where route guarding comes in.
Route guards answer one question:
Who is allowed to access this route?
They are not:
- Form logic
- UI state
- Feature behavior
They are navigation policy.
Layouts vs Guards (The Mental Model)
This distinction is critical.
Layout
- Controls how a page looks
- Wraps UI
- Has no authority over access
Examples:
- Auth background
- Centered card
- Dark mode toggle
Guard
- Controls who may access a route
- Wraps navigation
- Has no concern for styling
Examples:
- Redirect unauthenticated users to
/login - Prevent logged-in users from seeing
/signup
Layouts wrap UI. Guards wrap access.
Once you separate these concerns, routing becomes composable instead of confusing.
The Three Route Types in an Auth System
Every auth flow naturally divides into three categories:
1. Public-only routes
Accessible only when logged out
/login/signup/reset/reset/confirm
2. Protected routes
Accessible only when authenticated
/profile/settings
3. Token-based routes
Accessible without auth, but validated inside the page
/confirm-email/reset/confirm?token=...
These routes should not be protected by auth state — they are guarded by tokens, not sessions.
Guard Components
Before moving further, lets grab the branch we checked in to get started from here. Post 3 State Hooks
ProtectedRoute (Auth Required)
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
/**
* 🔒 ProtectedRoute
*
* Allows access ONLY when the user is authenticated.
* Redirects unauthenticated users to /login.
*/
export default function ProtectedRoute() {
const { isAuthenticated, authReady } = useAuth();
// Prevent redirect flicker while auth initializes
if (!authReady) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}
PublicOnlyRoute (Auth NOT Allowed)
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
/**
* 🚫 PublicOnlyRoute
*
* Allows access ONLY when the user is NOT authenticated.
* Redirects logged-in users to /profile.
*/
export default function PublicOnlyRoute() {
const { isAuthenticated } = useAuth();
if (isAuthenticated) {
return <Navigate to="/profile" replace />;
}
return <Outlet />;
}
These components do one thing each. They don’t render pages — they enforce policy.
Feature-Based AuthLayout (Drop-In Design)
This project treats authentication as a feature, not a global concern.
That means:
- Auth pages bring their own layout
- The rest of the app is unaffected
- The feature can be dropped into another app cleanly
AuthLayout responsibility
- Background
- Card container
- Dark mode toggle
- Visual consistency for auth screens
It does not decide access.
Composing Layouts and Guards
React Router allows stacking behavior through nesting.
Here’s the final routing structure.
<Routes>
{/* AUTH FEATURE (public-only) */}
<Route element={<AuthLayout darkMode={darkMode} setDarkMode={setDarkMode} />}>
<Route element={<PublicOnlyRoute />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/reset" element={<RequestResetPage />} />
<Route path="/reset/confirm" element={<ConfirmResetPage />} />
<Route path="/confirm-email" element={<ConfirmEmailPage />} />
</Route>
</Route>
{/* APPLICATION (protected) */}
<Route element={<ProtectedRoute />}>
<Route path="/profile" element={<ProfilePage />} />
</Route>
{/* Fallback */}
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
Route Tree Diagram
This diagram shows how access flows through the app:
Routes
│
├─ AuthLayout
│ └─ PublicOnlyRoute
│ ├─ /login
│ ├─ /signup
│ ├─ /reset
│ ├─ /reset/confirm
│ └─ /confirm-email
│
├─ ProtectedRoute
│ └─ /profile
│
└─ * → /login
Each layer has exactly one responsibility.
Why This Architecture Scales
This approach makes future changes easy:
Add roles → AdminRoute
Add verification → VerifiedEmailRoute
Add new auth pages → no router rewrite
Remove auth feature → no app-wide refactor
It also mirrors how production teams structure large React apps.
Key Takeaways
- Route guards are navigation policy, not UI
- Layouts and guards should never be mixed
- Auth features can (and should) own their own layout
- Nesting routes is more powerful than conditional rendering
- Clean naming (ProtectedRoute, PublicOnlyRoute) matters
At this point, the app doesn’t just look authenticated — it behaves like one.
We have fully covered everything we need and layered the feature correctly before moving forwad with our next post. We hit our milestoe and can checkoff these items:
✅ UI composition (forms, fields, layouts) ✅ State hooks (derived state, guards, submit conditions) ✅ Auth context integration ✅ Route guards (public-only + protected) ✅ Layout vs guard separation ✅ Feature-based drop-in auth design
Review this code found in Github Post 3a Route Guard
Coming Next
In Post 4 API Decoupling , we’ll discuss the
- Why service layers matter
- API abstraction
- Environment decoupling
- Mocking backend behavior in tests
This is where frontend code starts to feel production-ready.
Enjoy Reading This Article?
Here are some more articles you might like to read next: