Skip to Content
Setups

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:

ModeThe guardBeside it
DeclarativeSessionOutletNothing
DatarequireSessionLoader as the loaderSessionOutlet, useAuthRevalidation
Framework, with ssr: falserequireSessionLoader as the clientLoaderSessionOutlet, 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 3. This guide uses its client in src/auth.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 zod

React Router 7 and 8 both work.

Add a page to protect

In declarative and data mode, AuthProvider goes around the router. In framework mode, it goes in the root route. 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. Register the client where you render it, as step 4 of Getting started shows, and the hooks from @monarcode/session-kit/react carry your user.

Every mode on this page protects the same page:

src/home.tsx
import { ,  } from "@monarcode/session-kit/react";

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:

src/main.tsx
import {  } from "react";
import {
  ,
  ,
  ,
  ,
  ,
} from "react-router";

import { ,  } from "@monarcode/session-kit";
import { ,  } from "@monarcode/session-kit/react";
import {  } from "@monarcode/session-kit/react-router";
import {  } from "react-dom/client";

import {  } from "./auth.js";
import {  } from "./home.js";
import {  } from "./sign-in.js";

// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
  interface Register {
    : typeof ;
  }
}

/** The guard: its child routes render only while someone is signed in. */
function () {
  return (
    <
      ={<>Checking session…</>}
      ={({ ,  }) => (
        < ="alert">
          <>{?. ?? "Can't reach the server."}</>
          < ={}>Try again</>
        </>
      )}
    />
  );
}

/** Signing in changes the state, which renders this again and redirects. */
function () {
  const  = ();
  const [] = ();
  if ()
    return < ={(.("redirectTo"))}  />;
  return < />;
}

(.("root")!).(
  <>
    < ={}>
      <>
        <>
          < ="/login" ={< />} />
          < ={< />}>
            <  ={< />} />
          </>
        </>
      </>
    </>
  </>,
);

SessionOutlet shows pending while the saved session is restored, sends signed-out users to loginPath (/login unless you pass another) 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 signed-out user is redirected before a private route renders:

src/router.tsx
import { , ,  } from "react-router";

import {  } from "@monarcode/session-kit/react";
import {
  ,
  ,
  ,
  ,
} from "@monarcode/session-kit/react-router";

import {  } from "./auth.js";
import {  } from "./home.js";
import {  } from "./sign-in.js";

/** Runs the loaders again when auth changes, and with them the guards. */
function () {
  ();
  return < />;
}

/** 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",
        : (),
        : < />,
      },
      {
        : (),
        : < />,
        : < ={<>Checking session…</>} />,
        : [{ : true, : < /> }],
      },
    ],
  },
]);

Then render the router inside the provider, in src/main.tsx:

import { StrictMode } from "react";
import { RouterProvider } from "react-router/dom";

import { AuthProvider } from "@monarcode/session-kit/react";
import { createRoot } from "react-dom/client";

import { auth } from "./auth.js";
import { router } from "./router.js";

// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
  interface Register {
    auth: typeof auth;
  }
}

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <AuthProvider client={auth}>
      <RouterProvider router={router} />
    </AuthProvider>
  </StrictMode>,
);

Three pieces work together:

  • requireSessionLoader(auth) is the route's loader. It redirects signed-out users to /login with redirectTo, and returns { session } as the route's loader data. Pass { loginPath } for another sign-in page. A failed refresh is rethrown for the route's error element instead, because the user is still signed in. A loader that does more calls requireSession instead, as Guards in loaders shows.
  • useAuthRevalidation runs 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 shows pending until 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/. Framework mode also needs @react-router/dev, which a framework-mode app already has.

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:

app/root.tsx
import type {  } from "react";
import { , , , ,  } from "react-router";

import {  } from "@monarcode/session-kit/react";
import {  } from "@monarcode/session-kit/react-router";

import {  } from "./auth.js";

// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
  interface Register {
    : typeof ;
  }
}

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:

app/routes/protected.tsx
import {  } from "react-router";

import {  } from "@monarcode/session-kit/react";
import {
  ,
  ,
} from "@monarcode/session-kit/react-router";

import {  } from "../auth.js";

export const  = ();

export default function () {
  return < ={<>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. Its loader runs again after a sign-in, so the form doesn't navigate:

app/routes/login.tsx
import {  } from "@monarcode/session-kit/react-router";

import {  } from "../auth.js";
import {  } from "../sign-in.js";

export const  = ();

export default ;

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.

Guards in loaders

Two functions guard a loader, in data mode and in framework mode. Both send a signed-out user to /login, with redirectTo, and resolve { session }, typed with your user:

  • requireSessionLoader(auth) is the whole loader. Use it on a route that guards and loads nothing else, usually the layout route around your private pages, as in Data mode.
  • requireSession(auth, { request }) is a call inside a loader of your own. Use it when the loader loads data too: data only a signed-in user may load, or data that needs who is signed in.

React Router runs the loaders of nested routes at the same time, so a page's loader doesn't wait for its layout's guard. A page whose loader calls your API checks the session itself:

app/routes/profile.tsx
import {  } from "react-router";

import {  } from "@monarcode/session-kit/react-router";

import {  } from "../api.js";
import {  } from "../auth.js";

export async function ({  }: { :  }) {
  const {  } = await (, {  });
  return { : .., : await () };
}

export default function () {
  const { ,  } = <typeof >();
  return (
    <>
      {}, your email is {.}.
    </>
  );
}

A signed-out user is redirected before your API is called, rather than after a request that fails. Pass requireSession the same loginPath and basename as the layout's guard.

The sign-in page has the same pair: redirectIfSignedInLoader(auth) as its loader, or redirectIfSignedIn(auth, { redirectTo }) in a loader that does more.

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:

src/dashboard.tsx
import {  } from "@monarcode/session-kit/react";
import {  } from "@monarcode/session-kit/react-router";

/** 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. */}
      < ={}>…</>
      <>
        < ={<>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 pending until 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 handle null. The loader's session is 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

  • Another sign-in page. If sign-in isn't at /login, pass the same loginPath to SessionOutlet, requireSessionLoader (or requireSession), redirectIfSignedInLoader and safeReturnTo.
  • A router basename. Pass it to requireSessionLoader or requireSession too, so redirectTo leaves it out. Redirects add it again. SessionOutlet needs nothing.
  • A failed refresh is not a redirect. In declarative mode, SessionOutlet renders unavailable, or throws to the nearest error boundary without it. In data and framework mode, the guard rethrows it for the route's error element, which can offer auth.retry().
  • Locked sessions. Locking is for native apps, so the guards treat a locked session as signed out.
  • Cached data. state.sessionId is new on every sign-in. Scope private cached data, such as TanStack Query results, to it.

Next steps

On this page