React Router Cheatsheet
Auth Patterns
Use this React Router reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Auth Patterns
In data mode, gate in the loader rather than in a component effect. The loader runs before render, so there is no flash of protected UI.
// A shared loader guard async function requireUser(request) { const user = await getUserFromSession(request); if (!user) { const url = new URL(request.url); throw redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`); } return user; } const router = createBrowserRouter([ { path: "/dashboard", element: <DashboardLayout />, loader: async ({ request }) => { const user = await requireUser(request); return { user }; }, children: [ { index: true, element: <Overview />, loader: overviewLoader }, ], }, ]);
Because the parent's loader throws a redirect before the children render, one guard on a layout route protects the whole subtree.
// The login action, honoring ?next= export async function action({ request }) { const form = await request.formData(); const user = await verify(form.get("email"), form.get("password")); if (!user) return data({ error: "Invalid credentials" }, { status: 401 }); const url = new URL(request.url); const next = url.searchParams.get("next") ?? "/dashboard"; // Only allow same-origin, path-only redirects const safe = next.startsWith("/") && !next.startsWith("//") ? next : "/dashboard"; return redirect(safe, { headers: { "Set-Cookie": await commitSession(user) } }); }
That safe check matters. Redirecting to an unvalidated ?next= value is an open redirect, and it is one of the most common vulnerabilities in login flows.
Declarative Mode Guard
Without a data router there are no loaders, so guard during render.
function RequireAuth({ children }) { const { user, loading } = useAuth(); const location = useLocation(); if (loading) return <Spinner />; if (!user) return <Navigate to="/login" replace state={{ from: location }} />; return children; } <Route path="/dashboard" element={<RequireAuth><DashboardLayout /></RequireAuth>} > <Route index element={<Overview />} /> </Route>
The loading check is essential. Without it, the first render sees user === null while the session is still being checked and bounces an authenticated user to the login page.
Role-Based Access
function RequireRole({ role, children }) { const user = useRouteLoaderData("root")?.user; if (!user) return <Navigate to="/login" replace />; if (!user.roles.includes(role)) throw new Response("Forbidden", { status: 403 }); return children; }
Client-side checks are UI, not security. Every protected loader and action must re-verify authorization on the server, because a determined user can call your endpoints directly.
Handling Session Expiry
// In a loader, when the API says the session is gone const res = await fetch("/api/data", { headers: authHeaders() }); if (res.status === 401) throw redirect("/login?expired=1"); if (!res.ok) throw new Response("Upstream error", { status: 502 }); return res.json();
Logout
// A logout must be a POST, never a GET link export async function action({ request }) { return redirect("/", { headers: { "Set-Cookie": await destroySession(request) } }); } function LogoutButton() { return ( <Form method="post" action="/logout"> <button>Sign out</button> </Form> ); }
A <Link to="/logout"> is a GET, and browsers, extensions, and link prefetchers all follow GETs speculatively. That is how users get randomly signed out.
Auth Summary
| Need | Data mode | Declarative mode |
|---|---|---|
| Gate a subtree | loader on the layout route, throw redirect() | A <RequireAuth> wrapper element |
| Read the user | useRouteLoaderData("root") | Your own context |
| Sign in | action + Set-Cookie + redirect | Call your API, then navigate |
| Sign out | <Form method="post"> to a logout action | POST, then navigate |
| Refresh after auth change | Automatic revalidation | Refetch by hand |
| Authorize a mutation | Re-check in the action, always | Check on your server, always |