React Router
Guard routes in React Router's declarative, data or framework mode, with the session kept in the browser.
In this guide, you add React Router to the app from Getting started, in the mode your app uses. React Router has three modes, and Session kit guards each one the way that mode works:
| Mode | The guard | Beside it |
|---|---|---|
| Declarative | SessionOutlet | Nothing |
| Data | requireSession in loaders | SessionOutlet, useAuthRevalidation |
Framework, with ssr: false | requireSession in clientLoader | SessionOutlet, useAuthRevalidation |
Each mode has a complete example app. For framework mode with server rendering, follow React Router with server rendering instead.
Before you begin
Follow Getting started through step 4. This guide
uses its client in src/auth.ts, its hooks in src/hooks.ts, and its form in
src/sign-in.tsx.
Installation
To install the packages, run the following command:
npm install @monarcode/session-kit react-router zodReact Router 7 and 8 both work.
Add a page to protect
In every mode, AuthProvider goes around the router. It mounts the client in
a layout effect, so with localStorage and a synchronous schema, the session
is restored before the first paint. Nothing starts on a server.
Every mode on this page protects the same page:
import { , } from "./hooks.js";
export function () {
const = (() => .);
const = ();
return (
<>
<>Welcome, {?.}</>
< ={() => void .().(() => {})}>
Sign out
</>
</>
);
}Declarative mode
Declarative mode has no loaders, so SessionOutlet is the guard. Render it
as a layout route's element, <Route element={<Private />}>, and the routes
inside it render only while someone is signed in:
import { } from "react";
import {
,
,
,
,
,
} from "react-router";
import { } from "@monarcode/session-kit";
import { } from "@monarcode/session-kit/react-router";
import { } from "react-dom/client";
import { } from "./auth.js";
import { } from "./home.js";
import { , } from "./hooks.js";
import { } from "./sign-in.js";
/** The guard: its child routes render only while someone is signed in. */
function () {
return (
<
="/login"
={<>Checking session…</>}
={({ , }) => (
< ="alert">
<>{?. ?? "Can't reach the server."}</>
< ={}>Try again</>
</>
)}
/>
);
}
/** Signing in changes the state, which renders this again and redirects. */
function () {
const = (
() =>
. === "authenticated" || . === "refreshing",
);
const [] = ();
if ()
return < ={(.("redirectTo"))} />;
return < />;
}
(.("root")!).(
<>
< ={}>
<>
<>
< ="/login" ={< />} />
< ={< />}>
< ={< />} />
</>
</>
</>
</>
</>,
);SessionOutlet shows pending while the saved session is restored, sends
signed-out users to loginPath with redirectTo set to where they were
going, and renders unavailable after a failed refresh. Without
unavailable, the error is thrown to the nearest error boundary.
safeReturnTo accepts only same-origin paths and returns / for anything
else, including the sign-in page itself, so a stale redirectTo can't loop.
A complete app is in
examples/react-router-declarative.
Data mode
In data mode, loaders run before a route renders, so the guard goes there. A private route's data is never fetched for a signed-out user:
import {
,
,
,
,
,
} from "react-router";
import { } from "@monarcode/session-kit";
import {
,
,
,
,
} from "@monarcode/session-kit/react-router";
import { } from "./auth.js";
import { } from "./home.js";
import { } from "./hooks.js";
import { } from "./sign-in.js";
/** Runs the loaders again when auth changes, and with them the guards. */
function () {
();
return < />;
}
function () {
const = ();
const [] = ();
return (
<
={() =>
((.("redirectTo")), { : true })
}
/>
);
}
/** Shown after a failed refresh; a repeated failure shows here again. */
function () {
const = ();
const = ();
return (
< ="alert">
<>{ instanceof ? . : "Something went wrong"}</>
< ={() => void .().(() => {})}>Retry</>
</>
);
}
export const = ([
{
: < />,
: () => <>Checking session…</>,
: [
{
: "/login",
: async ({ }) => {
const = new (.)..(
"redirectTo",
);
await (, { });
return null;
},
: < />,
},
{
: ({ }) =>
(, { , : "/login" }),
: < />,
: (
<
="/login"
={<>Checking session…</>}
/>
),
: [{ : true, : < /> }],
},
],
},
]);Then render the router inside the provider, in src/main.tsx:
import { StrictMode } from "react";
import { RouterProvider } from "react-router";
import { createRoot } from "react-dom/client";
import { auth } from "./auth.js";
import { AuthProvider } from "./hooks.js";
import { router } from "./router.js";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AuthProvider client={auth}>
<RouterProvider router={router} />
</AuthProvider>
</StrictMode>,
);Three pieces work together:
requireSessionredirects signed-out users tologinPathwithredirectTo, and returns{ session }as the route's loader data. A failed refresh is rethrown for the route's error element instead: the user is still signed in.useAuthRevalidationruns the loaders again after a sign-in, a sign-out, an account switch, or a profile update, so the guards run again. Call it once, in the root route.SessionOutlet, as the guarded route's element, renders child routes only while the session the loader accepted is still the signed-in one. When another account signs in, it showspendinguntil the loader has run for the new session, so no page sees the previous account's loader data.
A complete app is in examples/react-router-data.
Framework mode with ssr: false
Framework mode without a server is data mode with route modules: the guards
are the same, in clientLoader. Keep the files from Getting started, and
home.tsx from Add a page to protect, in app/.
Turn server rendering off:
// react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default { ssr: false } satisfies Config;Provide the client, and watch for auth changes, in the root route:
import type { } from "react";
import { , , , , } from "react-router";
import { } from "@monarcode/session-kit/react-router";
import { } from "./auth.js";
import { } from "./hooks.js";
export function ({ }: { : }) {
return (
< ="en">
<>
< ="utf-8" />
< />
< />
</>
<>
{}
< />
< />
</>
</>
);
}
/** Runs the client loaders again when auth changes, and with them the guards. */
function () {
();
return < />;
}
export default function () {
return (
< ={}>
< />
</>
);
}
/** Prerendered into index.html, and shown until the client loaders finish. */
export function () {
return <>Checking session…</>;
}Guard a layout route, and nest the private routes under it:
import { } from "react-router";
import {
,
,
} from "@monarcode/session-kit/react-router";
import { } from "../auth.js";
import { } from "../hooks.js";
export function ({ }: { : }) {
return (, { , : "/login" });
}
export default function () {
return (
< ="/login" ={<>Checking session…</>} />
);
}
/** Shown after a failed refresh; a repeated failure shows here again. */
export function () {
const = ();
const = ();
return (
< ="alert">
<>{ instanceof ? . : "Something went wrong"}</>
< ={() => void .().(() => {})}>Retry</>
</>
);
}Add the sign-in route, which sends signed-in users on to redirectTo:
import { , } from "react-router";
import { } from "@monarcode/session-kit";
import { } from "@monarcode/session-kit/react-router";
import { } from "../auth.js";
import { } from "../sign-in.js";
export async function ({ }: { : }) {
const = new (.)..("redirectTo");
await (, { });
return null;
}
export default function () {
const = ();
const [] = ();
return (
<
={() =>
((.("redirectTo")), { : true })
}
/>
);
}In app/routes.ts, nest the routes the layout protects:
import {
index,
layout,
route,
type RouteConfig,
} from "@react-router/dev/routes";
export default [
route("login", "routes/login.tsx"),
layout("routes/protected.tsx", [index("routes/home.tsx")]),
] satisfies RouteConfig;routes/home.tsx is the page, as its default export:
export { Home as default } from "../home.js";.
AuthProvider mounts the client in an effect, so the build-time prerender of
the root route starts nothing, and the browser restores the session.
A complete app is in
examples/react-router-framework-spa.
Layouts around guarded pages
A guarded route often has UI of its own around its pages, such as a sidebar
and a top bar. Render it in the guarded route's element, with SessionOutlet
where the pages go:
import { } from "@monarcode/session-kit/react-router";
import { } from "./hooks.js";
/** The guarded route's element: `element: <Dashboard />`. */
export function () {
// From state, not loader data: this stays mounted through a sign-out.
const = (() => .);
const = (() => .);
return (
< ="dashboard">
<>{ ? `Signed in as ${.}` : null}</>
{/* Keyed by session, so one account's choices never show for the next. */}
< ={}>…</>
<>
< ="/login" ={<>Checking session…</>} />
</>
</>
);
}Three things follow from where SessionOutlet sits:
- It protects only what it renders. After a sign-out it redirects to the
sign-in page. After an account switch it remounts the pages, and in data
and framework mode shows
pendinguntil the guard runs again. The layout around it stays mounted meanwhile. - Read the user from state there. In the layout, use
useAuth((state) => state.user)and handlenull. The loader'ssessionis the one the guard accepted, which for that moment is the previous one. - Key what belongs to an account. Layout UI that holds state for one
account, such as a draft or an open panel, takes the session as its
key, so the next account starts fresh.
Things to know
- A router
basename. Pass the samebasenametorequireSession, soredirectToleaves it out. Redirects add it again. - A failed refresh is not a redirect. In declarative mode it renders
unavailable. In data and framework mode,requireSessionrethrows it for the route's error element, which can offerauth.retry(). - Locked sessions. Locking is for native apps, so the guards treat a locked session as signed out.
- Cached data.
state.sessionIdis new on every sign-in. Scope private cached data, such as TanStack Query results, to it.
Next steps
- Call your API with
authFetch, as in Getting started. It works unchanged with the router. - Using the client: updating the user, signing out, and the client's other methods.
- Effect and effetch: the same client as an Effect service.