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. Actions sign in and out, 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, 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 zod1. Create the server engine
Put the user schema in a module that the server and the browser both import:
import { } from "zod";
export const = .({ : .(), : .() });
export type = .<typeof >;createServerAuth from @monarcode/session-kit/server keeps the session in
a cookie sealed with your secret, and refreshes it with your backend. Create
it in a .server module, so React Router refuses to bundle it for the
browser:
import { } from "@monarcode/session-kit/server";
import { } from "zod";
import { } from "./user";
const = "https://api.example.com";
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 .());
},
});
/** 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 middleware
Every serverAuth method takes a cookie jar. cookieJarMiddleware from
@monarcode/session-kit/react-router/server gives each request one and sets
the cookies written to it on the response, redirects included.
routerJar(context) finds the jar in middleware, loaders and actions.
After it, refresh access that is about to expire, before any loader reads it, and keep pages that depend on the session out of shared caches:
import { , type } from "react-router";
import {
,
,
} from "@monarcode/session-kit/react-router/server";
import { } from "./session.server";
const : <> = async (
{ },
,
) => {
// A failed refresh shows up when a loader reads the session.
await .(()).(() => {});
const = await ();
// A copy: some responses, such as `fetch`'s, can't change their headers.
const = new (., );
..("Cache-Control", "private, no-store");
return ;
};
/** For the root route: the jar first, so it wraps the rest. */
export const = [, ];
/** Sends signed-out users to the sign-in page, which brings them back. */
export const : <> = async ({
,
,
}) => {
if (!(await .(())))
throw (
`/login?redirectTo=${(. + .)}`,
);
};url leaves out React Router's .data suffix, which request.url keeps.
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, start the action with the Sec-Fetch-Site check
that the sign-out action in app/routes/auth.session.ts uses. The form works
before the page's scripts load:
import {
,
,
,
type ,
type ,
} from "react-router";
import { } from "@monarcode/session-kit";
import { } from "@monarcode/session-kit/react-router/server";
import { , } from "../auth/session.server";
export async function ({ , }: ) {
if (await .(()))
throw ((..("redirectTo")));
return null;
}
export async function ({ , , }: ) {
const = await .();
try {
await .(
(),
await ((.("username")), (.("password"))),
);
} catch () {
return {
: instanceof ? . : "Sign-in failed",
};
}
throw ((..("redirectTo")));
}
export default function () {
const = <typeof >();
return (
< ="post">
<>
Username
< ="username" ="username" />
</>
<>
Password
<
="password"
="password"
="current-password"
/>
</>
{?. && < ="alert">{.}</>}
<>Sign in</>
</>
);
}A resource route gives the browser's client the session and signs out. React Router doesn't check where resource routes' requests come from, so its action accepts your own site only:
import type { , } from "react-router";
import { } from "@monarcode/session-kit/react-router/server";
import { } from "../auth/session.server";
export async function ({ }: ) {
return .(await .(()));
}
export async function ({ , }: ) {
if (..("sec-fetch-site") !== "same-origin")
return new (null, { : 403 });
await .(());
return .(null);
}Add it to app/routes.ts as route("auth/session", "routes/auth.session.ts").
4. Create the browser client
createMirrorAuth makes the client that components use, from the session
the root loader read and the resource route:
import { , type } from "@monarcode/session-kit";
import type { } from "./user";
export type = <>;
async function (: "GET" | "POST") {
const = await ("/auth/session", { });
if (!.) throw new (`Session failed (${.})`);
return (await .()) as | null;
}
export function (: | null) {
return ({
: "app",
: ,
: () => ("GET"),
: () => ("POST"),
});
}
export type = <typeof >;Create the hooks, typed by that client:
import { } from "@monarcode/session-kit/react";
import type { } from "./auth/client";
export const { , , } =
<>();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, running loaders again with revalidate
from useRevalidator() when the session changes in the browser:
import type { } from "react";
import {
,
,
,
,
,
,
type ,
} from "react-router";
import { } from "@monarcode/session-kit/react";
import { } from "@monarcode/session-kit/react-router/server";
import { } from "./auth/client";
import { } from "./auth/middleware.server";
import { } from "./auth/session.server";
import { } from "./hooks";
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. useMirrorAuth replaces useAuthRevalidation: 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, loaders
run again, so the server's redirects run.
5. Protect routes
Guard a layout route with middleware. It runs on the server before any loader or action nested in it, which loaders on their own can't promise:
import { } from "react-router";
import { } from "../auth/middleware.server";
export const = [];
// So navigation in the browser runs the middleware.
export const = () => null;
export default function () {
return < />;
}A page nested in it reads the user as usual:
import { } from "../hooks";
export default function () {
const = (() => .);
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 and the user instead. It seals what it's given, as Sign in on the TanStack Start page explains: check the tokens there first, or let your backend authorize requests with the access token.
Call your API
Loaders and actions get the access token with the request's jar, refreshing
it first when it has expired, and can renew it after a 401:
import { , , type } from "react-router";
import { } from "@monarcode/session-kit/react-router/server";
import { } from "zod";
import { } from "../auth/session.server";
export async function ({ }: ) {
const = await ..(());
if (!) throw ("/login?redirectTo=/orders");
const = await ("https://api.example.com/orders", {
: { : `Bearer ${.}` },
});
if (!.) throw new (`Orders failed (${.})`);
return .(.({ : .() })).(await .());
}
export default function () {
const = <typeof >();
return <>{.} orders</>;
}To call the API from the browser too, with createAuthFetch or effetch's
sessionBearer, opt in with clientCredentials: true in createServerAuth.
Expose serverAuth.clientCredentials.get and .renew through a resource
route, added as route("auth/credentials", "routes/auth.credentials.ts"):
import type { } from "react-router";
import { } from "@monarcode/session-kit/react-router/server";
import { } from "zod";
import { } from "../auth/session.server";
const = .({ : .(), : .() });
export async function ({ , }: ) {
if (..("sec-fetch-site") !== "same-origin")
return new (null, { : 403 });
const = ();
const = .(await .().(() => null));
return .(
.
? await ..(, .)
: await ..(),
);
}Then pass the client a credentials option: get posts to the route with
no body, and renew posts the rejected token. 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. A copy of the cookie works again ten seconds after sign-out: see Sign-out. - Refresh in middleware. The jar sets cookies until the response leaves
cookieJarMiddleware, which first waits for a refresh under way. A loader's deferred data that reads the session later, while the page streams, gets a read-only jar: an expired token then rejects withREFRESH_FAILED, since the jar can't save a new one.refreshIfDuein middleware refreshes before that. - One jar per request.
routerJarreturns the same jar for every call with the samecontext, which React Router makes per request. - 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.