Skip to Content
Setups

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 with new URL(request.url) where the code uses the url argument.
  • A secret of 32 or more random characters to seal the cookie, set as SESSION_SECRET on the server. To make one, run openssl rand -base64 32. react-router build doesn't need it.

Installation

To install the packages, run the following command:

npm install @monarcode/session-kit zod

1. Create the server engine

Put the user schema in a module that the server and the browser both import:

app/auth/user.ts
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:

app/auth/session.server.ts
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:

app/auth/middleware.server.ts
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:

app/routes/login.tsx
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:

app/routes/auth.session.ts
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:

app/auth/client.ts
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:

app/hooks.ts
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:

app/root.tsx
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:

app/routes/protected.tsx
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:

app/routes/home.tsx
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:

app/routes/orders.tsx
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"):

app/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 with REFRESH_FAILED, since the jar can't save a new one. refreshIfDue in middleware refreshes before that.
  • One jar per request. routerJar returns the same jar for every call with the same context, 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

On this page