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.
Where the Check Belongs
In Framework mode every check runs on the server, in middleware, a
loader, or an action. A component check is a rendering detail, not a
guard, because the loader already sent the data by then.
| Layer | Runs | Use for |
|---|---|---|
middleware | Before the loaders of that route branch | Whole sections behind a login |
loader | Before the route renders | One route, plus loading the user |
action | On submit | Every write, no exceptions |
| Component | In the browser | Hiding UI, never for security |
Never trust anything the client sends about identity, and re-check in every action. A hidden button is not access control.
Session Storage
createCookieSessionStorage keeps the session in a signed cookie, so it
needs no database. Keep it in a .server.ts file so it can never reach
the client bundle.
// app/sessions.server.ts import { createCookieSessionStorage } from "react-router"; type SessionData = { userId: string }; type SessionFlashData = { error: string }; export const { getSession, commitSession, destroySession } = createCookieSessionStorage<SessionData, SessionFlashData>({ cookie: { name: "__session", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 30, secrets: [process.env.SESSION_SECRET!], }, });
| Cookie option | Why |
|---|---|
httpOnly: true | JavaScript cannot read it, blunts XSS token theft |
secure: true | HTTPS only, off in local dev |
sameSite: "lax" | Blocks most cross site POSTs, "strict" breaks OAuth returns |
path: "/" | Sent on every route |
maxAge | Seconds. Prefer it over expires, which is fixed at deploy time |
secrets: [new, old] | Signing keys. First signs, the rest still verify, so keys rotate |
| Storage helper | Backing store |
|---|---|
createCookieSessionStorage | The cookie itself, roughly 4 KB limit |
createMemorySessionStorage | Process memory, dev and tests only |
createFileSessionStorage | Disk, Node only |
createSessionStorage | Your own database, via CRUD callbacks |
Reading and Writing a Session
// app/auth.server.ts import { redirect } from "react-router"; import { getSession } from "./sessions.server"; export async function requireUser(request: Request) { const session = await getSession(request.headers.get("Cookie")); const userId = session.get("userId"); if (!userId) { const url = new URL(request.url); const redirectTo = url.pathname + url.search; throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`); } const user = await db.user.find(userId); if (!user) throw redirect("/login"); return user; }
// app/routes/dashboard.tsx export async function loader({ request }: Route.LoaderArgs) { const user = await requireUser(request); return { user, invoices: await db.invoice.forUser(user.id) }; }
Throwing is what makes this safe. The loader body after the call never runs, so there is no early return to forget.
| Session method | Does |
|---|---|
session.get(key) | Read a value |
session.set(key, value) | Write a value |
session.has(key) | Presence check |
session.unset(key) | Remove a value |
session.flash(key, value) | Write a value that is deleted on first read |
session.id | The session id, empty for cookie storage |
Login and Logout
// app/routes/login.tsx import { data, redirect } from "react-router"; import { getSession, commitSession } from "../sessions.server"; import type { Route } from "./+types/login"; export async function action({ request }: Route.ActionArgs) { const session = await getSession(request.headers.get("Cookie")); const form = await request.formData(); const user = await verifyLogin( String(form.get("email")), String(form.get("password")) ); if (!user) { return data({ error: "Bad credentials" }, { status: 401 }); } session.set("userId", user.id); const raw = String(form.get("redirectTo") ?? "/dashboard"); const to = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/dashboard"; return redirect(to, { headers: { "Set-Cookie": await commitSession(session) }, }); }
// app/routes/logout.tsx, POST only export async function action({ request }: Route.ActionArgs) { const session = await getSession(request.headers.get("Cookie")); return redirect("/", { headers: { "Set-Cookie": await destroySession(session) }, }); } export function loader() { return redirect("/"); }
Logout is a POST action, never a <Link to="/logout">. A GET logout gets
triggered by any link prefetch or crawler, and it is trivially CSRF-able.
- Validate redirectTo yourself. A raw ?redirectTo=https://evil.com is
an open redirect.
- Rotate the session value on privilege changes so an old cookie cannot be
replayed.
- Set allowedActionOrigins in react-router.config.ts to restrict which
origins may submit to your UI route actions.
Passing the User Down
Middleware plus context replaces calling requireUser in every child
loader of a protected section.
// app/routes/dashboard/layout.tsx import { createContext, redirect } from "react-router"; import type { Route } from "./+types/layout"; export const userContext = createContext<User>(); export const middleware: Route.MiddlewareFunction[] = [ async ({ request, context }, next) => { const session = await getSession(request.headers.get("Cookie")); const user = await db.user.find(session.get("userId")); if (!user) throw redirect("/login"); context.set(userContext, user); return next(); }, ];
// any child route in that branch export async function loader({ context }: Route.LoaderArgs) { const user = context.get(userContext); return { invoices: await db.invoice.forUser(user.id) }; }
For rendering, put the user on a root loader and read it with
useRouteLoaderData("root"), so the nav knows who is signed in without a
second request.