React Router 16 ⚛️ Route Guards — Protecting private routes
The previous chapters covered the data loaders, the actions, the error boundaries, and the pending states. This chapter covers the mechanism that prevents the unauthenticated user from accessing the private routes: the route guards. In the React Router, a route guard is not a single component or a specific API. It is a pattern built from the loader, the redirect, and the Outlet. The critical distinction is between the client-side guard (the render-time check in the component) and the server-side guard (the loader redirect). The loader redirect is the correct approach for the production: it runs before the component renders, it blocks the data loading, and it is consistent across the SSR and the CSR. The render-time guard (the <Navigate> in the component) is the convenient for the simple cases, but it renders the component first, which can leak the layout or the loading state before the redirect.
Key point: The route guard is the loader (or the beforeLoad) that checks the authentication and throws the redirect if the user is not authorized. The redirect is the Response that the router follows. The <Outlet> in the parent route renders the protected children only if the parent’s loader succeeds. The authentication state is the external dependency (the context, the query, the store). The useRouteLoaderData reads the auth state in the child. The parent guard must not leak the protected data in the error boundary.
Why route guards require a specific mental model
The render-time problem. The component renders first, then the effect runs, then the check happens. The user sees the flash of the protected page before the redirect. The useEffect with the navigate('/login') is the common pattern, and it is the wrong pattern: the protected layout renders, the data requests fire, and the unauthorized user sees the skeleton of the page they cannot access.
The loader problem. The loader runs before the component. The check in the loader throws the redirect before the render. The protected layout never renders. The data never fetches. The redirect is the Response with the Location header. The router follows it. The clean.
The <Outlet> problem. The parent route is the layout. The parent’s loader runs the check. The child routes are the protected. If the parent’s loader succeeds, the parent renders the <Outlet /> and the child renders. If the parent’s loader redirects, the <Outlet /> never renders. The parent is the guard, the children are the protected.
The context problem. The auth state is the external. The context in the createBrowserRouter({ context: { auth } }) is the dependency injection. The loader reads the context.auth. The root route must declare the context type. The auth store is the external, the router context is the bridge.
The return-to problem. The unauthenticated user is redirected to the login. After the login, the user should return to the page they wanted. The redirect includes the search param with the redirect URL. The login form reads the redirect param and navigates there after the successful login.
The trade-off. The loader guard is the correct for the production. The <Navigate> is the simpler for the small. The useEffect is the wrong. The exam tests the loader guard and the redirect.
a. The loader guard and the redirect
The parent route’s loader checks the auth and throws the redirect.
import { createBrowserRouter, redirect } from "react-router";
async function authLoader({ context, request }) {
const isAuthenticated = context.auth.isAuthenticated;
if (!isAuthenticated) {
const url = new URL(request.url);
const redirectTo = url.pathname + url.search;
throw redirect(`/login?redirect=${encodeURIComponent(redirectTo)}`);
}
return { user: context.auth.user };
}
const router = createBrowserRouter([
{
path: "/app",
loader: authLoader,
Component: ProtectedLayout,
children: [
{ path: "dashboard", Component: Dashboard },
{ path: "settings", Component: Settings },
],
},
{
path: "/login",
Component: Login,
},
]);
The authLoader runs before the ProtectedLayout renders. The unauthenticated user is redirected to the /login?redirect=%2Fapp%2Fdashboard. The redirect is the thrown Response. The router follows it.
The ProtectedLayout renders the <Outlet /> only if the loader succeeds.
import { Outlet } from "react-router";
function ProtectedLayout() {
return (
<div>
<Sidebar />
<main>
<Outlet />
</main>
</div>
);
}
The <Outlet /> is the placeholder for the protected children. The children never render if the loader throws the redirect.
b. The context and the auth state
The auth state is the external dependency. The createBrowserRouter context passes it.
const router = createBrowserRouter(
[
/* routes */
],
{
context: {
auth: {
isAuthenticated: false,
user: null,
},
},
}
);
The context is the object. The loader reads context.auth. The component reads it with the useRouteLoaderData or the useContext.
The root route declares the context type for the TypeScript.
const RootContext = createContext(null);
function RootRoute() {
return (
<RootContext.Provider value={/* auth state */}>
<Outlet />
</RootContext.Provider>
);
}
The alternative is the useLoaderData in the protected layout.
function ProtectedLayout() {
const { user } = useLoaderData();
return (
<div>
<Sidebar user={user} />
<Outlet />
</div>
);
}
The useLoaderData returns the loader result. The user is the authenticated user. The <Outlet /> renders the children. The children can read the parent’s loader data with the useRouteLoaderData("route-id").
c. The return-to and the login flow
The login form reads the redirect param and navigates there after the successful login.
import { useSearchParams, useNavigate } from "react-router";
function Login() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const redirectTo = searchParams.get("redirect") || "/app";
const handleSubmit = async (event) => {
event.preventDefault();
const formData = new FormData(event.target);
const success = await login(formData);
if (success) {
navigate(redirectTo, { replace: true });
}
};
return (
<form onSubmit={handleSubmit}>
<input name="username" />
<input name="password" type="password" />
<button type="submit">Login</button>
</form>
);
}
The useSearchParams reads the redirect. The navigate(redirectTo, { replace: true }) returns the user to the original page. The replace: true prevents the login page from being in the history.
The auth state update after the login.
const handleSubmit = async (event) => {
event.preventDefault();
const success = await login(formData);
if (success) {
queryClient.invalidateQueries({ queryKey: ["auth"] });
navigate(redirectTo, { replace: true });
}
};
The invalidateQueries refetches the auth query. The next navigation to the protected route runs the loader, which sees the authenticated state. The redirect is not triggered.
Complete Example Session
# ============================================
# PART 1: AUTH LOADER
# ============================================
async function authLoader({ context, request }) {
if (!context.auth.isAuthenticated) {
const url = new URL(request.url);
throw redirect(`/login?redirect=${encodeURIComponent(url.pathname)}`);
}
return { user: context.auth.user };
}
# ============================================
# PART 2: ROUTES
# ============================================
const router = createBrowserRouter([
{
path: "/app",
loader: authLoader,
Component: ProtectedLayout,
children: [{ path: "dashboard", Component: Dashboard }],
},
{ path: "/login", Component: Login },
]);
# ============================================
# PART 3: PROTECTED LAYOUT
# ============================================
function ProtectedLayout() {
return <div><Sidebar /><Outlet /></div>;
}
# ============================================
# PART 4: CONTEXT
# ============================================
createBrowserRouter(routes, { context: { auth } });
# ============================================
# PART 5: LOGIN FORM
# ============================================
const [params] = useSearchParams();
const redirectTo = params.get("redirect") || "/app";
# ============================================
# PART 6: LOGIN SUBMIT
# ============================================
await login(formData);
navigate(redirectTo, { replace: true });
# ============================================
# PART 7: REFETCH AUTH
# ============================================
queryClient.invalidateQueries({ queryKey: ["auth"] });
# ============================================
# PART 8: CHILD READ
# ============================================
const { user } = useLoaderData();
# ============================================
# PART 9: VERIFY
# ============================================
# Unauthenticated → redirect to /login?redirect=%2Fapp%2Fdashboard.
# After login → navigate back to the original page.
The nine parts trace the route guard: the auth loader, the routes, the protected layout, the context, the login form, the login submit, the auth refetch, the child read, and the verification.
Quick Reference
Guard Components
| Component | Purpose |
|---|---|
loader | Run before the render |
redirect | Navigate on the failure |
Outlet | Protected children |
useLoaderData | Auth state in the layout |
Auth Flow
| Step | Action |
|---|---|
| 1 | Unauthenticated → redirect |
| 2 | Login form reads the redirect |
| 3 | Successful login → navigate |
| 4 | Auth refetch |
| 5 | Protected loader passes |
Hooks
| Hook | Purpose |
|---|---|
useLoaderData | Loader result |
useRouteLoaderData | Parent loader data |
useSearchParams | Read the redirect |
useNavigate | Return the navigation |
Best Practices
✅ Do This:
if (!context.auth.isAuthenticated) throw redirect("/login");
<!-- Guard in the loader --> # ✅
<Outlet />
<!-- Render the protected children in the layout --> # ✅
navigate(redirectTo, { replace: true });
<!-- Return to the original page --> # ✅
❌ Don’t Do This:
# Use useEffect + navigate for the guard
<!-- The layout flashes before the redirect --> # ❌
# Check the auth in every protected component
<!-- Use the parent loader once --> # ❌
# Skip the return-to
<!-- The user goes to the home instead of the target --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Protected layout flashes | Guard in the component | Guard in the loader |
| Redirect loop | The login route is protected | Exclude the login |
| Return-to missing | No redirect param | Add the search param |
| Auth not updating | No refetch | invalidateQueries |
Outlet missing | No children render | Add the <Outlet /> |
Real-World Examples
1. Auth Loader
async function authLoader({ context }) {
if (!context.auth.isAuthenticated) throw redirect("/login");
return {};
}
2. Protected Route
{ path: "/app", loader: authLoader, Component: ProtectedLayout, children: [...] }
3. Protected Layout
function ProtectedLayout() { return <div><Sidebar /><Outlet /></div>; }
4. Redirect with Return
throw redirect(`/login?redirect=${encodeURIComponent(path)}`);
5. Login Form
const redirectTo = params.get("redirect") || "/app";
6. Login Submit
navigate(redirectTo, { replace: true });
7. Auth Refetch
queryClient.invalidateQueries({ queryKey: ["auth"] });
8. Read Auth
const { user } = useLoaderData();
9. Parent Loader Data
const data = useRouteLoaderData("app");
10. Exclude the Login
{ path: "/login", Component: Login }
Visual
Guard Flow
┌──────────────────────────────────────────────┐
│ Navigate to /app/dashboard │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ authLoader runs │
│ → isAuthenticated? │
└──────────┬───────────────────────┬───────────┘
│ YES │ NO
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Render layout │ │ throw redirect │
│ + Outlet │ │ → /login?redirect= │
└──────────────────────┘ └──────────────────────┘
Login Flow
┌──────────────────────────────────────────────┐
│ User submits login │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ login() succeeds │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ invalidateQueries(["auth"]) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ navigate(redirectTo, { replace: true }) │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Loader runs → isAuthenticated → render │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Guard location | loader (parent route) |
| Redirect | throw redirect(...) |
| Protected children | <Outlet /> |
| Auth state | Router context |
| Return-to | ?redirect=... |
| Return navigation | navigate(redirectTo, { replace: true }) |
| Auth refetch | invalidateQueries |
Key takeaways:
- The guard is the loader. The
loaderruns before the render. The unauthorized user is redirected before the protected layout or the data loads. The<Navigate>in the component is the render-time fallback; theuseEffectis the wrong. - The
redirectis the mechanism. Thethrow redirect("/login")is theResponsethat the router follows. Thesearchparam with the original URL enables the return-to. - The
<Outlet />is the protected boundary. The parent layout renders the<Outlet />only if the parent’s loader succeeds. The children never render if the loader redirects. - The auth state is the context. The
createBrowserRouter({ context: { auth } })is the dependency injection. The loader reads it. The component reads it with theuseLoaderData. - The return-to is the UX. The login form reads the
redirectparam and navigates there after the successful login. Thereplace: trueprevents the login from being in the history.
Remember: The guard is the loader. The redirect is the response. The Outlet is the boundary. The context is the auth. The return-to is the UX. The exam tests the loader guard and the redirect pattern.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!