React Router with server rendering
Keep the session in an encrypted cookie in React Router's framework mode, where middleware refreshes it and loaders read it.
In this guide, you keep the session in an encrypted cookie in React Router's framework mode. An action signs in, a session route answers the browser, middleware refreshes the tokens, and loaders read the session. The first response already shows the signed-in page, so the browser never shows a "Checking session…" state or a flash of signed-out content. Components get a small client that mirrors the session, and the hooks work with it. Server rendering explains how the pieces fit.
For framework mode with ssr: false, follow
React Router
instead. A complete app is in
examples/react-router-framework-ssr.
Before you begin
- React Router's middleware: React Router 8, or 7.9 and later with
future.v8_middleware. The code on this page is for React Router 8. On 7.14 and earlier, read the URL withnew URL(request.url)where the code uses theurlargument. - A secret of 32 or more random characters to seal the cookie, set as
SESSION_SECRETon the server. To make one, runopenssl rand -base64 32.react-router builddoesn't need it.
Installation
To install the packages, run the following command:
npm install @monarcode/session-kit react-router zod1. Create the server engine
createServerAuth from @monarcode/session-kit/server keeps the session in
a cookie sealed with your secret, and refreshes it with your backend.
routerAuth gives a request's session by its context, and
requireSession makes the guard that protects routes in step 4. Create them
in a .server module, so React Router refuses to bundle it for the browser:
import {
,
,
} from "@monarcode/session-kit/react-router/server";
import { } from "@monarcode/session-kit/server";
import { } from "zod";
const = "https://api.example.com";
const = .({ : .(), : .() });
const = .({ : .(), : .() });
export const = ({
: "app",
: ,
: ..SESSION_SECRET!,
: async ({ , }) => {
const = await (`${}/auth/refresh`, {
: "POST",
,
: { "Content-Type": "application/json" },
: .({ }),
});
// The backend rejected the refresh token: sign out.
if (. === 401) return null;
if (!.) throw new (`Refresh failed (${.})`);
return .(await .());
},
});
/** The request's session: `auth(context)` in middleware, loaders and actions. */
export const = ();
/** Lets only signed-in users through to the routes it guards. */
export const = ();
/** Signs in with your backend, from the server. */
export async function (: string, : string) {
const = await (`${}/auth/login`, {
: "POST",
: { "Content-Type": "application/json" },
: .({ , }),
});
const : unknown = await .().(() => null);
if (!.) {
// The backend says why, such as wrong credentials, in `message`.
const = .({ : .() }).();
throw new (
.
? ..
: `Sign-in failed (${.})`,
);
}
return { ....(), : .() };
}Server sessions covers its other options, rotating the secret, signing out, and the limits every setup shares.
2. Add the root route
The root route's middleware is sessionMiddleware(serverAuth) from
@monarcode/session-kit/react-router/server. It reads each request's
cookies and sets the ones written on the response, redirects included, so
auth(context) gives the request's session in middleware, loaders and
actions. Then it refreshes access that is about to expire, before any loader
reads it, and marks each response that doesn't set its own
Cache-Control as private, no-store, so shared caches keep none. A failed
refresh doesn't fail the request. Loaders read the session as usual, and an
error there reaches the route's error boundary.
createMirrorAuth makes the client that components use, from the session
the root loader read and the session route that step 3 adds:
import { , type } from "@monarcode/session-kit";
// Types only: left out of the browser's bundle.
import type { } from "./session.server";
export type = <typeof >;
export function (: | null) {
return ({
: "app",
,
: "/auth/session",
});
}
export type = <typeof >;The root route runs the middleware and reads the session, and its component
creates the client with useMirrorAuth from @monarcode/session-kit/react:
once per request on the server, never at module level. It keeps the client
and the server in step both ways. When the session changes in the browser,
it calls onChange, here revalidate from useRevalidator(), which runs
the loaders again. Register the client's type here, where you render
AuthProvider, so the hooks carry your user:
import type { } from "react";
import {
,
,
,
,
,
,
type ,
} from "react-router";
import { , } from "@monarcode/session-kit/react";
import { } from "@monarcode/session-kit/react-router/server";
import { , type } from "./auth/client";
import { , } from "./auth/session.server";
// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
interface Register {
: ;
}
}
export const = ();
export async function ({ }: ) {
return { : await ().() };
}
export function ({ }: { : }) {
return (
< ="en">
<>
< />
< />
</>
<>
{}
< />
</>
</>
);
}
export default function () {
const { } = <typeof >();
const { } = ();
const = ({
: ,
,
: ,
});
return (
< ={}>
< />
</>
);
}getSession resolves { sessionId, user }, never tokens, so it's safe to
return from a loader. After an action signs in or out, the loader reads the
new session and the client adopts it. After a sign-out in the browser, here
or in another tab, onChange runs the loaders again, so the server's
redirects run. The client-side guides add useAuthRevalidation for this.
Here, useMirrorAuth does it.
3. Sign in and out
The sign-in route's action signs in with your backend and seals the result into the cookie. React Router 7.12 and later refuse route actions posted from other sites. On 7.9 to 7.11, refuse them yourself at the start of the action, as The session route explains. The form works before the page's scripts load:
import {
,
,
,
,
type ,
} from "react-router";
import { } from "@monarcode/session-kit";
import { } from "@monarcode/session-kit/react-router/server";
import { , , } from "../auth/session.server";
// A signed-in user goes on to `redirectTo`.
export const = ();
export async function ({ , , }: ) {
const = await .();
try {
const = await (
(.("username")),
(.("password")),
);
await ().();
} catch () {
return (
{ : instanceof ? . : "Sign-in failed" },
{ : 400 },
);
}
throw ((..("redirectTo")));
}
export default function () {
const = <typeof >();
return (
< ="post">
<>
Username
< ="username" ="username" />
</>
<>
Password
<
="password"
="password"
="current-password"
/>
</>
{?. && < ="alert">{.}</>}
<>Sign in</>
</>
);
}A failed sign-in returns its message with status 400 for the form to show. React Router doesn't run the page's loaders again after a 4xx action.
The browser's client reads the session and signs out through a session
route, a resource route. sessionRoute from
@monarcode/session-kit/react-router/server makes its loader and action:
import { } from "@monarcode/session-kit/react-router/server";
import { } from "../auth/session.server";
const = ();
export const = .;
export const = .;Add it to app/routes.ts as route("auth/session", "routes/auth.session.ts").
Export the loader and action one by one, as app/routes/auth.session.ts
does. React Router's build fails on a destructured
export const { loader, action }. The route answers your
own site's pages only, so another site can't sign the user out: see
Security.
4. Protect routes
Guard a layout route with signedIn, the middleware requireSession made.
It runs on the server before any loader or action nested in it, which
loaders on their own can't promise. Signed-out users go to /login, with
?redirectTo= set to the page they asked for:
import { } from "react-router";
import { } from "../auth/session.server";
export const = [];
// So navigation in the browser runs the middleware.
export const = () => null;
export default function () {
return < />;
}A loader nested in it gets the session from signedIn.session(context),
which is never null there. Components read the user with the hooks as
usual:
import { , type } from "react-router";
import { } from "../auth/session.server";
export function ({ }: ) {
return { : .().. };
}
export default function () {
const { } = <typeof >();
return <>Welcome, {}</>;
}In app/routes.ts, nest the routes it protects:
layout("routes/protected.tsx", [index("routes/home.tsx")]). During
navigation in the browser, server middleware runs only when a route has a
loader to fetch, so the layout has one in case the routes nested in it don't.
In this guide, the server signs in with your backend, so the user in the cookie comes from the backend. If the browser signs in with the backend itself, have the action take the tokens instead, as Hybrid setup shows for TanStack Start.
Call your API
Call your API with createAuthFetch from @monarcode/session-kit/http.
Given the request's session, it sends the access token, refreshed first when
it has expired. After a 401, it renews the token, saves it in the cookie, and
sends a GET again:
import { , , type } from "react-router";
import { } from "@monarcode/session-kit";
import { } from "@monarcode/session-kit/http";
import { } from "zod";
import { } from "../auth/session.server";
export async function ({ }: ) {
const = ();
// Outside the guarded layout: send the user to sign in, then back here.
if (!(await .()))
throw (("/login", "/orders"));
const = (, {
: "https://api.example.com/",
});
const = await ("orders");
if (!.) throw new (`Orders failed (${.})`);
return .(.({ : .() })).(await .());
}
export default function () {
const = <typeof >();
return <>{.} orders</>;
}With an HTTP client of your own, such as ky or axios, see
Other HTTP clients. A client without a
fetch option reads the token with credentials.get().
To call the API from the browser too, with createAuthFetch or effetch's
sessionBearer, opt in with clientCredentials: true in createServerAuth.
The session route then serves the access token, and the client's
credentials get it there. The browser keeps the access token in memory,
never the refresh token; see
Server sessions.
The example app does this.
Things to know
- Signing out ends the session only with
revoke. Without it, a copy of the cookie made before sign-out still works: see Sign-out. - Refresh in middleware. The request's session sets cookies until the
response leaves
sessionMiddleware, which first waits for a refresh under way. A loader's deferred data that reads the session later, while the page streams, can't set cookies: an expired token then rejects withREFRESH_FAILED, since nothing new can be saved.sessionMiddlewarerefreshes before that. - One session per request.
auth(context)returns the same session for every call with the samecontext, which React Router makes per request. - Caching.
sessionMiddlewareleaves a response that sets its ownCache-Controlas it is. PasscacheControlto send another value, or{ cacheControl: false }to leave every response as it is. - Guards with the same names.
requireSessionandredirectIfSignedInon this page come from@monarcode/session-kit/react-router/server.@monarcode/session-kit/react-routerexports guards with the same names that take the browser's client, for apps without server rendering: see React Router. - A
basename. Pass it to the guard too, asrequireSession(serverAuth, { basename: "/app" }). React Router adds it to loaders' and actions' redirects, but not to middleware's, so the guard adds it to its own. The client'sendpointincludes it as well:"/app/auth/session". - Tabs. Signing in or out in one tab reaches the others, which ask the server again and run their loaders again. So does returning to a tab after a while.
- More. Several server instances, the cookie's size, and plain http during development: see Server sessions.
Next steps
- Server sessions: the cookie, signing out, and the limits every server-rendered setup shares.
- Using the client: what the browser client can do.
- Effect on the server: run Effects with a request's session.