# Introduction (https://session-kit.pages.dev/docs)
Session kit is a schema-driven toolkit for defining, validating, and managing
the session in a React app whose own backend signs users in and issues access
and refresh tokens. You give it the tokens your backend returns, and it
manages the session from there. It supports TanStack Router and Start, React
Router, Next.js, Expo Router on iOS and Android, and Effect.
This page explains why it exists, what it does, and the terms the rest of the
docs use. To start using it, see [Installation](https://session-kit.pages.dev/docs/installation).
## Why Session kit
Signing in is the easy part. Your backend checks a password and answers with
an access token and a refresh token, and for a moment the app is done.
Then the user reloads the page. The access token expires halfway through a
form. They open a second tab, sign out of one, and switch accounts in the
other. The backend goes down for a minute, just as a refresh was due.
Every app with its own backend has to get these moments right. It usually
takes a few hundred lines spread across an auth context, a fetch wrapper, and
the route guards, and one or two cases stay missing until a user finds them.
Session kit is that code, written once and tested with the routers and
frameworks it supports.
### What it does
Session kit handles each of these moments for you:
| When… | Session kit… |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| The page reloads | Restores the saved session and checks the user against your schema again. With `localStorage` and a synchronous schema, before the first paint. |
| The access token is about to expire | Refreshes it early, in the background, so requests don't wait. |
| A request gets a 401 anyway | Refreshes once, however many requests failed, then replays the safe ones. |
| Two tabs need a refresh at the same moment | Lets one tab spend the refresh token and the other reuse what it saved, where the browser supports Web Locks. |
| The user signs out or switches account in one tab | Follows in every tab, and remounts guarded pages so nothing of the previous account stays on screen. |
| The backend rejects the refresh token | Signs out in every tab and clears storage. |
| The backend is down when a refresh is due | Keeps the session, reports the error, and waits for a retry. An outage is not a sign-out. |
| A signed-out user opens a private page | Sends them to sign-in, and back to where they were going afterwards. |
| The page renders on the server | Reads the session from an encrypted cookie, so the first response is already signed in. |
### What it doesn't do
Session kit doesn't sign users in. It has no sign-in form, no OAuth flow, and
no user database. It starts where your backend's sign-in response ends. If you
need those too, use an auth service or framework such as Auth.js, Better Auth,
or Clerk, which manage their own sessions.
## How it works
Session kit sits between your backend and your components. Tokens travel on
the left, and only state reaches the right:
```text
Your backend Session kit Your components
┌───────────────┐ ┌───────────────────┐ ┌──────────────────┐
│ POST /login │ ───▶ │ signIn() │ ───▶ │ useAuth() │
│ POST /refresh │ ◀──▶ │ refresh, on time │ │ route guards │
│ GET /orders │ ◀─── │ authFetch() │ ◀─── │ loaders, queries │
└───────────────┘ └───────────────────┘ └──────────────────┘
issues tokens keeps the session state, never tokens
```
Your backend issues the tokens. Session kit saves them, refreshes them on
time, and attaches them to your requests. Your components and route guards
read the session's state, and never see a token.
Five terms come up throughout:
- **Tokens** are what your backend issues. The access token goes with each
request and is short-lived. The refresh token, if there is one, is exchanged
for a new access token when the old one expires.
- **The client**, from `createAuth`, holds the tokens and does the work:
saving, restoring, refreshing, and telling other tabs. An app usually has
one.
- **A session** is who is signed in: `{ sessionId, user }`. It never holds
tokens, so it is safe to render and to pass around.
- **State** is what components read: `status`, `user`, `sessionId`, and
`error`, updated as the session changes.
- **Credentials** are the access token and its expiry, given only to code that
attaches them to requests, such as `createAuthFetch`.
`status` says where the session stands, and checking it narrows `user`:
| `status` | Meaning | `user` |
| ----------------- | ------------------------------------------------------------------------- | ------ |
| `initializing` | The saved session hasn't been restored yet. | `null` |
| `authenticated` | Access is usable. | Set |
| `refreshing` | A refresh is replacing expired or rejected access. | Set |
| `locked` | With `locking`, a session is saved, but its tokens wait for `unlock()`. | Set |
| `unauthenticated` | No session; sign-in is needed. | `null` |
| `unavailable` | Restoring failed, or a failed refresh left no usable access; see `error`. | `null` |
The session is kept in one of two places, depending on where your app renders:
| | In the browser or on the device | On the server |
| ----------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| For | Single-page apps and Expo apps | TanStack Start, Next.js, React Router with SSR |
| Client | `createAuth` | `createServerAuth`, with `createMirrorAuth` in the browser |
| Saved in | `localStorage`, `sessionStorage`, the iOS keychain, or Android encrypted storage | An encrypted `httpOnly` cookie |
| The refresh token | Stays in that storage | Never reaches the browser |
Hooks, guards, and tab sync work the same in both.
## Next steps
- [Installation](https://session-kit.pages.dev/docs/installation): install the package and find the guide
for your setup.
- [Getting started](https://session-kit.pages.dev/docs/getting-started): build a small app that signs in,
stays signed in, and calls an API.
- [Server rendering](https://session-kit.pages.dev/docs/server-rendering): keep the session on the server
with TanStack Start, Next.js, or React Router.
# Installation (https://session-kit.pages.dev/docs/installation)
Install Session kit, a schema library, and the packages your setup needs, then
follow the guide for that setup.
To install the package, run the following command:
```npm
npm install @monarcode/session-kit
```
Alpha releases are published to the `latest` distribution tag and may change
the public API, so pin an exact version and read the
[release notes](https://github.com/monarcode/session-kit/blob/main/CHANGELOG.md)
before upgrading.
Session kit checks each user against a schema from any
[Standard Schema](https://standardschema.dev) library. The examples use Zod.
To install it, run the following command:
```npm
npm install zod
```
Then install React, and the router or framework your setup uses.
[Compatibility](https://session-kit.pages.dev/docs/reference/compatibility) lists every optional peer
dependency and the versions it supports.
## Pick your setup
Find your setup in the following table. Each one has an import path and a
guide. All but the first also have a runnable app in
[`examples/`](https://github.com/monarcode/session-kit/tree/main/examples)
that signs in against DummyJSON's public test API:
| Setup | Import | Guide | Example app |
| ------------------------------------------ | -------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| React without a router | `@monarcode/session-kit/react` | [Getting started](https://session-kit.pages.dev/docs/getting-started) | None |
| TanStack Router | `@monarcode/session-kit/tanstack-router` | [TanStack Router](https://session-kit.pages.dev/docs/setups/tanstack-router) | [`tanstack-router`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-router) |
| TanStack Start, SPA mode | `@monarcode/session-kit/tanstack-router` | [TanStack Router](https://session-kit.pages.dev/docs/setups/tanstack-router#tanstack-start-in-spa-mode) | [`tanstack-start-spa`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa), [`tanstack-start-spa-effetch`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa-effetch) |
| TanStack Start with server rendering | `@monarcode/session-kit/tanstack-start` | [TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start) | [`tanstack-start-ssr`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-ssr) |
| React Router, declarative mode | `@monarcode/session-kit/react-router` | [React Router](https://session-kit.pages.dev/docs/setups/react-router#declarative-mode) | [`react-router-declarative`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-declarative) |
| React Router, data mode | `@monarcode/session-kit/react-router` | [React Router](https://session-kit.pages.dev/docs/setups/react-router#data-mode) | [`react-router-data`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-data) |
| React Router, framework mode, `ssr: false` | `@monarcode/session-kit/react-router` | [React Router](https://session-kit.pages.dev/docs/setups/react-router#framework-mode-with-ssr-false) | [`react-router-framework-spa`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-framework-spa) |
| React Router, framework mode with SSR | `@monarcode/session-kit/react-router/server` | [React Router with SSR](https://session-kit.pages.dev/docs/setups/react-router-ssr) | [`react-router-framework-ssr`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-framework-ssr) |
| Next.js 16, App Router | `@monarcode/session-kit/next` | [Next.js](https://session-kit.pages.dev/docs/setups/next) | [`next-app`](https://github.com/monarcode/session-kit/tree/main/examples/next-app) |
| Expo Router on iOS and Android, SDK 53+ | `@monarcode/session-kit/expo` | [Expo Router](https://session-kit.pages.dev/docs/setups/expo) | [`expo-router`](https://github.com/monarcode/session-kit/tree/main/examples/expo-router) |
Two guides apply to more than one setup:
- [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions) covers what the
three server-rendered setups share.
- [Effect and effetch](https://session-kit.pages.dev/docs/effect) works with any setup.
## Docs for coding assistants
These docs are also plain Markdown, for AI coding assistants. Point yours at
[`/llms.txt`](https://session-kit.pages.dev/llms.txt), which links every page in reading order, or
[`/llms-full.txt`](https://session-kit.pages.dev/llms-full.txt), which holds them all in one file. Add
`.md` to a page's URL for that page alone.
## Next steps
- For a setup that keeps the session in the browser or on the device, start
with [Getting started](https://session-kit.pages.dev/docs/getting-started). The web router guides build
on its app. The Expo guide starts from scratch.
- For a server-rendered setup, start with
[Server rendering](https://session-kit.pages.dev/docs/server-rendering).
# Getting started (https://session-kit.pages.dev/docs/getting-started)
In this guide, you build a small React app that signs in, stays signed in
across reloads and tabs, calls an API, and signs out. The web router guides
build on this app. Server-rendered apps create their client differently, so
start from [Server rendering](https://session-kit.pages.dev/docs/server-rendering) instead.
The code signs in against
[DummyJSON's auth API](https://dummyjson.com/docs/auth), a public test
backend, so it runs as written.
## Before you begin
In a React app, install Session kit and Zod, as
[Installation](https://session-kit.pages.dev/docs/installation) shows.
## 1. Talk to your backend
Session kit never calls your backend for you, so start with the two requests
you would write anyway: one that signs in, and one that exchanges a refresh
token for new tokens. Nothing in this file imports Session kit:
```ts title="src/backend.ts"
import { z } from "zod";
export const API = "https://dummyjson.com";
export const userSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string(),
firstName: z.string(),
});
const tokensSchema = z.object({
accessToken: z.string(),
refreshToken: z.string(),
});
const post = (path: string, body: unknown, init?: RequestInit) =>
fetch(API + path, {
...init,
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
/** Checks the password, and resolves the tokens and the user. */
export async function login(username: string, password: string) {
const response = await post("/auth/login", { username, password });
if (!response.ok) throw new Error(`Sign-in failed (${response.status})`);
// DummyJSON returns the user's fields beside the tokens.
const body: unknown = await response.json();
return { ...tokensSchema.parse(body), user: userSchema.parse(body) };
}
/**
* Exchanges a refresh token for new tokens. Resolves `null` when the backend
* rejects the token, and throws for any other failure.
*/
export async function refresh(refreshToken: string, signal: AbortSignal) {
const response = await post("/auth/refresh", { refreshToken }, { signal });
// DummyJSON answers 403 for an invalid token and 401 for a missing one.
if (response.status === 401 || response.status === 403) return null;
if (!response.ok) throw new Error(`Refresh failed (${response.status})`);
return tokensSchema.parse(await response.json());
}
```
The schemas do two jobs. They check what the backend sent, and they give the
rest of the app its types. Requests omit `credentials: "include"`, because
Session kit keeps tokens out of cookies and attaches them itself.
## 2. Create the client
The client is the one object that holds the session. Tell it what a user is,
where to save the session, and how to refresh:
```ts title="src/auth.ts"
import { createAuth, webStorage } from "@monarcode/session-kit";
import { login, refresh, userSchema } from "./backend.js";
export const auth = createAuth({
// Keeps this app's saved session apart from any other on the same origin.
name: "my-app",
// Every user is checked against this schema, and typed by it.
user: userSchema,
// Where the session is saved: `localStorage`.
storage: webStorage(),
// How expired access is replaced.
refresh: ({ refreshToken, signal }) => refresh(refreshToken, signal),
});
/** Signs in with the backend, then hands the result to Session kit. */
export async function signInWithPassword(username: string, password: string) {
const tokens = await login(username, password);
await auth.signIn(tokens);
}
export type AppAuth = typeof auth;
```
`refresh` tells two failures apart. `null` means the backend rejected the
refresh token, so the user is signed out. A throw means something else failed,
so the session is kept and the error reported. That split is why an outage
doesn't sign anyone out. [Refresh](https://session-kit.pages.dev/docs/configuration/refresh) has the
details, and [Storage](https://session-kit.pages.dev/docs/configuration/storage) lists the other places a
session can be saved.
Create the client once, at module level, as this file does. Give each client
in an app its own `name`.
DummyJSON's tokens are JWTs, so their expiry comes from the `exp` and `iat`
claims. [Expiry](https://session-kit.pages.dev/docs/configuration/expiry) covers backends that say it
another way.
## 3. Add a sign-in form
`signInWithPassword` is all a form needs. The form has no Session kit code
of its own:
```tsx title="src/sign-in.tsx"
import { useState, type FormEvent } from "react";
import { signInWithPassword } from "./auth.js";
export function SignIn() {
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
const form = new FormData(event.currentTarget);
setError("");
setPending(true);
try {
await signInWithPassword(
String(form.get("username")),
String(form.get("password")),
);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not sign in");
} finally {
setPending(false);
}
}
return (
);
}
```
The form doesn't have to do anything when it succeeds. Signing in changes the
client's state, and the next step renders from that state. With a router, the
sign-in page's guard runs again and sends the user on.
## 4. Read the session in React
The hooks come from `@monarcode/session-kit/react`. Register your client's
type once, where you render `AuthProvider`, and they carry your user.
Then render by `status`. Each case is one of the states in
[How it works](https://session-kit.pages.dev/docs#how-it-works). By the last case, TypeScript has narrowed
`user` to a value that's set:
```tsx title="src/app.tsx"
import {
AuthProvider,
useAuth,
useAuthClient,
} from "@monarcode/session-kit/react";
import { auth } from "./auth.js";
import { SignIn } from "./sign-in.js";
// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
interface Register {
auth: typeof auth;
}
}
export function App() {
return (
);
}
function Screen() {
const { status, user } = useAuth();
const client = useAuthClient();
switch (status) {
case "initializing":
return
Restoring your session…
;
case "unauthenticated":
return ;
case "unavailable":
// Access couldn't be restored or refreshed: offer another try.
return (
);
default:
// Signed in: "authenticated", or "refreshing" with the user on screen.
return (
Welcome, {user.firstName}
);
}
}
/** The latest failure, such as a refresh the backend couldn't answer. */
function AuthError() {
const error = useAuth((state) => state.error);
return error ?
{error.message}
: null;
}
```
Each part of this file has a job:
- `AuthProvider` mounts the client. It restores the saved session, starts the
refresh timer, and follows other tabs.
- `useAuth()` returns the whole state. `useAuth(selector)` returns a part of
it, and re-renders only when that part changes, as `AuthError` does.
- `useAuthClient()` returns the client, for actions.
- With more than one client in an app, create hooks for each with
`createAuthHooks()` instead.
- `isSignedIn(state)` is `true` while `authenticated` or `refreshing`. Pass it
to `useAuth` to read only that: `useAuth(isSignedIn)`. It comes from
`@monarcode/session-kit`.
- `retry` and `signOut` reject when they fail, and publish the same failure in
`state.error`. `AuthError` shows it, so the handlers have nothing left to
do.
With [`locking`](https://session-kit.pages.dev/docs/client/locking), give `locked` a case of its own. It
has a user too, but no usable access.
That's a working app. To check it:
1. Sign in. The welcome message appears.
2. Reload the page. You're still signed in.
3. Open the app in a second tab, and sign out in one of them. Both tabs show
the sign-in form.
## 5. Call your API
`createAuthFetch` makes a `fetch` that sends the access token to one origin,
and to no other:
```ts title="src/api.ts"
import { createAuthFetch } from "@monarcode/session-kit/http";
import { auth } from "./auth.js";
import { API, userSchema } from "./backend.js";
export const authFetch = createAuthFetch(auth, { baseUrl: `${API}/` });
/** The signed-in user, fresh from the backend. */
export async function fetchProfile() {
const response = await authFetch("auth/me");
if (!response.ok) throw new Error(`Profile failed (${response.status})`);
return userSchema.parse(await response.json());
}
```
When a request gets a 401, `authFetch` refreshes, then sends a GET or HEAD
again. Other methods are never sent twice.
[Authenticated requests](https://session-kit.pages.dev/docs/client/requests) covers the rest of its
behaviour and its options.
Using ky, axios or openapi-fetch? Pass `authFetch` as the client's `fetch`:
see [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients).
## 6. Protect your routes
The client is the same everywhere. What changes is how each router keeps
signed-out users away from private pages. To continue, follow the guide for
your router:
- [TanStack Router](https://session-kit.pages.dev/docs/setups/tanstack-router), and
[TanStack Start in SPA mode](https://session-kit.pages.dev/docs/setups/tanstack-router#tanstack-start-in-spa-mode).
- [React Router](https://session-kit.pages.dev/docs/setups/react-router), in declarative, data, or
framework mode with `ssr: false`.
- [Expo Router](https://session-kit.pages.dev/docs/setups/expo), for iOS and Android. Its guide starts from
scratch.
- Without a router, you are done. Render by `status`, as `Screen` does.
With Effect, the same client becomes a service: see
[Effect and effetch](https://session-kit.pages.dev/docs/effect).
## Next steps
- [Using the client](https://session-kit.pages.dev/docs/client): what else the client does, such as
updating the user and locking.
- [Configuring the client](https://session-kit.pages.dev/docs/configuration): the rest of its options.
# TanStack Router (https://session-kit.pages.dev/docs/setups/tanstack-router)
In this guide, you add TanStack Router to the app from Getting started: a
sign-in route, private routes that redirect signed-out users, and hooks that
read the client from the Router. Guards run in `beforeLoad`, before a private
route loads or renders. The hooks come from
`@monarcode/session-kit/tanstack-router`, typed by the Router you register,
and need no `AuthProvider`.
A complete app is in
[`examples/tanstack-router`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-router).
[TanStack Start in SPA mode](#tanstack-start-in-spa-mode) lists what changes
for Start. For TanStack Start with server rendering, follow
[TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start) instead.
## Before you begin
Follow [Getting started](https://session-kit.pages.dev/docs/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 commands:
```npm
npm install @monarcode/session-kit @tanstack/react-router zod
npm install -D @tanstack/router-plugin
```
This page uses file-based routing, so add the Router plugin before the React
plugin in `vite.config.ts`:
```ts
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
tanstackRouter({ target: "react", autoCodeSplitting: true }),
react(),
],
});
```
The plugin generates `src/routeTree.gen.ts` from the files under `src/routes`.
## 1. Type the root route
The root route declares that Router context holds the client, so every guard
gets a typed `context.auth`:
```tsx title="src/routes/__root.tsx"
import { useAuth } from "@monarcode/session-kit/tanstack-router";
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import type { AppAuth } from "../auth.js";
export const Route = createRootRouteWithContext<{ auth: AppAuth }>()({
component: Root,
});
function Root() {
// Shows failures that outlive the page that caused them, such as a sign-out
// that couldn't clear storage.
const error = useAuth((state) => state.error);
return (
<>
{error &&
{error.message}
}
>
);
}
```
## 2. Add the sign-in route
The sign-in route renders the form from Getting started. Its guard sends a
signed-in user on to the page they asked for, and it runs again after a
sign-in, so the form doesn't navigate:
```tsx title="src/routes/login.tsx"
import { safeReturnTo } from "@monarcode/session-kit";
import { redirectIfSignedIn } from "@monarcode/session-kit/tanstack-router";
import { createFileRoute } from "@tanstack/react-router";
import { SignIn } from "../sign-in.js";
export const Route = createFileRoute("/login")({
validateSearch: (search: Record) => ({
redirectTo: safeReturnTo(search.redirectTo),
}),
beforeLoad: ({ context, search }) =>
redirectIfSignedIn(context.auth, { redirectTo: search.redirectTo }),
component: SignIn,
});
```
`safeReturnTo` accepts only same-origin paths and returns `/` for anything
else, including the sign-in page itself, so a stale `redirectTo` can't loop.
If sign-in isn't at `/login`, pass the same `{ loginPath }` to
`safeReturnTo`, `redirectIfSignedIn` and `requireSession`.
`redirectIfSignedIn` sends signed-in users on to `redirectTo`, checked the
same way. `connectAuth` runs it again after a sign-in.
## 3. Guard private routes
Guard private routes with a pathless layout. Its child routes are guarded
without `authenticated` appearing in their URLs:
```tsx title="src/routes/_authenticated.tsx"
import {
requireSession,
SessionOutlet,
useAuthClient,
} from "@monarcode/session-kit/tanstack-router";
import {
createFileRoute,
type ErrorComponentProps,
} from "@tanstack/react-router";
export const Route = createFileRoute("/_authenticated")({
beforeLoad: ({ context, location }) =>
requireSession(context.auth, { location }),
errorComponent: SessionError,
component: () => Checking session…
} />,
});
/** Shown after a failed refresh; a repeated failure stays in state.error. */
function SessionError({ error }: ErrorComponentProps) {
const auth = useAuthClient();
return (
{error instanceof Error ? error.message : "Something went wrong"}
);
}
```
`requireSession` redirects signed-out users to `/login`, or the `loginPath`
you pass, with `redirectTo` set to where they were going, and puts
`{ session }` in route context. A
failed refresh reaches the error component instead of a redirect, because the
user is still signed in.
`SessionOutlet` renders child routes while the session the guard accepted is
still the signed-in one. After a sign-out or an account switch it shows
`pending` until the guard runs again, and remounts child routes for the new
account, so nothing from the previous one stays on screen. Profile updates and
token refreshes keep child routes mounted.
Child routes read the session from route context, where `user` is typed and
never `null`:
```tsx title="src/routes/_authenticated/index.tsx"
import { useAuthClient } from "@monarcode/session-kit/tanstack-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_authenticated/")({
component: Home,
});
function Home() {
const { session } = Route.useRouteContext();
const auth = useAuthClient();
return (
Welcome, {session.user.firstName}
);
}
```
## 4. Create and connect the Router
Create the Router with the client in its context, then connect the two:
```tsx title="src/router.tsx"
import { connectAuth } from "@monarcode/session-kit/tanstack-router";
import { createRouter } from "@tanstack/react-router";
import { auth } from "./auth.js";
import { routeTree } from "./routeTree.gen.js";
export const router = createRouter({
routeTree,
context: { auth },
defaultPendingComponent: () =>
Loading…
,
});
// Restores the session, runs expiry timers and tab sync, and re-runs guards
// when auth changes. Call it once, beside the Router.
export const disconnectAuth = connectAuth(router);
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
```
`connectAuth` does for the Router what `AuthProvider` does for a plain React
app, and one thing more. When the session changes, it runs the guards again,
so a sign-out in another tab redirects this one.
Registering the Router also types `useAuth` and `useAuthClient` with your
client. Until it's registered, they're typed `never`.
## 5. Render the app
Render the Router:
```tsx title="src/main.tsx"
import { StrictMode } from "react";
import { RouterProvider } from "@tanstack/react-router";
import { createRoot } from "react-dom/client";
import { router } from "./router.js";
createRoot(document.getElementById("root")!).render(
,
);
```
To check the app:
1. Signed out, open `/`. The Router sends you to `/login`.
2. Sign in. You're back on `/`, with the welcome message.
3. Open a second tab and sign out there. The first tab goes to `/login`.
## 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 route's component, with `SessionOutlet` where
the pages go:
```tsx title="src/dashboard.tsx"
import { useState } from "react";
import { SessionOutlet, useAuth } from "@monarcode/session-kit/tanstack-router";
/** The guarded route's component: `component: Dashboard`. */
export function Dashboard() {
// From state, not route context: this stays mounted through a sign-out.
const user = useAuth((state) => state.user);
const sessionId = useAuth((state) => state.sessionId);
return (
{user ? `Signed in as ${user.firstName}` : null}
{/* Keyed by session, so one account's choices never show for the next. */}
Checking session…} />
);
}
function Sidebar() {
const [pinned, setPinned] = useState(false);
return (
);
}
```
In the guarded route's component, `SessionOutlet` reads the session that
route's guard accepted. Three things follow from where it sits:
- **It protects only what it renders.** After a sign-out or an account switch,
the pages are swapped for `pending` and remounted, but the layout around
them stays mounted until the guard redirects.
- **Read the user from state there.** In the layout, use
`useAuth((state) => state.user)` and handle `null`. Route context's
`session.user` is the session the guard accepted, which for that moment is
the previous one. Inside the pages, route context's session is always the
signed-in 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.
## TanStack Start in SPA mode
TanStack Start's SPA mode is TanStack Router with a build-time shell. Steps 2
and 3 apply as they are. Installation, step 1, step 4 and step 5 change:
- Install `@tanstack/react-start` instead of `@tanstack/router-plugin`.
- In `vite.config.ts`, use Start's plugin, with SPA mode on, instead of the
Router plugin:
`plugins: [tanstackStart({ spa: { enabled: true } }), react()]`.
- Give the root route a `shellComponent` that renders ``,
`` and ``, as the example app does. Start renders
the app from it, so there is no `src/main.tsx`.
In `src/router.tsx`, create the Router and connect auth inside
`getRouter()`, which Start calls:
```tsx
import { connectAuth } from "@monarcode/session-kit/tanstack-router";
import { createRouter } from "@tanstack/react-router";
import { auth } from "./auth.js";
import { routeTree } from "./routeTree.gen.js";
export function getRouter() {
const router = createRouter({
routeTree,
context: { auth },
defaultPendingComponent: () =>
Loading…
,
});
// Does nothing while the build prerenders the shell.
connectAuth(router);
return router;
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType;
}
}
```
And turn server rendering off for routes, in `src/start.ts`:
```ts title="src/start.ts"
import { createStart } from "@tanstack/react-start";
// The session is in browser storage, so guards must run in the browser.
export const startInstance = createStart(() => ({
defaultSsr: false,
}));
```
Start's own server still answers requests in SPA mode, and it has no saved
session to check. `connectAuth` does nothing while the Router renders on a
server, so the build-time shell shows your pending component. Complete apps
are in [`examples/tanstack-start-spa`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa) and,
written with Effect and effetch, in
[`examples/tanstack-start-spa-effetch`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa-effetch).
## Things to know
- **The first render.** With synchronous storage and a synchronous schema,
`connectAuth` restores the saved session before the first render, so a
signed-in user never sees the sign-in page.
- **Stopping.** Call `disconnectAuth` on app teardown or hot-module disposal.
It stops background work without signing out. In Start's SPA mode,
`getRouter()` doesn't keep it, as the Router lasts as long as the page.
- **A failed refresh is not a redirect.** `requireSession` rethrows it for the
route's error component, which can offer `auth.retry()`. The user is still
signed in.
- **Locked sessions.** [Locking](https://session-kit.pages.dev/docs/client/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
- [Call your API](https://session-kit.pages.dev/docs/getting-started#5-call-your-api) with `authFetch`, as
in Getting started, or with
[ky, axios or openapi-fetch](https://session-kit.pages.dev/docs/client/other-http-clients). It works
unchanged with the Router.
- [Using the client](https://session-kit.pages.dev/docs/client): updating the user, signing out, and the
client's other methods.
- [Effect and effetch](https://session-kit.pages.dev/docs/effect): the same client as an Effect service.
# TanStack Start (https://session-kit.pages.dev/docs/setups/tanstack-start)
In this guide, you keep the session in an encrypted cookie with TanStack
Start. The server signs in, refreshes the tokens before rendering, and sends
the signed-in page first, so the browser never shows a "Checking session…"
state or a flash of signed-out content. The browser holds a small client that
mirrors the session, and the
[TanStack Router hooks and guards](https://session-kit.pages.dev/docs/setups/tanstack-router) work with it.
[Server rendering](https://session-kit.pages.dev/docs/server-rendering) explains how the pieces fit.
For TanStack Start in SPA mode, where the browser keeps the session, follow
[TanStack Start in SPA mode](https://session-kit.pages.dev/docs/setups/tanstack-router#tanstack-start-in-spa-mode)
instead. A complete app is in
[`examples/tanstack-start-ssr`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-ssr).
## Before you begin
- Node 22.12 or newer, which Start needs.
- 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`.
## Installation
To install the packages, run the following command:
```npm
npm install @monarcode/session-kit @tanstack/react-start @tanstack/react-router zod
```
## 1. 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.
`startAuth` gives the session of the request Start is handling. Create both
in a server-only module, so Start refuses to bundle it for the browser:
```ts title="src/auth/server.ts"
import "@tanstack/react-start/server-only";
import { createServerAuth } from "@monarcode/session-kit/server";
import { startAuth } from "@monarcode/session-kit/tanstack-start/server";
import { z } from "zod";
const API = "https://api.example.com";
const userSchema = z.object({ id: z.number(), firstName: z.string() });
const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() });
export const serverAuth = createServerAuth({
name: "app",
user: userSchema,
secret: process.env.SESSION_SECRET!,
refresh: async ({ refreshToken, signal }) => {
const response = await fetch(`${API}/auth/refresh`, {
method: "POST",
signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
// The backend rejected the refresh token: sign out.
if (response.status === 401) return null;
if (!response.ok) throw new Error(`Refresh failed (${response.status})`);
return tokens.parse(await response.json());
},
});
/** The session of the request Start is handling, found on each call. */
export const session = startAuth(serverAuth);
/** Signs in with your backend, from the server. */
export async function login(username: string, password: string) {
const response = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
// The backend says why, such as wrong credentials, in `message`.
const failure = z.object({ message: z.string() }).safeParse(body);
throw new Error(
failure.success
? failure.data.message
: `Sign-in failed (${response.status})`,
);
}
return { ...tokens.parse(body), user: userSchema.parse(body) };
}
```
[Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions#the-cookie) covers
its other options, rotating the secret, signing out, and the limits every
setup shares.
## 2. Add the session route and server functions
The browser's client signs out and gets the access token through a session
route. `sessionRoute` from `@monarcode/session-kit/tanstack-start/server`
makes its `GET` and `POST` handlers, for a server route. Start leaves
`server.handlers` out of the browser bundle, `serverAuth` included:
```ts title="src/routes/api/session.ts"
import { sessionRoute } from "@monarcode/session-kit/tanstack-start/server";
import { createFileRoute } from "@tanstack/react-router";
import { serverAuth } from "../../auth/server";
export const Route = createFileRoute("/api/session")({
server: { handlers: sessionRoute(serverAuth) },
});
```
It answers your own site's pages only, so another site can't sign the user
out: see [The session route](https://session-kit.pages.dev/docs/security#the-session-route).
Two server functions do the rest. Signing in takes your form's input. Reading
the session is a server function too, because `loadSession` reads it while
Start renders on the server, where the client can't call a route on its own
site. `session` finds the request each time it's called, so one object
serves every request:
```ts title="src/auth/functions.ts"
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { login, session } from "./server";
export const getSessionFn = createServerFn().handler(() =>
session.getSession(),
);
export const signInFn = createServerFn({ method: "POST" })
.validator(z.object({ username: z.string(), password: z.string() }))
.handler(async ({ data }) => {
const tokens = await login(data.username, data.password);
return session.signIn(tokens);
});
```
`getSession` and `signIn` resolve `{ sessionId, user }`, never tokens, so
their results are safe to send to the browser. Start's compiler replaces each
handler with a request in the browser bundle, and leaves `session` out of it.
## 3. Refresh before rendering
Request middleware refreshes access that is about to expire before any route
or server function reads it, while the response can still set the cookie.
Once Start starts streaming a page, a cookie set later is lost, so a refresh
then, such as in a deferred loader's promise, would spend the refresh token
and drop the new one.
`refreshSession` from `@monarcode/session-kit/tanstack-start/server` does
the refresh. Pass it to Start's own `createMiddleware().server()`. Start
bundles `src/start.ts` for the browser too, but strips what's inside
`.server()`, so `serverAuth` stays on the server:
```ts title="src/start.ts"
import { refreshSession } from "@monarcode/session-kit/tanstack-start/server";
import {
createCsrfMiddleware,
createMiddleware,
createStart,
} from "@tanstack/react-start";
import { serverAuth } from "./auth/server";
const sessionMiddleware = createMiddleware({ type: "request" }).server(
refreshSession(serverAuth),
);
export const startInstance = createStart(() => ({
requestMiddleware: [
createCsrfMiddleware({
filter: ({ handlerType }) => handlerType === "serverFn",
}),
sessionMiddleware,
],
}));
```
It refreshes access that expires within 60 seconds. Access tokens that live
a minute or less are refreshed on every request, so pass a smaller `within`
for them, such as `refreshSession(serverAuth, { within: 15 })`.
A failed refresh doesn't fail the request. Routes read the session as
usual, and an error there reaches the route's error component.
An app that defines `createStart` has no CSRF protection for its server
functions until it adds `createCsrfMiddleware`. Its `filter` keeps the check
to server-function calls. Unfiltered, it also refuses page loads from links
on other sites and from the address bar. It leaves out server routes.
`sessionRoute` refuses other sites itself, and a server route of your own
that changes the session must too: see
[The session route](https://session-kit.pages.dev/docs/security#the-session-route).
## 4. Create the browser client
`createMirrorAuth` makes the client that routes and components use. Create it
in `getRouter`, which Start calls once per request on the server and once in
the browser, never at module level. `connectSession` sends the session the
server read to the browser with the page, and runs guards again when it
changes there.
First, the client:
```ts title="src/auth/client.ts"
import { createMirrorAuth } from "@monarcode/session-kit";
import { getSessionFn, signInFn } from "./functions";
export function createAppAuth() {
return createMirrorAuth({
name: "app",
session: null, // filled by loadSession, then by hydration
endpoint: "/api/session",
revalidate: () => getSessionFn(),
signIn: (data: { username: string; password: string }) =>
signInFn({ data }),
});
}
export type AppAuth = ReturnType;
```
Then the Router, which creates it and connects it:
```tsx title="src/router.tsx"
import { connectSession } from "@monarcode/session-kit/tanstack-start";
import { createRouter } from "@tanstack/react-router";
import { createAppAuth } from "./auth/client";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const auth = createAppAuth();
const router = createRouter({ routeTree, context: { auth } });
connectSession(router);
return router;
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType;
}
}
```
Let TypeScript infer the client's types from its options. With
`createMirrorAuth(…)`, `signIn` would be left out. A `dehydrate` and a
`hydrate` of your own, such as for a query cache, go to `createRouter` as
usual. `connectSession` keeps them.
## 5. Load the session, then guard routes
The root route's `beforeLoad` reads the request's session into the client
with `loadSession`, before any guard runs. In the browser, the hydrated
session is already there, so it doesn't call the server:
```tsx title="src/routes/__root.tsx"
import { loadSession } from "@monarcode/session-kit/tanstack-start";
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import type { AppAuth } from "../auth/client";
export const Route = createRootRouteWithContext<{ auth: AppAuth }>()({
beforeLoad: ({ context }) => loadSession(context.auth),
component: Outlet,
});
```
When the server can't read the session, such as after a failed refresh,
`loadSession` rejects, and the root route's `errorComponent` shows. In the
example app, it offers a retry with `router.invalidate()`, which runs
`loadSession` again.
Guards are the ones from `@monarcode/session-kit/tanstack-router`. On the
server, a redirect is a real HTTP redirect:
```tsx title="src/routes/_authenticated.tsx"
import {
requireSession,
SessionOutlet,
} from "@monarcode/session-kit/tanstack-router";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_authenticated")({
beforeLoad: ({ context, location }) =>
requireSession(context.auth, { location }),
component: () => Loading…} />,
});
```
[Guard private routes](https://session-kit.pages.dev/docs/setups/tanstack-router#3-guard-private-routes)
explains `SessionOutlet`. Its error component isn't needed here, as
`loadSession` reports a failed refresh first.
Guards decide what a page shows. Server functions that return private data
must check the session themselves, with `session.getSession()` or
`session.credentials.get()`.
## Sign in
The client's `signIn` sends what it's given to your `signInFn`, then adopts
the session it returns, re-runs guards and tells other tabs:
```ts title="src/sign-in.ts"
import type { AppAuth } from "./auth/client";
/** Signs in, and resolves the message to show when it fails. */
export async function signIn(
auth: AppAuth,
username: string,
password: string,
): Promise {
try {
await auth.signIn({ username, password });
return null;
} catch (error) {
// What signInFn threw, such as your backend's message for a wrong
// password.
return error instanceof Error ? error.message : "Could not sign in";
}
}
```
In a component, `useAuthClient` from `@monarcode/session-kit/tanstack-router`
gives you `auth`, from Router context.
Give the sign-in route the `redirectIfSignedIn` guard, as in TanStack Router's
[Add the sign-in route](https://session-kit.pages.dev/docs/setups/tanstack-router#2-add-the-sign-in-route).
`connectSession` runs it again after a sign-in, so the form doesn't navigate.
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, for
example through a provider's SDK, have `signInFn` take the tokens instead, as
[Hybrid setup](https://session-kit.pages.dev/docs/server-rendering/hybrid) shows.
## Sign out
The client's `signOut()` signs out on the server through the session route,
which clears the cookie and calls your `revoke` option, when
`createServerAuth` has one. It then signs out this tab and tells the others.
Guards run again, so `requireSession` sends the user to the sign-in page:
```tsx title="src/sign-out.tsx"
import { useAuthClient } from "@monarcode/session-kit/tanstack-router";
export function SignOutButton() {
const auth = useAuthClient();
// A failed sign-out keeps the session, and shows in `state.error`.
return (
);
}
```
## Call your API
Call your API with `createAuthFetch` from `@monarcode/session-kit/http`.
Given `session`, it sends the access token of the request Start is handling,
refreshed first when it has expired. After a 401, it renews the token, saves
it in the cookie, and sends a `GET` again. Make it once, in a server-only
module:
```ts title="src/auth/api-fetch.ts"
import "@tanstack/react-start/server-only";
import { createAuthFetch } from "@monarcode/session-kit/http";
import { session } from "./server";
export const apiFetch = createAuthFetch(session, {
baseUrl: "https://api.example.com/",
});
```
Then call it in a server function. Signed out, it rejects with
`UNAUTHENTICATED`:
```ts title="src/orders.ts"
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { apiFetch } from "./auth/api-fetch";
export const getOrdersFn = createServerFn().handler(async () => {
const response = await apiFetch("orders");
if (!response.ok) throw new Error(`Orders failed (${response.status})`);
return z.array(z.object({ id: z.number() })).parse(await response.json());
});
```
With an HTTP client of your own, such as ky or axios, see
[Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients). A client without a
`fetch` option reads the token with `session.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. 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](https://session-kit.pages.dev/docs/server-rendering/server-sessions#sign-out).
- **One session per request.** Each call to `session` reads the request
Start is handling, and calls during one request share what it read and
one refresh. Outside a request, such as in the browser, they reject with
`INVALID_OPTIONS`.
- **Caching.** Pages and server functions that read the session differ per
user. `refreshSession` sends `Cache-Control: private, no-store` with each
response that doesn't set its own, so a CDN or proxy doesn't cache them.
To leave every response as it is, pass `{ cacheControl: false }`.
- **Prerendering.** A prerendered page renders at build time, without a
cookie, so it shows the signed-out state. Don't prerender pages that read
the session.
- **Tabs.** Signing in or out in one tab reaches the others, which ask the
server 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](https://session-kit.pages.dev/docs/server-rendering/server-sessions).
## Next steps
- [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions): the cookie,
signing out, and the limits every server-rendered setup shares.
- [Using the client](https://session-kit.pages.dev/docs/client): what the browser client can do.
- [Effect on the server](https://session-kit.pages.dev/docs/effect#on-the-server): run Effects with a
request's session.
# React Router (https://session-kit.pages.dev/docs/setups/react-router)
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 | `requireSessionLoader` as the `loader` | `SessionOutlet`, `useAuthRevalidation` |
| Framework, with `ssr: false` | `requireSessionLoader` as the `clientLoader` | `SessionOutlet`, `useAuthRevalidation` |
Each mode has a complete example app. For framework mode with server
rendering, follow
[React Router with server rendering](https://session-kit.pages.dev/docs/setups/react-router-ssr) instead.
## Before you begin
Follow [Getting started](https://session-kit.pages.dev/docs/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
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:
```tsx title="src/home.tsx"
import { useAuth, useAuthClient } from "@monarcode/session-kit/react";
export function Home() {
const user = useAuth((state) => state.user);
const auth = useAuthClient();
return (
Welcome, {user?.firstName}
);
}
```
## Declarative mode
Declarative mode has no loaders, so `SessionOutlet` is the guard. Render it
as a layout route's element, `}>`, and the routes
inside it render only while someone is signed in:
```tsx title="src/main.tsx"
import { StrictMode } from "react";
import {
BrowserRouter,
Navigate,
Route,
Routes,
useSearchParams,
} from "react-router";
import { isSignedIn, safeReturnTo } from "@monarcode/session-kit";
import { AuthProvider, useAuth } from "@monarcode/session-kit/react";
import { SessionOutlet } from "@monarcode/session-kit/react-router";
import { createRoot } from "react-dom/client";
import { auth } from "./auth.js";
import { Home } from "./home.js";
import { SignIn } from "./sign-in.js";
// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
interface Register {
auth: typeof auth;
}
}
/** The guard: its child routes render only while someone is signed in. */
function Private() {
return (
Checking session…}
unavailable={({ error, retry }) => (
{error?.message ?? "Can't reach the server."}
)}
/>
);
}
/** Signing in changes the state, which renders this again and redirects. */
function Login() {
const signedIn = useAuth(isSignedIn);
const [search] = useSearchParams();
if (signedIn)
return ;
return ;
}
createRoot(document.getElementById("root")!).render(
} />
}>
} />
,
);
```
`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`](https://github.com/monarcode/session-kit/tree/main/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:
```tsx title="src/router.tsx"
import { createBrowserRouter, Outlet, useRouteError } from "react-router";
import { useAuthClient } from "@monarcode/session-kit/react";
import {
redirectIfSignedInLoader,
requireSessionLoader,
SessionOutlet,
useAuthRevalidation,
} from "@monarcode/session-kit/react-router";
import { auth } from "./auth.js";
import { Home } from "./home.js";
import { SignIn } from "./sign-in.js";
/** Runs the loaders again when auth changes, and with them the guards. */
function Root() {
useAuthRevalidation();
return ;
}
/** Shown after a failed refresh; a repeated failure shows here again. */
function SessionError() {
const error = useRouteError();
const client = useAuthClient();
return (
{error instanceof Error ? error.message : "Something went wrong"}
,
children: [
{
path: "/login",
loader: redirectIfSignedInLoader(auth),
element: ,
},
{
loader: requireSessionLoader(auth),
errorElement: ,
element: Checking session…} />,
children: [{ index: true, element: }],
},
],
},
]);
```
Then render the router inside the provider, in `src/main.tsx`:
```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(
,
);
```
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](#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`](https://github.com/monarcode/session-kit/tree/main/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](#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:
```ts
// 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:
```tsx title="app/root.tsx"
import type { ReactNode } from "react";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import { AuthProvider } from "@monarcode/session-kit/react";
import { useAuthRevalidation } from "@monarcode/session-kit/react-router";
import { auth } from "./auth.js";
// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
interface Register {
auth: typeof auth;
}
}
export function Layout({ children }: { children: ReactNode }) {
return (
{children}
);
}
/** Runs the client loaders again when auth changes, and with them the guards. */
function Revalidate() {
useAuthRevalidation();
return ;
}
export default function App() {
return (
);
}
/** Prerendered into index.html, and shown until the client loaders finish. */
export function HydrateFallback() {
return
Checking session…
;
}
```
Guard a layout route, and nest the private routes under it:
```tsx title="app/routes/protected.tsx"
import { useRouteError } from "react-router";
import { useAuthClient } from "@monarcode/session-kit/react";
import {
requireSessionLoader,
SessionOutlet,
} from "@monarcode/session-kit/react-router";
import { auth } from "../auth.js";
export const clientLoader = requireSessionLoader(auth);
export default function Protected() {
return Checking session…} />;
}
/** Shown after a failed refresh; a repeated failure shows here again. */
export function ErrorBoundary() {
const error = useRouteError();
const client = useAuthClient();
return (
{error instanceof Error ? error.message : "Something went wrong"}
);
}
```
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:
```tsx title="app/routes/login.tsx"
import { redirectIfSignedInLoader } from "@monarcode/session-kit/react-router";
import { auth } from "../auth.js";
import { SignIn } from "../sign-in.js";
export const clientLoader = redirectIfSignedInLoader(auth);
export default SignIn;
```
In `app/routes.ts`, nest the routes the layout protects:
```ts
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`](https://github.com/monarcode/session-kit/tree/main/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](#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:
```tsx title="app/routes/profile.tsx"
import { useLoaderData } from "react-router";
import { requireSession } from "@monarcode/session-kit/react-router";
import { fetchProfile } from "../api.js";
import { auth } from "../auth.js";
export async function clientLoader({ request }: { request: Request }) {
const { session } = await requireSession(auth, { request });
return { name: session.user.firstName, profile: await fetchProfile() };
}
export default function Profile() {
const { name, profile } = useLoaderData();
return (
{name}, your email is {profile.email}.
);
}
```
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:
```tsx title="src/dashboard.tsx"
import { useAuth } from "@monarcode/session-kit/react";
import { SessionOutlet } from "@monarcode/session-kit/react-router";
/** The guarded route's element: `element: `. */
export function Dashboard() {
// From state, not loader data: this stays mounted through a sign-out.
const user = useAuth((state) => state.user);
const sessionId = useAuth((state) => state.sessionId);
return (
{user ? `Signed in as ${user.firstName}` : 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](https://session-kit.pages.dev/docs/client/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
- [Call your API](https://session-kit.pages.dev/docs/getting-started#5-call-your-api) with `authFetch`, as
in Getting started, or with
[ky, axios or openapi-fetch](https://session-kit.pages.dev/docs/client/other-http-clients). It works
unchanged with the router.
- [Using the client](https://session-kit.pages.dev/docs/client): updating the user, signing out, and the
client's other methods.
- [Effect and effetch](https://session-kit.pages.dev/docs/effect): the same client as an Effect service.
# React Router with server rendering (https://session-kit.pages.dev/docs/setups/react-router-ssr)
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](https://session-kit.pages.dev/docs/getting-started#4-read-the-session-in-react) work with it.
[Server rendering](https://session-kit.pages.dev/docs/server-rendering) explains how the pieces fit.
For framework mode with `ssr: false`, follow
[React Router](https://session-kit.pages.dev/docs/setups/react-router#framework-mode-with-ssr-false)
instead. A complete app is in
[`examples/react-router-framework-ssr`](https://github.com/monarcode/session-kit/tree/main/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 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
npm install @monarcode/session-kit react-router zod
```
## 1. 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:
```ts title="app/auth/session.server.ts"
import {
requireSession,
routerAuth,
} from "@monarcode/session-kit/react-router/server";
import { createServerAuth } from "@monarcode/session-kit/server";
import { z } from "zod";
const API = "https://api.example.com";
const userSchema = z.object({ id: z.number(), firstName: z.string() });
const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() });
export const serverAuth = createServerAuth({
name: "app",
user: userSchema,
secret: process.env.SESSION_SECRET!,
refresh: async ({ refreshToken, signal }) => {
const response = await fetch(`${API}/auth/refresh`, {
method: "POST",
signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
// The backend rejected the refresh token: sign out.
if (response.status === 401) return null;
if (!response.ok) throw new Error(`Refresh failed (${response.status})`);
return tokens.parse(await response.json());
},
});
/** The request's session: `auth(context)` in middleware, loaders and actions. */
export const auth = routerAuth(serverAuth);
/** Lets only signed-in users through to the routes it guards. */
export const signedIn = requireSession(serverAuth);
/** Signs in with your backend, from the server. */
export async function login(username: string, password: string) {
const response = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
// The backend says why, such as wrong credentials, in `message`.
const failure = z.object({ message: z.string() }).safeParse(body);
throw new Error(
failure.success
? failure.data.message
: `Sign-in failed (${response.status})`,
);
}
return { ...tokens.parse(body), user: userSchema.parse(body) };
}
```
[Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions#the-cookie) 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:
```ts title="app/auth/client.ts"
import { createMirrorAuth, type SessionOf } from "@monarcode/session-kit";
// Types only: left out of the browser's bundle.
import type { serverAuth } from "./session.server";
export type AppSession = SessionOf;
export function createAppAuth(session: AppSession | null) {
return createMirrorAuth({
name: "app",
session,
endpoint: "/auth/session",
});
}
export type AppAuth = ReturnType;
```
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:
```tsx title="app/root.tsx"
import type { ReactNode } from "react";
import {
Links,
Meta,
Outlet,
Scripts,
useLoaderData,
useRevalidator,
type LoaderFunctionArgs,
} from "react-router";
import { AuthProvider, useMirrorAuth } from "@monarcode/session-kit/react";
import { sessionMiddleware } from "@monarcode/session-kit/react-router/server";
import { createAppAuth, type AppAuth } from "./auth/client";
import { auth, serverAuth } from "./auth/session.server";
// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
interface Register {
auth: AppAuth;
}
}
export const middleware = sessionMiddleware(serverAuth);
export async function loader({ context }: LoaderFunctionArgs) {
return { session: await auth(context).getSession() };
}
export function Layout({ children }: { children: ReactNode }) {
return (
{children}
);
}
export default function App() {
const { session } = useLoaderData();
const { revalidate } = useRevalidator();
const auth = useMirrorAuth({
create: createAppAuth,
session,
onChange: revalidate,
});
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](https://session-kit.pages.dev/docs/security#the-session-route) explains.
The form works before the page's scripts load:
```tsx title="app/routes/login.tsx"
import {
data,
Form,
redirect,
useActionData,
type ActionFunctionArgs,
} from "react-router";
import { safeReturnTo } from "@monarcode/session-kit";
import { redirectIfSignedIn } from "@monarcode/session-kit/react-router/server";
import { auth, login, serverAuth } from "../auth/session.server";
// A signed-in user goes on to `redirectTo`.
export const loader = redirectIfSignedIn(serverAuth);
export async function action({ request, context, url }: ActionFunctionArgs) {
const form = await request.formData();
try {
const tokens = await login(
String(form.get("username")),
String(form.get("password")),
);
await auth(context).signIn(tokens);
} catch (error) {
return data(
{ error: error instanceof Error ? error.message : "Sign-in failed" },
{ status: 400 },
);
}
throw redirect(safeReturnTo(url.searchParams.get("redirectTo")));
}
export default function Login() {
const result = useActionData();
return (
);
}
```
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:
```ts title="app/routes/auth.session.ts"
import { sessionRoute } from "@monarcode/session-kit/react-router/server";
import { serverAuth } from "../auth/session.server";
const route = sessionRoute(serverAuth);
export const loader = route.loader;
export const action = route.action;
```
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](https://session-kit.pages.dev/docs/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:
```tsx title="app/routes/protected.tsx"
import { Outlet } from "react-router";
import { signedIn } from "../auth/session.server";
export const middleware = [signedIn];
// So navigation in the browser runs the middleware.
export const loader = () => null;
export default function Protected() {
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:
```tsx title="app/routes/home.tsx"
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
import { signedIn } from "../auth/session.server";
export function loader({ context }: LoaderFunctionArgs) {
return { name: signedIn.session(context).user.firstName };
}
export default function Home() {
const { name } = useLoaderData();
return
Welcome, {name}
;
}
```
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](https://session-kit.pages.dev/docs/server-rendering/hybrid) 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:
```tsx title="app/routes/orders.tsx"
import { redirect, useLoaderData, type LoaderFunctionArgs } from "react-router";
import { loginHref } from "@monarcode/session-kit";
import { createAuthFetch } from "@monarcode/session-kit/http";
import { z } from "zod";
import { auth } from "../auth/session.server";
export async function loader({ context }: LoaderFunctionArgs) {
const session = auth(context);
// Outside the guarded layout: send the user to sign in, then back here.
if (!(await session.getSession()))
throw redirect(loginHref("/login", "/orders"));
const apiFetch = createAuthFetch(session, {
baseUrl: "https://api.example.com/",
});
const response = await apiFetch("orders");
if (!response.ok) throw new Error(`Orders failed (${response.status})`);
return z.array(z.object({ id: z.number() })).parse(await response.json());
}
export default function Orders() {
const orders = useLoaderData();
return
{orders.length} orders
;
}
```
With an HTTP client of your own, such as ky or axios, see
[Other HTTP clients](https://session-kit.pages.dev/docs/client/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](https://session-kit.pages.dev/docs/server-rendering/server-sessions#the-access-token-in-the-browser).
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](https://session-kit.pages.dev/docs/server-rendering/server-sessions#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 with
`REFRESH_FAILED`, since nothing new can be saved. `sessionMiddleware`
refreshes before that.
- **One session per request.** `auth(context)` returns the same session for
every call with the same `context`, which React Router makes per request.
- **Caching.** `sessionMiddleware` leaves a response that sets its own
`Cache-Control` as it is. Pass `cacheControl` to send another value, or
`{ cacheControl: false }` to leave every response as it is.
- **Guards with the same names.** `requireSession` and `redirectIfSignedIn`
on this page come from `@monarcode/session-kit/react-router/server`.
`@monarcode/session-kit/react-router` exports guards with the same names
that take the browser's client, for apps without server rendering: see
[React Router](https://session-kit.pages.dev/docs/setups/react-router).
- **A `basename`.** Pass it to the guard too, as
`requireSession(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's `endpoint` includes 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](https://session-kit.pages.dev/docs/server-rendering/server-sessions).
## Next steps
- [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions): the cookie,
signing out, and the limits every server-rendered setup shares.
- [Using the client](https://session-kit.pages.dev/docs/client): what the browser client can do.
- [Effect on the server](https://session-kit.pages.dev/docs/effect#on-the-server): run Effects with a
request's session.
# Next.js (https://session-kit.pages.dev/docs/setups/next)
In this guide, you keep the session in an encrypted cookie with the Next.js
App Router. A Server Function signs in, a session route answers the browser,
Proxy refreshes the tokens, and Server Components 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. Client
Components get a small client that mirrors the session, and the
[hooks](https://session-kit.pages.dev/docs/getting-started#4-read-the-session-in-react) work with it.
[Server rendering](https://session-kit.pages.dev/docs/server-rendering) explains how the pieces fit.
A complete app is in
[`examples/next-app`](https://github.com/monarcode/session-kit/tree/main/examples/next-app).
## Before you begin
- Next.js 16, with the App Router.
- 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`. `next build` doesn't need it.
## Installation
To install the packages, run the following command:
```npm
npm install @monarcode/session-kit next zod
```
## 1. 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. Create
it in a server-only module, so Next refuses to bundle it for the browser:
```ts title="lib/auth/server.ts"
import "server-only";
import { createServerAuth } from "@monarcode/session-kit/server";
import { z } from "zod";
const API = "https://api.example.com";
const userSchema = z.object({ id: z.number(), firstName: z.string() });
const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() });
export const serverAuth = createServerAuth({
name: "app",
user: userSchema,
secret: process.env.SESSION_SECRET!,
refresh: async ({ refreshToken, signal }) => {
const response = await fetch(`${API}/auth/refresh`, {
method: "POST",
signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
// The backend rejected the refresh token: sign out.
if (response.status === 401) return null;
if (!response.ok) throw new Error(`Refresh failed (${response.status})`);
return tokens.parse(await response.json());
},
});
/** Signs in with your backend, from the server. */
export async function login(username: string, password: string) {
const response = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
// The backend says why, such as wrong credentials, in `message`.
const failure = z.object({ message: z.string() }).safeParse(body);
throw new Error(
failure.success
? failure.data.message
: `Sign-in failed (${response.status})`,
);
}
return { ...tokens.parse(body), user: userSchema.parse(body) };
}
```
[Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions#the-cookie) covers
its other options, rotating the secret, signing out, and the limits every
setup shares.
## 2. Read the session in Server Components
`nextSession` from `@monarcode/session-kit/next` gives your app its session
over Next's cookies. Session kit doesn't import `next`, so pass it Next's own
`cookies` and `redirect`. Create it in a server-only module too:
```ts title="lib/auth/session.ts"
import "server-only";
import { nextSession } from "@monarcode/session-kit/next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { serverAuth } from "./server";
export const session = nextSession(serverAuth, { cookies, redirect });
```
In a Server Component, `await session.get()` resolves `{ sessionId, user }`
or `null`. It holds no tokens, so it's safe to pass to Client Components. It
doesn't set cookies, as Server Components can't. Proxy refreshes first.
## 3. Refresh in Proxy
Proxy refreshes access that is about to expire before any page or Server
Function reads it. `session.refresh(request, response)` sets the new cookie
on the response, and Next passes it on to the Server Components rendering
the same request:
```ts title="proxy.ts"
import { NextResponse, type NextRequest } from "next/server";
import { session } from "./lib/auth/session";
export function proxy(request: NextRequest) {
return session.refresh(request, NextResponse.next());
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
```
If the refresh fails, the page still renders with the cookie as it is.
Server Functions are POST requests to the page that calls them, so keep the
matcher wide enough to include those pages.
## 4. Add the session route and sign-in
The browser's client reads the session and signs out through a session
route. `sessionRoute` from `@monarcode/session-kit/next` makes its `GET` and
`POST` handlers. Pass it Next's `cookies`:
```ts title="app/api/session/route.ts"
import { sessionRoute } from "@monarcode/session-kit/next";
import { cookies } from "next/headers";
import { serverAuth } from "../../../lib/auth/server";
export const { GET, POST } = sessionRoute(serverAuth, { cookies });
```
It answers your own site's pages only, so another site can't sign the user
out: see [Security](https://session-kit.pages.dev/docs/security).
Signing in is yours, as it takes your form's input. A Server Function sets
the cookie with `session.signIn`:
```ts title="app/actions.ts"
"use server";
import { z } from "zod";
import { login } from "../lib/auth/server";
import { session } from "../lib/auth/session";
const credentials = z.object({ username: z.string(), password: z.string() });
export async function signInAction(input: unknown) {
const { username, password } = credentials.parse(input);
try {
const tokens = await login(username, password);
return { session: await session.signIn(tokens) };
} catch (error) {
return {
error: error instanceof Error ? error.message : "Sign-in failed",
};
}
}
```
`signInAction` returns its failure rather than throwing it, as Next advises
for expected errors. In production, Next hides a thrown error's message from
the browser.
## 5. Create the browser client
`createMirrorAuth` makes the client that Client Components use, from the
session the layout read, the session route and your sign-in:
```ts title="lib/auth/client.ts"
import { createMirrorAuth, type SessionOf } from "@monarcode/session-kit";
import { signInAction } from "../../app/actions";
// Types only: left out of the browser's bundle.
import type { serverAuth } from "./server";
export type AppSession = SessionOf;
export function createAppAuth(session: AppSession | null) {
return createMirrorAuth({
name: "app",
session,
endpoint: "/api/session",
signIn: async (input: { username: string; password: string }) => {
const result = await signInAction(input);
if ("error" in result) throw new Error(result.error);
return result.session;
},
});
}
export type AppAuth = ReturnType;
```
`useMirrorAuth` from `@monarcode/session-kit/react` creates the client in a
Client Component, 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 `router.refresh()`, which renders
Server Components again. Register the client's type where you render
`AuthProvider`, so the hooks carry your user:
```tsx title="app/providers.tsx"
"use client";
import type { ReactNode } from "react";
import { AuthProvider, useMirrorAuth } from "@monarcode/session-kit/react";
import { useRouter } from "next/navigation";
import {
createAppAuth,
type AppAuth,
type AppSession,
} from "../lib/auth/client";
// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
interface Register {
auth: AppAuth;
}
}
export function Providers(props: {
session: AppSession | null;
children: ReactNode;
}) {
const router = useRouter();
const auth = useMirrorAuth({
create: createAppAuth,
session: props.session,
onChange: () => router.refresh(),
});
return {props.children};
}
```
Render the providers in the root layout, with the session it reads:
```tsx title="app/layout.tsx"
import type { ReactNode } from "react";
import { session } from "../lib/auth/session";
import { Providers } from "./providers";
export default async function RootLayout(props: { children: ReactNode }) {
const current = await session.get();
return (
{props.children}
);
}
```
## 6. Protect pages
Check the session in each page with `session.require`, rather than in a
layout. Layouts don't render again on navigation. It takes the page's own
path, where the sign-in page sends the user back, as Server Components don't
know their URL:
```tsx title="app/page.tsx"
import { session } from "../lib/auth/session";
export default async function Home() {
const { user } = await session.require("/");
return
Welcome, {user.firstName}
;
}
```
The sign-in page sends a signed-in user on to `redirectTo` with
`session.redirectIfSignedIn`, and its form calls the client's `signIn`. When
the session changes in the browser, after a sign-in or sign-out here or in
another tab, `useMirrorAuth` renders the page again on the server, so these
redirects run without code in the form:
```tsx title="app/login/page.tsx"
import { session } from "../../lib/auth/session";
import { SignInForm } from "./sign-in-form";
export default async function Login({
searchParams,
}: {
searchParams: Promise>;
}) {
await session.redirectIfSignedIn((await searchParams).redirectTo);
return ;
}
```
The form is a Client Component that calls the client's `signIn`:
```tsx title="app/login/sign-in-form.tsx"
"use client";
import { useState, type FormEvent } from "react";
import { useAuthClient } from "@monarcode/session-kit/react";
export function SignInForm() {
const auth = useAuthClient();
const [error, setError] = useState("");
async function submit(event: FormEvent) {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await auth.signIn({
username: String(form.get("username")),
password: String(form.get("password")),
});
} catch (failure) {
// What the client's signIn option threw, from signInAction's error.
setError(failure instanceof Error ? failure.message : "Sign-in failed");
}
}
return (
);
}
```
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
`signInAction` take the tokens instead, as
[Hybrid setup](https://session-kit.pages.dev/docs/server-rendering/hybrid) shows for TanStack Start.
## Call your API
Call your API with `createAuthFetch` from `@monarcode/session-kit/http`. It
sends the request's access token to your API's origin only. In Server
Components, pass it `session.readOnly()`, the request's session that doesn't
set cookies. Proxy refreshed the token before the render. A token that
expired anyway rejects with `REFRESH_FAILED`, and so does a 401 from your API,
since nothing new can be saved:
```tsx title="app/orders/page.tsx"
import { createAuthFetch } from "@monarcode/session-kit/http";
import { z } from "zod";
import { session } from "../../lib/auth/session";
export default async function Orders() {
await session.require("/orders");
const apiFetch = createAuthFetch(await session.readOnly(), {
baseUrl: "https://api.example.com/",
});
const response = await apiFetch("orders");
if (!response.ok) throw new Error(`Orders failed (${response.status})`);
const orders = z
.array(z.object({ id: z.number() }))
.parse(await response.json());
return
{orders.length} orders
;
}
```
In Server Functions and Route Handlers, pass `await session.writable()`
instead, the request's session that can set cookies. After a 401, it renews the token, saves it in the cookie, and sends a
`GET` again. With an HTTP client of your own, such as ky or axios, see
[Other HTTP clients](https://session-kit.pages.dev/docs/client/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](https://session-kit.pages.dev/docs/server-rendering/server-sessions#the-access-token-in-the-browser).
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](https://session-kit.pages.dev/docs/server-rendering/server-sessions#sign-out).
- **Writes only where Next allows them.** Use `signIn`, `signOut`,
`updateUser` and `writable()` in Server Functions and Route Handlers only.
In a Server Component they can't set the cookie, and `writable()` would
refresh and then fail to save the new tokens, which spends a rotating
refresh token.
- **One read per request.** Calls to `session` during one request read the
cookie once, and a sign-in through `signIn` shows in `get()`.
- **Separate instances.** Next builds Proxy, Route Handlers, and pages with
their Server Functions separately, so each has its own `serverAuth`, even
on one server. They share no refresh. A matcher that covers every route
that reads the session lets Proxy refresh first, so Route Handlers and
Server Functions rarely need to.
- **A `basePath`.** Next doesn't add it to `fetch`, so the client's
`endpoint` includes it: `"/app/api/session"`. Nor does it add it to a
Route Handler's redirects, so a Route Handler reads the session with
`get` and answers 401, rather than calling `require`.
- **Rendering.** Reading cookies makes a page dynamic, and the root layout
reads the session, so every page is dynamic. Next sends such pages with
`Cache-Control: no-store`, and plain `use cache` can't read cookies.
Keep pages that read the session on the Node.js runtime, the default.
Next may not pass Proxy's refreshed cookie on to Edge runtime pages.
- **Tabs.** Signing in or out in one tab reaches the others, which ask the
server again and render their pages 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](https://session-kit.pages.dev/docs/server-rendering/server-sessions).
## Next steps
- [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions): the cookie,
signing out, and the limits every server-rendered setup shares.
- [Using the client](https://session-kit.pages.dev/docs/client): what the browser client can do.
- [Effect on the server](https://session-kit.pages.dev/docs/effect#on-the-server): run Effects with a
request's session.
# Expo Router (https://session-kit.pages.dev/docs/setups/expo)
In this guide, you build an Expo Router app for iOS and Android that keeps
the session in the device's secure storage, holds the splash screen while it
restores, and keeps deep links across sign-in. Expo Router's
`Stack.Protected` guards the screens, and `SessionGate` wraps the root
navigator. It holds the splash screen while the session is restored,
renders `unavailable` after a failed refresh, and checks the session again
when the app returns to the foreground.
This page builds the
[`expo-router` example app](https://github.com/monarcode/session-kit/tree/main/examples/expo-router)
from scratch. It signs in against
[DummyJSON's auth API](https://dummyjson.com/docs/auth), so it runs as
written. Its `src/auth.ts` takes the place of Getting started's `backend.ts`
and `auth.ts`. [Using the client](https://session-kit.pages.dev/docs/client),
[Refresh](https://session-kit.pages.dev/docs/configuration/refresh) and
[Errors](https://session-kit.pages.dev/docs/configuration/errors) explain how the client works. This page
covers what differs on Expo.
## Before you begin
- Expo SDK 53 or newer: Expo Router 5 or newer, and React Native 0.79 or
newer.
- iOS and Android only. Expo web is not supported. On the web, use a
[web router setup](https://session-kit.pages.dev/docs/installation#pick-your-setup) with `webStorage()`.
- Expo Go, which includes `expo-secure-store` and `expo-file-system`, or a
development build with both installed.
## Installation
Install the package, its Expo peers, and a schema library. `npx expo install`
picks the peer versions that match your SDK:
```sh
npx expo install @monarcode/session-kit expo-secure-store expo-file-system zod
```
An Expo Router app already has `expo-router` and `react-native`. Holding the
splash screen needs `expo-splash-screen`, so install it too if your app
doesn't have it. Add the `expo-secure-store` config plugin to `app.json`, so
Android Auto Backup leaves the session out of backups:
```json
{
"expo": {
"scheme": "myapp",
"plugins": ["expo-router", "expo-secure-store"]
}
}
```
`scheme` is what custom-scheme deep links such as `myapp://profile` use.
## 1. Create the client
`secureStorage()` replaces `webStorage()`. The rest of the client is the same
as on the web:
```ts title="src/auth.ts"
import { createAuth } from "@monarcode/session-kit";
import { secureStorage } from "@monarcode/session-kit/expo";
import { createAuthFetch } from "@monarcode/session-kit/http";
import { z } from "zod";
const API = "https://dummyjson.com";
export const userSchema = z.object({
id: z.number(),
username: z.string(),
email: z.string(),
firstName: z.string(),
});
const tokensSchema = z.object({
accessToken: z.string(),
refreshToken: z.string(),
});
const loginResponseSchema = tokensSchema.extend(userSchema.shape);
export const auth = createAuth({
name: "my-app",
user: userSchema,
storage: secureStorage(),
refresh: async ({ refreshToken, signal }) => {
const response = await fetch(`${API}/auth/refresh`, {
method: "POST",
signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
// DummyJSON answers 401 or 403 when the refresh token is no good.
if (response.status === 401 || response.status === 403) return null;
if (!response.ok) throw new Error(`Refresh failed (${response.status})`);
return tokensSchema.parse(await response.json());
},
});
export const authFetch = createAuthFetch(auth, { baseUrl: `${API}/` });
export async function signInWithPassword(username: string, password: string) {
const response = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!response.ok) throw new Error(`Sign-in failed (${response.status})`);
const { accessToken, refreshToken, ...user } = loginResponseSchema.parse(
await response.json(),
);
await auth.signIn({ accessToken, refreshToken, user });
}
```
## 2. Gate the root layout
`SessionGate` goes inside `AuthProvider`, around the root navigator. Guard the
screens with `Stack.Protected`. Signed-in users see the `(app)` group, and
everyone else sees `login`. Register the client's type here, so the hooks
from `@monarcode/session-kit/react` carry your user:
```tsx title="app/_layout.tsx"
import { isSignedIn } from "@monarcode/session-kit";
import { SessionGate } from "@monarcode/session-kit/expo";
import { AuthProvider, useAuth } from "@monarcode/session-kit/react";
import { SplashScreen, Stack } from "expo-router";
import { auth } from "../src/auth";
import { Unavailable } from "../src/unavailable";
// Types `AuthProvider` and the hooks with this client.
declare module "@monarcode/session-kit" {
interface Register {
auth: typeof auth;
}
}
// Keeps the splash screen up until SessionGate hides it.
void SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
return (
}>
);
}
function RootStack() {
// Not signed in while locked, though a locked session keeps its user.
const signedIn = useAuth(isSignedIn);
return (
);
}
```
`RootStack` is its own component so that `useAuth` runs inside
`AuthProvider`. The navigator mounts only once the session is restored, so the
guards never see `initializing`.
If the first screen needs more than the session, such as fonts, pass `ready`.
The splash screen then stays up until both are done. Count a failed load as
done, or the splash screen never hides:
```tsx
const [fontsLoaded, fontError] = useFonts(fonts);
}
>
;
```
## 3. Keep deep links
`Stack.Protected` sends a link to a guarded screen to the first screen the
user may see, so a signed-out user who opens `myapp://profile` lands on the
sign-in screen and the link is lost. `deepLinkHandler` keeps it, and
`SessionGate` opens it after the sign-in. Expo Router calls
`redirectSystemPath` with every URL that opens the app, before routing:
```tsx title="app/+native-intent.tsx"
import { deepLinkHandler } from "@monarcode/session-kit/expo";
import { auth } from "../src/auth";
export const redirectSystemPath = deepLinkHandler(auth);
```
It returns the path unchanged. The link still goes to Expo Router, which
shows it right away when the user is signed in. If your `redirectSystemPath`
does more, such as rewriting old links, call `rememberDeepLink(auth, path)`
in it instead.
## 4. Add the sign-in screen
The sign-in screen calls `signInWithPassword` from `src/auth.ts`:
```tsx title="app/login.tsx"
import { useState } from "react";
import { Button, Text, TextInput, View } from "react-native";
import { signInWithPassword } from "../src/auth";
export default function Login() {
// DummyJSON's documented test user.
const [username, setUsername] = useState("emilys");
const [password, setPassword] = useState("emilyspass");
const [error, setError] = useState("");
async function submit() {
setError("");
try {
await signInWithPassword(username, password);
// No navigation: Stack.Protected leaves this screen, and
// SessionGate opens the deep link the user was headed to.
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not sign in");
}
}
return (
{error ? {error} : null}
);
}
```
Don't navigate after `signIn`. Once the user is signed in, `Stack.Protected`
removes the sign-in screen, and a `router.replace` of your own would race the
deep link `SessionGate` opens.
## 5. Add the signed-in screens
Screens inside the guarded group read the user with `useAuth`, and call the API
with `authFetch`, which attaches the access token and refreshes after a 401. To
use axios or another HTTP client instead, see
[Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients). The group's layout is a
plain stack:
```tsx title="app/(app)/_layout.tsx"
import { Stack } from "expo-router";
export default function AppLayout() {
return ;
}
```
Its first screen shows the user's name, links to the profile, and signs out:
```tsx title="app/(app)/index.tsx"
import { Button, Text, View } from "react-native";
import { useAuth, useAuthClient } from "@monarcode/session-kit/react";
import { Link } from "expo-router";
export default function Home() {
const firstName = useAuth((state) => state.user?.firstName);
const auth = useAuthClient();
return (
Welcome, {firstName}
Profile
{/* No navigation: Stack.Protected then shows the sign-in screen. */}
);
}
```
The profile screen fetches the user from the API. It's also where a deep
link such as `myapp://profile` goes:
```tsx title="app/(app)/profile.tsx"
import { useEffect, useState } from "react";
import { Text, View } from "react-native";
import { z } from "zod";
import { authFetch, userSchema } from "../../src/auth";
const profileSchema = userSchema.extend({
lastName: z.string(),
gender: z.string(),
});
export default function Profile() {
const [profile, setProfile] = useState>();
const [error, setError] = useState("");
useEffect(function loadProfile() {
const controller = new AbortController();
authFetch("auth/me", { signal: controller.signal })
.then(async (response) => {
if (!response.ok)
throw new Error(`Profile failed (${response.status})`);
setProfile(profileSchema.parse(await response.json()));
})
.catch((cause: unknown) => {
if (controller.signal.aborted) return;
setError(cause instanceof Error ? cause.message : "Could not load");
});
return function cancelLoad() {
controller.abort();
};
}, []);
if (error) return {error};
if (!profile) return Loading…;
return (
{profile.firstName} {profile.lastName}
{profile.email}{profile.gender}
);
}
```
## 6. Show unavailable access
Access is `unavailable` when the session exists but can't be used: a refresh
failed, often because the device is offline, or the keychain couldn't be
read. `SessionGate` then renders `unavailable` instead of the navigator:
```tsx title="src/unavailable.tsx"
import { Button, Text, View } from "react-native";
import type { UnavailableProps } from "@monarcode/session-kit/expo";
import { auth } from "./auth";
export function Unavailable({ error, retry }: UnavailableProps) {
return (
Can't reach your account{error?.message ?? "Check your connection and try again."}
{/* Works offline: it clears the session on this device. */}
);
}
```
Without `unavailable`, `SessionGate` hides the splash screen and throws the
error to the root layout's `ErrorBoundary`. The boundary's `retry` only
renders again, and a failed refresh stays failed until `auth.retry()`, so
have the boundary call both.
To check the app:
1. Sign in. The home screen shows your name. Open the profile to see your
email.
2. Close the app and open it again. The splash screen stays up until the
session is restored, then the home screen shows.
3. Sign out. The sign-in screen shows.
4. Signed out, open `myapp://profile`. On the iOS Simulator, run
`xcrun simctl openurl booted myapp://profile`. In Expo Go, open
`exp://:8081/--/profile` instead. Sign in. The profile screen opens.
## Secure storage
[`secureStorage()`](https://session-kit.pages.dev/docs/reference/expo#securestorage) saves the session
with `expo-secure-store`, in the iOS keychain and in Keystore-encrypted
storage on Android. A session over 2048 bytes, which some iOS releases refuse
to store in one value, is split across several keys, up to 128 KB. By
default, a reinstalled app starts signed out. So does an app whose saved
session Android can't decrypt, as after a backup is restored onto another
device. Its options are in
[`SecureStorageOptions`](https://session-kit.pages.dev/docs/reference/expo#securestorageoptions).
Two things depend on your app:
- **Background refreshes.** By default, iOS lets the app read the keychain
only while the device is unlocked. While the keychain can't be read, the
session is `unavailable`, never signed out. An app that refreshes in a
background task while the device is locked needs
`secureStorage({ keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK })`.
- **Android backups.** The `expo-secure-store` config plugin from
[Installation](#installation) keeps the session out of Auto Backup.
## SessionGate
[`SessionGate`](https://session-kit.pages.dev/docs/reference/expo#sessiongate) adds what `Stack.Protected`
lacks. It holds the splash screen while the session is restored, shows
`unavailable` when access can't be used, checks the session again when the
app returns to the foreground, and opens deep links kept for after sign-in.
While the session is locked, it renders the navigator, so your guards can
show an unlock screen. When one account replaces another without a sign-out
between them, it remounts the navigator, so no screen keeps the previous
account's state. [`SessionGateProps`](https://session-kit.pages.dev/docs/reference/expo#sessiongateprops)
covers `ready` and `unavailable`.
## Deep links
[`deepLinkHandler`](https://session-kit.pages.dev/docs/reference/expo#deeplinkhandler) keeps one link at a
time, and `SessionGate` opens it after the next sign-in or unlock. A link to
the root or the sign-in screen is ignored, as is one that could leave the
app. A link that arrives while the user is signed in is dropped, since Expo
Router opens it as usual. A link to a screen anyone may see is dropped once
that screen shows. If the app launched with it, it's dropped at the sign-in
instead, unopened.
If your sign-in screen isn't `/login`, pass
`deepLinkHandler(auth, { loginPath: "/sign-in" })`.
Where `Stack.Protected` takes a `redirectTo`, as in Expo SDK 58, it only
chooses the screen a turned-away link goes to, such as `/login`. It doesn't
keep the link, so `deepLinkHandler` is still what opens it after the sign-in.
There is no `?redirectTo=` parameter on Expo. The link waits in memory, not
in the URL, and an app restart forgets it.
## Debugging
Turn on the client's `debug` option to see what Session kit does in Metro's
logs:
```ts
export const auth = createAuth({
// ...
debug: __DEV__,
});
```
`SessionGate` and deep links report through the same option. They log why
the splash screen is held (restoring, or waiting for `ready`), when it hides,
and what happened to a remembered link. `secureStorage()` adds what only it
can see, such as removing a session an earlier install left.
[Debugging](https://session-kit.pages.dev/docs/configuration/debugging) covers what events leave out, and
how to send them somewhere other than the console.
## Locking
A returning user can be asked to unlock before the app opens: a
"Welcome back" screen with Face ID, Touch ID, or a fingerprint, and the
password as a fallback. With `locking`, a saved session restores as `locked`,
with `user` and `sessionId` set and its tokens sealed by a key your app holds.
[Locking](https://session-kit.pages.dev/docs/client/locking) covers how it behaves. This section is the
Expo side.
Keep a random key in a SecureStore item that needs biometrics to read, and
seal the tokens with AES-GCM. SecureStore's biometric prompt needs a
development build on a real device. Expo Go doesn't support it, and
simulators don't ask for biometrics. On iOS, the `expo-secure-store` config
plugin from [Installation](#installation) adds the Face ID permission. This
example uses `@noble/ciphers` and `expo-crypto`, your app's dependencies, not
Session kit's:
```ts title="src/session-key.ts"
import { Platform } from "react-native";
import type { AuthLocking } from "@monarcode/session-kit";
import { gcm } from "@noble/ciphers/aes.js";
import {
bytesToHex,
bytesToUtf8,
hexToBytes,
utf8ToBytes,
} from "@noble/ciphers/utils.js";
import { getRandomBytes } from "expo-crypto";
import * as SecureStore from "expo-secure-store";
const KEY_ITEM = "session-key";
// Whether the key was saved behind biometrics, so it's read the same way.
const BIOMETRIC_ITEM = "session-key-biometric";
function keyOptions(biometric: boolean): SecureStore.SecureStoreOptions {
return biometric
? {
requireAuthentication: true,
authenticationPrompt: "Unlock your account",
}
: {};
}
// In memory from an unlock or a password sign-in until the app locks.
let key: Uint8Array | undefined;
/**
* Saves the key behind the device's biometrics, or, on a device with none
* enrolled, without them. On Android, saving shows the prompt.
*/
async function saveKey(next: Uint8Array): Promise {
const biometric = SecureStore.canUseBiometricAuthentication();
await SecureStore.setItemAsync(
KEY_ITEM,
bytesToHex(next),
keyOptions(biometric),
);
await SecureStore.setItemAsync(BIOMETRIC_ITEM, String(biometric));
}
/**
* Reads the key, which shows the prompt when it needs biometrics. Resolves
* `null` when no key was saved yet.
*/
async function readKey(): Promise {
const biometric = await SecureStore.getItemAsync(BIOMETRIC_ITEM);
if (biometric === null) return null;
const saved = await SecureStore.getItemAsync(
KEY_ITEM,
keyOptions(biometric === "true"),
);
// SecureStore reads `null` for a key that a change to the device's
// biometrics made unreadable.
if (saved === null) throw new Error("The session key can't be read");
return saved;
}
/**
* Loads the key to unlock. Throws when the user cancels the prompt, or when
* the device's biometrics changed and the key can't be read.
*/
export async function loadKey(): Promise {
const saved = await readKey();
if (saved) {
key = hexToBytes(saved);
return;
}
// A session saved before locking was added has no key yet. Make one: on
// iOS, saving doesn't prompt, so read it back to show the prompt.
const next = getRandomBytes(32);
await saveKey(next);
if (Platform.OS === "ios") await readKey();
key = next;
}
/**
* Signs in with a new key, and saves it only once the sign-in succeeds: a
* failed sign-in keeps the key that the saved session is sealed with.
*/
export async function withNewKey(signIn: () => Promise): Promise {
const previous = key;
const next = getRandomBytes(32);
key = next;
let result: T;
try {
result = await signIn();
} catch (error) {
if (key === next) key = previous;
throw error;
}
try {
await saveKey(next);
} catch (error) {
// The session works until the app closes; then the password unlocks it.
console.warn("Could not save the session key", error);
}
return result;
}
/** Forgets the key in memory, once `auth.lock()` has settled. */
export function dropKey(): void {
key = undefined;
}
function loadedKey(): Uint8Array {
if (!key) throw new Error("The session key isn't loaded");
return key;
}
/** AES-256-GCM: a random 12-byte nonce, then the ciphertext, as hex. */
export const sessionLocking: AuthLocking = {
seal(payload) {
const nonce = getRandomBytes(12);
const sealed = gcm(loadedKey(), nonce).encrypt(utf8ToBytes(payload));
return bytesToHex(nonce) + bytesToHex(sealed);
},
open(sealed) {
const bytes = hexToBytes(sealed);
const nonce = bytes.subarray(0, 12);
return bytesToUtf8(gcm(loadedKey(), nonce).decrypt(bytes.subarray(12)));
},
};
```
A device with no biometrics enrolled can't save an item that needs them, so
there the key is saved without them, and unlocking asks for nothing beyond
the device's own lock screen. If your app needs more on those devices, sign
out instead of locking.
Pass it to the client, and sign in with a new key, so the new session is
sealed with it:
```ts
export const auth = createAuth({
// ...
locking: sessionLocking,
});
export async function signInWithPassword(username: string, password: string) {
// ...
await withNewKey(() => auth.signIn({ accessToken, refreshToken, user }));
}
```
Guard the screens with `isSignedIn` and `status`, since a locked session
keeps its user. The unlock screen comes before the sign-in screen, so a
locked app opens on it, and the sign-in screen stays open to a locked user
for the password path:
```tsx
import { isSignedIn } from "@monarcode/session-kit";
function RootStack() {
const signedIn = useAuth(isSignedIn);
const locked = useAuth((state) => state.status === "locked");
return (
);
}
```
The unlock screen reads the key, which shows the biometric prompt, then
unlocks:
```tsx title="app/welcome-back.tsx"
import { useState } from "react";
import { Button, Text, View } from "react-native";
import { Link } from "expo-router";
import { useAuth } from "@monarcode/session-kit/react";
import { auth } from "../src/auth";
import { loadKey } from "../src/session-key";
export default function WelcomeBack() {
const name = useAuth((state) => state.user?.firstName);
const [failed, setFailed] = useState(false);
async function unlock() {
setFailed(false);
try {
await loadKey();
await auth.unlock();
// No navigation: Stack.Protected leaves this screen, and
// SessionGate opens the deep link the user was headed to.
} catch {
// Cancelled, or the biometrics changed: offer the password. A refresh
// that failed after unlocking leaves the session `unavailable`, which
// SessionGate shows instead.
if (auth.state.get().status === "locked") setFailed(true);
}
}
return (
Welcome back{name ? `, ${name}` : ""}
);
}
```
To lock again after some time in the background, lock when the app returns
to the foreground, then drop the key:
```ts
let backgroundedAt: number | undefined;
AppState.addEventListener("change", (next) => {
if (next === "background") backgroundedAt = Date.now();
else if (next === "active" && backgroundedAt !== undefined) {
if (Date.now() - backgroundedAt > 5 * 60_000)
void auth.lock().then(dropKey);
backgroundedAt = undefined;
}
});
```
Call `dropKey()` after `auth.signOut()` too. While locked, `SessionGate`
re-checks nothing on a return to the foreground, and requests through
`authFetch` reject with `LOCKED` until the user unlocks.
## Not supported
- **Expo web.** `secureStorage()` and `SessionGate` are for iOS and Android.
- **`requireAuthentication`.** `secureStorage()` doesn't use SecureStore's
biometric prompt. A session that refreshes on its own can't ask for a face
or fingerprint on every read and write. Keep a key behind the prompt
instead, and seal the session with it, as [Locking](#locking) shows.
- **Network status.** Session kit doesn't watch connectivity. When your app
learns the device is back online, for example from
`@react-native-community/netinfo`, call `auth.retry()`, or the `retry` that
`unavailable` receives.
## Next steps
- [Using the client](https://session-kit.pages.dev/docs/client): updating the user, signing out, and the
client's other methods.
- [Locking](https://session-kit.pages.dev/docs/client/locking): how a locked session behaves.
- [Effect and effetch](https://session-kit.pages.dev/docs/effect): the same client as an Effect service.
# Overview (https://session-kit.pages.dev/docs/server-rendering)
If your app renders pages on the server, with TanStack Start, Next.js, or
React Router's framework mode, Session kit keeps the session on the server.
This page explains how the pieces fit, then sends you to your framework's
guide.
Both tokens and the user sit in one encrypted, `httpOnly` cookie that page
scripts can't read, so they never see the refresh token. The server refreshes
before rendering, so pages never show a "Checking session…" state.
## The pieces
- **On the server**, `createServerAuth` from `@monarcode/session-kit/server`
seals the cookie with your secret. Each framework's adapter gives one
request's session over it, and middleware that refreshes before
rendering.
- **In the browser**, `createMirrorAuth` makes a client that starts from the
session the server rendered. `AuthProvider`, the hooks, and the TanStack
Router guards work with it, and sign-ins and sign-outs reach every tab.
- **Your server code** signs in with your backend and calls your API with
the access token, through one request's session. It's shaped like a
client, so code written for one, such as `createAuthFetch` or Effect's
`Auth`, takes it. To call your API from the browser too, opt in with
`clientCredentials`. Page scripts then get the access token in memory,
never the refresh token.
The engine is the same `createServerAuth` call in every framework.
[Create the server engine](https://session-kit.pages.dev/docs/setups/tanstack-start#1-create-the-server-engine)
on the TanStack Start page shows one. The browser's client mirrors what the
server rendered. It signs out and gets the access token through a session
route, which each adapter's `sessionRoute` makes. It reads the session there
too, except in TanStack Start, where a server function reads it, as the
server render needs. It signs in through your own server code, as in
[Create the browser client](https://session-kit.pages.dev/docs/setups/tanstack-start#4-create-the-browser-client).
## Frameworks
What differs between frameworks is how server code gets a request's
session, and how the session reaches the browser:
| Framework | On the server | In the browser | Setup |
| ------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------- |
| TanStack Start | `startAuth(serverAuth)`, with `refreshSession` in request middleware | `connectSession`, `loadSession` | [TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start) |
| Next.js 16, App Router | `nextSession(serverAuth, { cookies, redirect })`, with its `refresh` in Proxy | `useMirrorAuth` from `@monarcode/session-kit/react` | [Next.js](https://session-kit.pages.dev/docs/setups/next) |
| React Router framework mode, with SSR | `routerAuth(serverAuth)`, with `sessionMiddleware` in the root route | `useMirrorAuth` from `@monarcode/session-kit/react` | [React Router with server rendering](https://session-kit.pages.dev/docs/setups/react-router-ssr) |
Locking isn't available with server rendering. For another server, such as
the Next.js Pages Router, see
[Servers without an adapter](https://session-kit.pages.dev/docs/server-rendering/server-sessions#servers-without-an-adapter).
## Next steps
- Follow the guide for your framework in the table, from installation to
calling your API.
- [Server sessions](https://session-kit.pages.dev/docs/server-rendering/server-sessions): the secret,
cookie jars, refreshes across server instances, sign-out, and limits.
- [Hybrid setup](https://session-kit.pages.dev/docs/server-rendering/hybrid): sign in with your backend
from the browser and call your API there, with the session still on the
server.
# Server sessions (https://session-kit.pages.dev/docs/server-rendering/server-sessions)
This page covers what every server-rendered setup shares: the cookie and its
secret, a request's session, refreshes, sign-out, and limits. Read it once
your app works, to deploy it. The setup guides show the code:
[TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start), [Next.js](https://session-kit.pages.dev/docs/setups/next),
and [React Router with server rendering](https://session-kit.pages.dev/docs/setups/react-router-ssr).
## The cookie
`createServerAuth` from `@monarcode/session-kit/server` keeps both tokens and
the user in one cookie, `.session`, sealed with AES-256-GCM through Web
Crypto. It's `httpOnly`, so page scripts can't read it, and `Secure` and
`SameSite=Lax` unless you pass other `cookie` options. It takes the options
`createAuth` does, except `storage` and `locking`, plus `secret`, `cookie`
and `clientCredentials`.
Keep the module that creates it on the server. Each setup guide shows how
its framework keeps such a module out of the browser bundle.
## The secret
Use 32 or more random characters from an environment variable. To make one,
run `openssl rand -base64 32`. `createServerAuth` checks the secret on first
use, not when the module loads, so build steps that import the module without
the secret still work.
Your module still reads `process.env.SESSION_SECRET` when it loads. On
Cloudflare Workers, `process.env` has the secret at that point when Node.js
compatibility is on, the default from compatibility date 2025-04-01. Without
Node.js compatibility, read it with `import { env } from "cloudflare:workers"`,
which also works at the top of a module.
To rotate the secret, pass a list. The first secret seals new cookies, and
any of them opens one, so sessions survive the change. For example, to read
the old secret from `OLD_SESSION_SECRET` only while it's set:
```ts title="src/auth/server.ts"
import { createServerAuth } from "@monarcode/session-kit/server";
import { z } from "zod";
const secrets = [process.env.SESSION_SECRET, process.env.OLD_SESSION_SECRET];
export const serverAuth = createServerAuth({
name: "app",
user: z.object({ id: z.number(), firstName: z.string() }),
// A secret that isn't set is left out, so rotating only changes the env.
secret: secrets.filter((secret) => secret !== undefined),
});
```
Each save seals the cookie with the first secret. Remove
`OLD_SESSION_SECRET` once `maxAge` has passed since the change, 30 days by
default. By then, every cookie it sealed has expired. A cookie that no listed
secret opens reads as signed out.
## A request's session
Your server code reads and changes the session through one request's
session (`RequestAuth`). It has every `serverAuth` method, without the
request's cookie jar to pass:
- **TanStack Start:** `startAuth(serverAuth)`, made once at module level.
Each call finds the request Start is handling.
- **React Router:** `routerAuth(serverAuth)` makes a function you call with
a loader's, action's or middleware's `context`.
- **Next.js:** `nextSession(serverAuth, { cookies, redirect })`, whose
`writable()` gives it in Server Functions and Route Handlers, and
`readOnly()` in Server Components.
- **Servers without an adapter:** `serverAuth.forRequest(jar)`, over the
request's cookie jar.
It's shaped like a client, so code written for one works on the server with
it. `createAuthFetch(session, { baseUrl })` sends the cookie's token and saves
a renewed one after a 401, and Effect's `Auth.layer` takes it
([Effect on the server](https://session-kit.pages.dev/docs/effect#on-the-server)).
Some requests can't set cookies: a Next.js Server Component rendering, or a
React Router loader once its response has left the middleware. There,
nothing is saved, so `signIn`, `signOut`, `updateUser` and `refreshIfDue`
reject with `INVALID_OPTIONS`. `getSession` still resolves, and
`credentials.get` rejects with `REFRESH_FAILED` once the access token has
expired. TanStack Start can't tell once a page has started streaming, so a
cookie set then is lost. Refresh before rendering, with the middleware each
setup guide adds: `refreshSession` in TanStack Start, `sessionMiddleware` in
React Router, and `nextSession`'s `refresh` in Next.js's Proxy.
## Servers without an adapter
Every `serverAuth` method also takes the request's `CookieJar` first:
`serverAuth.getSession(jar)` does what a request's `getSession()` does, and
`serverAuth.forRequest(jar)` gives that request's session. For a server
without an adapter, build a [`CookieJar`](https://session-kit.pages.dev/docs/reference/server#cookiejar)
over its cookies. Answer the browser client's session route with
`sessionHandler(serverAuth)`, called with the request and its jar.
Use one jar per request. Calls with the same jar share what it read and
wrote and one refresh, so a jar kept at module level would show one
request's session to the next. A jar that can't set cookies says so with
`writable: false`.
## Token refresh
Parallel requests to one instance that carry the same cookie share one
refresh. For ten seconds afterwards, a request still carrying the old cookie
gets the new tokens rather than refreshing again. A refresh that fails at
your backend is shared too. For ten seconds, requests carrying that cookie
reject with its error rather than call your backend again.
Each `createServerAuth` call is its own instance, and a framework may make
several on one server, one per bundle. Separate processes, servers, and
serverless functions are separate instances too. Instances share nothing, so
with rotating refresh tokens, give your backend a short reuse window. Two
instances that refresh the same session then don't sign it out.
## Sign-out
A request's `signOut` clears the cookie in this browser, then calls your
`revoke`. The cookie holds the whole session and the server keeps no list of
sessions, so a copy of the cookie made before sign-out still reads as signed
in. For ten seconds, the instance that signed out treats such a copy as
signed out, as it treats an old cookie after a refresh. After that, the copy
works for as long as its tokens do.
`revoke` is what ends the session: have your backend revoke the refresh
token there, so a copy can't refresh, and keep access tokens short-lived,
since the backend accepts one until it expires unless it checks for
revocation.
## The access token in the browser
Server code calls your API with a request's `credentials`. To call it from
the browser too, with `createAuthFetch` or effetch's `sessionBearer`, opt in
with `clientCredentials: true`. The session route then serves the access
token, and the browser's client gets it there. Page scripts hold the access
token in memory, never the refresh token, so a script injected into the page
can use the API while the page is open, as it could with any token the
browser holds.
The session route answers your own site's pages only: see
[The session route](https://session-kit.pages.dev/docs/security#the-session-route).
## Limits
- **Size.** The cookie can take about 7.5 KB across two cookies, since
servers and proxies refuse larger request headers (nginx: 8 KB for the
`Cookie` line, shared with the site's other cookies), and go on refusing
every page until the cookie is cleared. That holds about 5.5 KB of tokens
and user before encryption. A larger session rejects at sign-in with
`PERSISTENCE_FAILED`, so keep the user small. An `updateUser` or a refresh
that outgrows the cookie rejects the same way, and signs the user out.
- **Plain http.** The cookie is `Secure` by default. Browsers accept that on
`http://localhost` except Safari: pass `cookie: { secure: false }` for
local development there, never in production.
- **Locking.** `locking` isn't available. The server keeps the tokens, so
there's nothing on the device to lock.
## Next steps
- [Effect on the server](https://session-kit.pages.dev/docs/effect#on-the-server): run Effects with a
request's session.
- [Security](https://session-kit.pages.dev/docs/security): what Session kit protects, and what stays your
backend's job.
# Hybrid setup (https://session-kit.pages.dev/docs/server-rendering/hybrid)
In a hybrid setup, the browser talks to your backend: it signs in, and calls
your API with the access token. The server still keeps the session in its
cookie, refreshes it, and renders who is signed in. Use it when your
backend's sign-in has to run in the browser, such as a provider's SDK, or when
your pages call your API themselves.
Two things change from a server-rendered setup:
- **Signing in.** The browser gets the tokens from your backend and sends
them to a server function, which seals them into the cookie with
`session.signIn`.
- **Calling your API.** The session route gives the browser the access token,
and `createAuthFetch` sends it. The refresh token stays in the cookie.
This page builds on the [TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start) guide.
With Next.js or React Router, do the same in a Server Function or an action.
## Before you begin
Follow the [TanStack Start](https://session-kit.pages.dev/docs/setups/tanstack-start) guide through step 5.
This page replaces its `src/auth/functions.ts`, `src/auth/client.ts` and
`src/sign-in.ts`.
Then let the session route serve the access token, with `clientCredentials`
in `createServerAuth`, in `src/auth/server.ts`:
```ts
export const serverAuth = createServerAuth({
// …the options from the guide
clientCredentials: true,
});
```
## 1. Sign in with your backend in the browser
The browser calls your backend's sign-in, which must allow requests from your
site (CORS):
```ts title="src/backend.ts"
import { z } from "zod";
const API = "https://api.example.com";
const tokensSchema = z.object({
accessToken: z.string(),
refreshToken: z.string(),
});
const userSchema = z.object({ id: z.number(), firstName: z.string() });
/** Signs in with your backend, from the browser. */
export async function login(username: string, password: string) {
const response = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!response.ok) throw new Error(`Sign-in failed (${response.status})`);
const body: unknown = await response.json();
return { ...tokensSchema.parse(body), user: userSchema.parse(body) };
}
```
## 2. Send the tokens to the server
`session.signIn` seals what it's given, and never asks your backend. So the
server function decides what to trust. Check the tokens first, unless the
cookie's user is only ever shown.
### Check the tokens first
The server function takes the tokens only, and asks your backend who they
belong to. A forged or expired token is refused, and the cookie's user comes
from your backend, not from the browser:
```ts title="src/auth/functions.ts"
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { session } from "./server";
const API = "https://api.example.com";
export const getSessionFn = createServerFn().handler(() =>
session.getSession(),
);
export const signInFn = createServerFn({ method: "POST" })
.validator(z.object({ accessToken: z.string(), refreshToken: z.string() }))
.handler(async ({ data }) => {
// Proves the access token is real, and reads its user.
const response = await fetch(`${API}/auth/me`, {
headers: { Authorization: `Bearer ${data.accessToken}` },
});
if (!response.ok) throw new Error("Your backend refused the token");
const me: unknown = await response.json();
const user = z.object({ id: z.number(), firstName: z.string() }).parse(me);
return session.signIn({ ...data, user });
});
```
The client's `signIn` takes the tokens, and sends them to `signInFn`:
```ts title="src/auth/client.ts"
import { createMirrorAuth } from "@monarcode/session-kit";
import { getSessionFn, signInFn } from "./functions";
export function createAppAuth() {
return createMirrorAuth({
name: "app",
session: null, // filled by loadSession, then by hydration
endpoint: "/api/session",
revalidate: () => getSessionFn(),
signIn: (tokens: { accessToken: string; refreshToken: string }) =>
signInFn({ data: tokens }),
});
}
export type AppAuth = ReturnType;
```
The form signs in with your backend, then with the client. The sign-in
page's guard sends the user on, so the form doesn't navigate:
```ts title="src/sign-in.ts"
import type { AppAuth } from "./auth/client";
import { login } from "./backend";
/** Signs in, and resolves the message to show when it fails. */
export async function signIn(
auth: AppAuth,
username: string,
password: string,
): Promise {
try {
const { accessToken, refreshToken } = await login(username, password);
await auth.signIn({ accessToken, refreshToken });
return null;
} catch (error) {
return error instanceof Error ? error.message : "Could not sign in";
}
}
```
With [`fromAccessToken`](https://session-kit.pages.dev/docs/client/users-from-tokens) as the server's
`user` option, the server reads the user from the access token instead. Give
it a `decode` that verifies the token's signature, and that's the check.
### Pass the tokens without a check
The server function takes the tokens and the user the browser sends, and
seals them as they are:
```ts title="src/auth/trusting-sign-in.ts"
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { session } from "./server";
export const trustingSignInFn = createServerFn({ method: "POST" })
.validator(
z.object({
accessToken: z.string(),
refreshToken: z.string(),
user: z.object({ id: z.number(), firstName: z.string() }),
}),
)
.handler(({ data }) => session.signIn(data));
```
The client's `signIn` takes what your backend's sign-in returned, so the
form passes it `login`'s result as it is:
```ts
signIn: (result: Awaited>) =>
trustingSignInFn({ data: result }),
```
Anyone can then put any user in their own cookie, with tokens of their own.
That's safe only when the cookie's user is shown, never trusted:
- **Your backend authorizes every request** with the access token, and
nothing on your server acts on `session.user` alone, such as reading
another account's data by its `id`.
- **A forged token gets nowhere.** Your API refuses it, and the first refresh
fails and signs the user out.
When in doubt, check the tokens first. It costs one request at sign-in.
## 3. Call your API from the browser
`createAuthFetch` from `@monarcode/session-kit/http` sends the access token
that the session route serves. Make one per client, since the client is made
once per page load:
```tsx title="src/orders.tsx"
import { useMemo, useState } from "react";
import { createAuthFetch } from "@monarcode/session-kit/http";
import { useAuthClient } from "@monarcode/session-kit/tanstack-router";
export function Orders() {
const auth = useAuthClient();
const apiFetch = useMemo(
() => createAuthFetch(auth, { baseUrl: "https://api.example.com/" }),
[auth],
);
const [count, setCount] = useState(null);
async function load() {
const response = await apiFetch("orders");
if (!response.ok) throw new Error(`Orders failed (${response.status})`);
const orders: unknown = await response.json();
setCount(Array.isArray(orders) ? orders.length : 0);
}
return (
{count !== null &&
{count} orders
}
);
}
```
With ky, axios or openapi-fetch, pass `apiFetch` as the client's `fetch`: see
[Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients).
The browser keeps the access token in memory, never the refresh token. After
a 401, the client asks the session route for a new token, and the server
refreshes with the cookie's refresh token. Server functions can still call
your API too, as the guide's
[Call your API](https://session-kit.pages.dev/docs/setups/tanstack-start#call-your-api) shows.
## Things to know
- **The refresh token passes through the browser once,** in your backend's
sign-in response, on its way to the server. After that, page scripts never
see it. If your backend's sign-in can run on the server, sign in there, as
the setup guides do.
- **Your backend must allow your site (CORS)** for its sign-in and the API
calls you make from the browser. Session kit's own requests go to your
session route, on your site.
- **Signing out** works as in the guide's
[Sign out](https://session-kit.pages.dev/docs/setups/tanstack-start#sign-out), through the session
route. To end the session at your backend too, give `createServerAuth` a
`revoke`.
- **Security** has more on what the cookie's user can be trusted for, in
[Your backend decides what's allowed](https://session-kit.pages.dev/docs/security#your-backend-decides-whats-allowed).
# Using the client (https://session-kit.pages.dev/docs/client)
This section is for code outside React components. Components read the
session with hooks. Everything else, such as loaders, event handlers, and
request code, calls the client that `createAuth` returned. These are its
methods:
| Call | What it does |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `auth.state.get()`, `.subscribe()` | Reads the [state](https://session-kit.pages.dev/docs/client/state), or follows its changes. |
| `auth.getSession()` | Resolves who is signed in, restoring or refreshing first, or `null`. |
| `auth.signIn({ ...tokens, user })` | Saves a new session, replacing any current one. |
| `auth.signOut()` | Ends the session, clears storage, and tells other tabs. |
| `auth.updateUser(user)` | Replaces the user and keeps the session. |
| `auth.refresh()` | Refreshes now, even while the access token is usable. |
| `auth.retry()` | Tries again after a failed refresh. |
| `auth.isCurrent(session)` | Tells whether a session is still the signed-in one. |
| `auth.credentials.get()`, `.renew()`, `.reject()` | Resolves the access token, and replaces or ends it after a 401, for code that sends requests. |
| `auth.lock()`, `auth.unlock()` | Locks and unlocks a saved session, with [`locking`](https://session-kit.pages.dev/docs/client/locking). |
| `auth.mount()` | Starts timers and tab sync. `AuthProvider` and the routers call it. |
Each method's JSDoc says what it resolves and which `AuthError` it rejects
with, so your editor shows the details where you use them.
With server rendering, the browser's client comes from `createMirrorAuth`. It
has the same state, `getSession`, `signOut`, `isCurrent` and `credentials`,
but no `lock`, `unlock` or `updateUser`. See
[Server rendering](https://session-kit.pages.dev/docs/server-rendering).
The examples in this section build on the app from
[Getting started](https://session-kit.pages.dev/docs/getting-started). `auth` is its client, and
`authFetch` its `fetch`.
- [State](https://session-kit.pages.dev/docs/client/state): Read the state outside React, and what each field holds.
- [Sessions and credentials](https://session-kit.pages.dev/docs/client/sessions): Ask who is signed in, and keep the access token apart from the user.
- [Signing in and out](https://session-kit.pages.dev/docs/client/signing-in-and-out): Save a new session, end it, and revoke its tokens.
- [Updating the user](https://session-kit.pages.dev/docs/client/updating-the-user): Replace the user without ending the session.
- [Authenticated requests](https://session-kit.pages.dev/docs/client/requests): A fetch that sends the access token to one API, and refreshes on a 401.
- [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients): Send the access token with ky, axios, openapi-fetch or any other client.
- [Users from the access token](https://session-kit.pages.dev/docs/client/users-from-tokens): Read the user from a JWT's claims instead of the sign-in response.
- [Locking](https://session-kit.pages.dev/docs/client/locking): Ask a returning user to unlock, for example with biometrics, in native apps.
# State (https://session-kit.pages.dev/docs/client/state)
`auth.state` holds what `useAuth` reads in components. Outside React, read it
once with `get`, or follow it with `subscribe`:
```ts title="src/status-log.ts"
import { auth } from "./auth.js";
/** Logs each change of status. Returns a function that stops it. */
export function logStatus() {
let last = auth.state.get().status;
const { unsubscribe } = auth.state.subscribe(({ status }) => {
if (status === last) return;
last = status;
console.info(`Auth is ${status}`);
});
return unsubscribe;
}
```
`subscribe` reports later changes, not the current value. A listener that
throws doesn't interrupt auth. Its error is rethrown asynchronously, where
error reporting can catch it.
The state holds these fields:
| Field | Holds |
| ----------- | ------------------------------------------------------------------------------------- |
| `status` | Where the session stands. [How it works](https://session-kit.pages.dev/docs#how-it-works) lists each one. |
| `user` | The signed-in user, or `null`. Checking `status` narrows it. |
| `sessionId` | New on every sign-in, and stable across refreshes and profile updates. |
| `error` | The latest failure, as an [`AuthError`](https://session-kit.pages.dev/docs/configuration/errors), or `null`. |
| `version` | Changes when guards must run again. `connectAuth` and `useAuthRevalidation` watch it. |
Two habits follow from these fields:
- **Scope private cached data to `sessionId`**, such as TanStack Query
results, so one account never sees another's.
- **Check `status`, not `user`, to know whether access is usable.** A
[locked](https://session-kit.pages.dev/docs/client/locking) session keeps its user. `isSignedIn(state)`
is the check. It's `true` while `authenticated` or `refreshing`, and it
narrows `user`. `useAuth(isSignedIn)` reads it in a component.
# Sessions and credentials (https://session-kit.pages.dev/docs/client/sessions)
A **session** is who is signed in, `{ sessionId, user }`, and never holds
tokens. `getSession()` is how code outside React asks for it. It waits for a
restore or a refresh that is under way, so a loader never acts on a stale
answer:
```ts title="src/cart.ts"
import { authFetch } from "./api.js";
import { auth } from "./auth.js";
/** A loader: it runs outside React, so it asks the client. */
export async function loadCart(): Promise {
const session = await auth.getSession();
if (!session) return null;
const response = await authFetch(`auth/carts/user/${session.user.id}`);
if (!response.ok) throw new Error(`Cart failed (${response.status})`);
return response.json();
}
```
**Credentials** are the access token, `{ sessionId, accessToken, expiresAt }`,
kept apart so that code which only needs the user never holds one.
[`createAuthFetch`](https://session-kit.pages.dev/docs/client/requests) reads them for you, and so does
an HTTP client that takes `authFetch` as its `fetch`. Code that sends
requests another way uses `auth.credentials`, as
[Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients#clients-without-a-fetch-option)
shows.
`auth.isCurrent({ sessionId })` tells whether a session you're holding is
still the signed-in one, for work that might finish after an account switch.
# Signing in and out (https://session-kit.pages.dev/docs/client/signing-in-and-out)
`auth.signIn({ ...tokens, user })` validates and saves a new session,
replacing any current one, even a locked one. With
[`fromAccessToken`](https://session-kit.pages.dev/docs/client/users-from-tokens), leave out `user`. The
latest call wins, and invalid input keeps the previous session.
[Expiry](https://session-kit.pages.dev/docs/configuration/expiry) covers how the token's lifetime is
found.
`auth.signOut()` ends the session and clears storage. Other tabs sign out too
when the storage reports changes, as `webStorage()` does, and `locking` is
off.
If your backend can revoke tokens, give the client a `revoke` callback. It
receives the ended tokens after the user is signed out:
```ts title="src/revoke.ts"
import type { RevokeFn } from "@monarcode/session-kit";
/** Pass as `createAuth`'s `revoke` option. */
export const revoke: RevokeFn = async ({ refreshToken, signal }) => {
if (!refreshToken) return;
const response = await fetch("https://api.example.com/auth/revoke", {
method: "POST",
signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) throw new Error(`Revoke failed (${response.status})`);
};
```
`signOut()` resolves once `revoke` settles, so await it before a full-page
redirect, which would cancel the request.
A failed revocation shows in `state.error` as `REVOKE_FAILED`, and the
sign-out stands. The user asked to leave, and has. The client doesn't revoke
a session the backend already rejected.
# Updating the user (https://session-kit.pages.dev/docs/client/updating-the-user)
`auth.updateUser` replaces the user and keeps the session. When you already
have the new user, such as from a form's response, pass it directly:
`auth.updateUser(user)`.
When the new user comes from a request, pass a function that fetches it
instead. The client notes the session before the function runs. If the
session changes meanwhile, `updateUser` rejects with `SESSION_CHANGED`, and
the user it fetched never lands on another account. `fetchProfile` is the
request from [Getting started](https://session-kit.pages.dev/docs/getting-started#5-call-your-api):
```ts title="src/profile.ts"
import { fetchProfile } from "./api.js";
import { auth } from "./auth.js";
export const reloadProfile = () => auth.updateUser(fetchProfile);
```
# Authenticated requests (https://session-kit.pages.dev/docs/client/requests)
`createAuthFetch(auth, { baseUrl })` returns a `fetch` for one API. It's
built so that a token can't leak and a user can't be signed out by accident:
| When… | `authFetch`… |
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| The request is for another origin, or has a username or password in its URL | Rejects with `INVALID_OPTIONS`. The token goes only to `baseUrl`'s origin. |
| The response is a redirect | Rejects rather than following it. |
| The response is a 401 | Refreshes once, then sends a GET or HEAD again with the new token. |
| Any other method gets a 401 | Refreshes, and resolves the 401. It never sends a write twice on its own. |
| The replayed request gets a 401 too | Signs out, unless `signOutOnRepeated401` is `false`. |
| The session changes during the request | Rejects with `SESSION_CHANGED` rather than resolve another account's answer. |
| Nobody is signed in | Rejects with `UNAUTHENTICATED`, unless `optional` is `true`. |
| The session is locked | Rejects with `LOCKED`, even when `optional` is `true`. |
| A refresh fails | Rejects with that failure, such as `REFRESH_FAILED`. |
| The network fails | Rejects as `fetch` does, unchanged. |
Call it with a path, which is resolved against `baseUrl` as a link is
against a page's address. End `baseUrl` with `/`, so that
`authFetch("orders")` lands under it. It also takes a `URL` or a `Request`,
as `fetch` does, so an HTTP client with a `fetch` option can send its
requests through it. See [Other HTTP clients](https://session-kit.pages.dev/docs/client/other-http-clients).
A `Request` with a body can be sent once, as with `fetch`. To retry a write
after a 401, send a clone you kept.
Because a write is never replayed for you, retry it yourself where sending it
twice is safe. By then the token has been refreshed:
```ts title="src/add-to-cart.ts"
import { authFetch } from "./api.js";
export async function addToCart(userId: number, productId: number) {
const send = () =>
authFetch("auth/carts/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId,
products: [{ id: productId, quantity: 1 }],
}),
});
let response = await send();
// The 401 refreshed the token, so one more try uses the new one.
if (response.status === 401) response = await send();
if (!response.ok) throw new Error(`Add to cart failed (${response.status})`);
}
```
Two more options change the defaults:
```ts title="src/public-api.ts"
import { createAuthFetch } from "@monarcode/session-kit/http";
import { auth } from "./auth.js";
import { API } from "./backend.js";
export const publicFetch = createAuthFetch(auth, {
baseUrl: `${API}/`,
// One `fetch` for public and private endpoints: signed out, requests are
// sent without a token instead of rejecting.
optional: true,
// For APIs that answer 401 for reasons other than a rejected token, so a
// second 401 from one of those endpoints doesn't sign users out.
signOutOnRepeated401: false,
});
```
Without a refresh token or a `refresh` option, there's nothing to refresh
with, so the first 401 signs the user out, whatever `signOutOnRepeated401`
is.
# Other HTTP clients (https://session-kit.pages.dev/docs/client/other-http-clients)
[`authFetch`](https://session-kit.pages.dev/docs/client/requests) takes a `URL` or a `Request` as well as
a path, as `fetch` does. An HTTP client that lets you choose its `fetch` can
send its requests through it, and they get everything `authFetch` does: the
token stays on your API's origin, a 401 refreshes once, and only a GET or
HEAD is sent again.
A client with its own transport uses
[the calls `authFetch` is built on](#clients-without-a-fetch-option) instead.
## Clients that take a `fetch`
With [ky](https://github.com/sindresorhus/ky), pass `authFetch` as `fetch`:
```ts title="src/ky.ts"
import ky from "ky";
import { authFetch } from "./api.js";
import { API } from "./backend.js";
export const api = ky.create({ baseUrl: `${API}/`, fetch: authFetch });
export async function fetchCarts(userId: number): Promise {
return api.get(`auth/carts/user/${userId}`).json();
}
```
With [axios](https://axios-http.com), choose its `fetch` adapter and pass
`authFetch` in `env`:
```ts title="src/axios.ts"
import axios from "axios";
import { authFetch } from "./api.js";
import { API } from "./backend.js";
export const http = axios.create({
baseURL: API,
adapter: "fetch",
env: { fetch: authFetch },
});
```
[openapi-fetch](https://openapi-ts.dev/openapi-fetch/) takes it the same way
as ky:
```ts
const client = createClient({ baseUrl: API, fetch: authFetch });
```
What changes for the client's calls:
- **Give the client the same API.** Its base URL must be on the origin that
`authFetch` was created for. A request for another origin rejects with
`INVALID_OPTIONS`, and the token isn't sent.
- **Signed out, every call rejects** with `UNAUTHENTICATED`. If the client
also calls public endpoints, such as your sign-in, create `authFetch` with
[`optional: true`](https://session-kit.pages.dev/docs/client/requests). Those requests then go without
a token.
- **A 401 reaches the client after the refresh.** For a GET or HEAD, the
client sees the answer to the request that was sent again. For a write, it
sees the 401, as ky's `HTTPError` or axios's `AxiosError`. Retry it where
sending it twice is safe.
- **Session kit's errors keep their code.** ky rejects with the `AuthError`
itself. axios wraps it in an `AxiosError` that has the same `code`, with
the `AuthError` as its `cause`. Match on `error.code`, which works with
both, rather than on `instanceof AuthError`.
- **`authFetch` sets `Authorization`.** While signed in, it replaces a
header the client set itself.
- **Only what a `Request` holds is sent.** ky hands `fetch` the options it
doesn't know itself, such as Next.js's `next`. `authFetch` doesn't pass
those on.
## Clients without a `fetch` option
`createAuthFetch` and the
[effetch plugin](https://session-kit.pages.dev/docs/effect#6-call-your-api-with-effetch) are built on three
calls, and a client with its own transport can use the same ones:
1. **Before a request**, `auth.credentials.get()` resolves the credentials to
send, waiting for a restore or a refresh that is under way. It resolves
`null` when nobody is signed in.
2. **After a 401**, `auth.credentials.renew(credentials)` takes the
credentials that request was sent with, and resolves new ones. However many
requests failed, they share one refresh. It resolves `null` when the user
was signed out instead.
3. **After a second 401**, `auth.credentials.reject(credentials)` ends the
session, unless newer credentials have replaced the ones you pass.
An axios instance that keeps its default adapter does it with two
interceptors:
```ts title="src/axios-interceptors.ts"
import type { Credentials } from "@monarcode/session-kit";
import axios, { type InternalAxiosRequestConfig } from "axios";
import { auth } from "./auth.js";
import { API } from "./backend.js";
type Config = InternalAxiosRequestConfig & { replayed?: boolean };
// One instance for one API, so the token goes nowhere else.
export const http = axios.create({ baseURL: API });
/** The credentials each request was sent with. */
const sent = new WeakMap();
http.interceptors.request.use(async (config) => {
const credentials = await auth.credentials.get();
// Signed out, the request goes without a token, as a public one would.
if (credentials) {
config.headers.set("Authorization", `Bearer ${credentials.accessToken}`);
sent.set(config, credentials);
}
return config;
});
http.interceptors.response.use(undefined, async (error: unknown) => {
if (!axios.isAxiosError(error) || error.response?.status !== 401) throw error;
const config: Config | undefined = error.config;
const used = config && sent.get(config);
if (!config || !used) throw error;
if (config.replayed) {
// The new token was refused too: end the session.
await auth.credentials.reject(used);
throw error;
}
const fresh = await auth.credentials.renew(used);
// As with authFetch, only a GET or HEAD is sent twice. For anything else
// the caller decides.
if (!fresh || !["get", "head"].includes(config.method ?? "")) throw error;
const replay: Config = { ...config, replayed: true };
return http.request(replay);
});
```
Three things `createAuthFetch` does for you are yours to keep here:
- **Keep the token on one origin.** Give the client one base URL, and don't
pass it absolute URLs to other hosts.
- **Don't read the token early.** Ask for credentials in the request hook,
never once at startup. A token saved in a variable goes stale at the next
refresh.
- **Check the account after slow work.** `auth.isCurrent(credentials)` tells
whether the session that sent a request is still the signed-in one, before
you show its answer.
# Users from the access token (https://session-kit.pages.dev/docs/client/users-from-tokens)
If your backend puts the user in a JWT access token, read it from there with
`fromAccessToken`. `signIn` then takes tokens only, the refresh callback
returns them, and the user changes whenever the token does:
```ts title="src/token-auth.ts"
import {
createAuth,
fromAccessToken,
webStorage,
} from "@monarcode/session-kit";
import { z } from "zod";
const claimsSchema = z.object({ sub: z.string(), email: z.string() });
export const tokenAuth = createAuth({
name: "my-token-app",
// The claims are validated, then mapped to the user the app sees.
user: fromAccessToken(claimsSchema, (claims) => ({
id: claims.sub,
email: claims.email,
})),
storage: webStorage(),
});
export const signInWithTokens = (tokens: {
accessToken: string;
refreshToken: string;
}) => tokenAuth.signIn(tokens);
```
Reading the user from the token changes a few things:
- **No `updateUser`.** The token is the only source of the user, so call
`auth.refresh()` to pick up a change.
- **The signature isn't verified.** That suits a browser, which trusts a user
object in a JSON response to the same degree. Pass `{ decode }` as the third
argument to verify or decode the token yourself.
- **Leave out claims that change on every refresh**, such as `exp`, `iat`, or
`jti`, so the user stays the same object across refreshes.
- **For backends you control.** OAuth providers can change their access token
format without notice.
The
[`tanstack-start-spa`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa)
and
[`react-router-data`](https://github.com/monarcode/session-kit/tree/main/examples/react-router-data)
examples read their user this way.
# Locking (https://session-kit.pages.dev/docs/client/locking)
Locking is for native apps that ask a returning user to unlock, for example
with biometrics, before the app opens. With the `locking` option, a saved
session restores as `locked`. `user` and `sessionId` are set, so a
"Welcome back" screen can greet the user, but the tokens wait for
`auth.unlock()`. The following client encrypts its saved tokens with a key
the app holds:
```ts title="src/locked-auth.ts"
import { createAuth, type AuthStorage } from "@monarcode/session-kit";
import { userSchema } from "./backend.js";
// Your app's own: a native secure store, and encryption with a key it holds
// in memory only while unlocked.
declare const storage: AuthStorage;
declare const key: CryptoKey;
declare function encrypt(key: CryptoKey, plain: string): Promise;
declare function decrypt(key: CryptoKey, sealed: string): Promise;
export const lockedAuth = createAuth({
name: "my-native-app",
user: userSchema,
storage,
locking: {
// Runs whenever the tokens are saved.
seal: (payload) => encrypt(key, payload),
// Runs only from `unlock()`.
open: (sealed) => decrypt(key, sealed),
},
});
/** Once the app has its key: resolves the session, now `authenticated`. */
export const unlockApp = () => lockedAuth.unlock();
/** `locked` again. The saved session stays, and the tokens leave memory. */
export const lockApp = () => lockedAuth.lock();
```
Locking comes with these rules:
- **Sealing is yours.** Session kit depends on no crypto or biometrics
library. The user and session ID are saved beside the sealed tokens,
readable while locked. `locking: true` stores the tokens as they are, for a
lock screen in front of an otherwise ordinary session.
- **While locked, nothing waits.** `getSession`, `retry`, `refresh`,
`updateUser`, `credentials.get`, and `credentials.renew` reject with
`LOCKED` instead of waiting for an unlock that may never come. Nothing
refreshes, and timers are off. `signIn` still works and replaces the
locked session, so a user who can't unlock can sign in again. After
`lock()` resolves, `signOut` clears it without calling `revoke`, since no
tokens are left in memory.
- **A failed unlock isn't always still locked.** When `open` throws,
`unlock()` rejects with `UNLOCK_FAILED` and the session stays locked. But a
refresh that fails after the tokens were opened rejects with
`REFRESH_FAILED` and leaves the session unlocked and `unavailable` until
`retry()`. Check `status` after a rejection.
- **Keep the key until `lock()` settles.** A refresh already running still
saves its tokens, and saving seals them.
- **Native apps only.** Locking is built for one JavaScript context. In a
browser, tabs don't follow each other with `locking`, and the router
adapters treat a locked session as signed out. It also needs a user schema:
a user read from the access token can't be shown without its token.
- **Turning it on and off.** A session saved before `locking` was added
restores as `locked` too, and is sealed when unlocked. Turning
`locking` off later makes sealed sessions unreadable, which signs those
users out.
## Next steps
The [Expo guide](https://session-kit.pages.dev/docs/setups/expo#locking) has a worked example: a key behind
the device's biometrics, AES-GCM sealing, and a welcome-back screen.
# Configuring the client (https://session-kit.pages.dev/docs/configuration)
`createAuth` builds the client from a few options. This section explains each
one, for when the defaults in [Getting started](https://session-kit.pages.dev/docs/getting-started) no
longer fit your app.
## Options
`createAuth` takes three required options and five optional ones:
| Option | Required | What it sets |
| --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Yes | Names the saved session and the refresh lock: letters, digits, `_` or `-`, up to 64 characters. Keeps two apps on one origin apart. |
| `user` | Yes | A [user schema](https://session-kit.pages.dev/docs/configuration/user-schemas), or [`fromAccessToken`](https://session-kit.pages.dev/docs/client/users-from-tokens). |
| `storage` | Yes | [Where the session is saved](https://session-kit.pages.dev/docs/configuration/storage). |
| `refresh` | No | [How to get new tokens](https://session-kit.pages.dev/docs/configuration/refresh). Without it, an expired token signs the user out. |
| `revoke` | No | [Revokes the ended tokens](https://session-kit.pages.dev/docs/client/signing-in-and-out) on your backend after a sign-out. |
| `maxAge` | No | How long a saved session can be restored, in seconds. Default: 30 days, counted again from each save. |
| `debug` | No | [Reports what the client does](https://session-kit.pages.dev/docs/configuration/debugging). Default: off. |
| `locking` | No | [Restores a saved session as `locked`](https://session-kit.pages.dev/docs/client/locking), for native apps. Default: off. |
`createAuth` throws an `AuthError` with the code `INVALID_OPTIONS` for an
option it can't use, such as a `name` with other characters, or a `maxAge`
that isn't a positive whole number.
## Pages in this section
Each page covers one part of the client: how it checks users, gets new
tokens, saves the session, follows other tabs, and reports what happens.
- [User schemas](https://session-kit.pages.dev/docs/configuration/user-schemas): When the schema checks users, and the rules restoring a session sets for it.
- [Refresh](https://session-kit.pages.dev/docs/configuration/refresh): What the refresh callback returns, and when a refresh runs.
- [Expiry](https://session-kit.pages.dev/docs/configuration/expiry): How the client finds out when the access token expires.
- [Storage](https://session-kit.pages.dev/docs/configuration/storage): Where the session is saved: localStorage, sessionStorage, the device, or your own store.
- [Tabs](https://session-kit.pages.dev/docs/configuration/tabs): How tabs follow each other's sign-ins, and share one refresh.
- [Errors](https://session-kit.pages.dev/docs/configuration/errors): How errors reach your app, and the failures that need a decision from you.
- [Debugging](https://session-kit.pages.dev/docs/configuration/debugging): Log what the client does, or send it to your error reporting.
# User schemas (https://session-kit.pages.dev/docs/configuration/user-schemas)
The schema you pass as `createAuth`'s `user` option checks each user the
client accepts and gives the user its type. This page explains when it runs,
and the two rules it must follow so that a saved session can be restored.
Any [Standard Schema](https://standardschema.dev) library works, such as Zod,
Valibot or ArkType. If your backend puts the user in the access token
instead, see [Users from the access token](https://session-kit.pages.dev/docs/client/users-from-tokens).
## When the schema runs
The client runs the schema on every user it accepts:
- At sign-in.
- When `updateUser` replaces the user.
- After a refresh that returns a user.
- When it restores a saved session.
- When another tab saves a session.
It keeps a frozen copy of each user it accepts. To change the user, call
[`updateUser`](https://session-kit.pages.dev/docs/client/updating-the-user).
## The user's type
`createAuth` infers the user's type from the schema's output. Hover `user` to
see it:
```ts twoslash
import { createAuth, memoryStorage } from "@monarcode/session-kit";
import { z } from "zod";
const auth = createAuth({
name: "my-app",
user: z.object({ id: z.number(), firstName: z.string() }),
storage: memoryStorage(),
});
const { user } = auth.state.get();
// ^?
```
To name the types elsewhere, use `UserOf` and
`SessionOf`. To type the hooks from
`@monarcode/session-kit/react`, register the client once, as
[Getting started](https://session-kit.pages.dev/docs/getting-started#4-read-the-session-in-react) shows.
## Rules for restoring
The client saves the user beside the tokens, and runs the schema on it again
when it restores the session. That sets two rules for the schema:
- **Its output is a plain JSON object.** Dates, `Map`s and class instances
can't be saved.
- **It accepts its own output and returns it unchanged.** A one-way
transform, such as one that renames a field, breaks this rule.
A schema that breaks either rule is rejected with `USER_VALIDATION_FAILED`
the first time it accepts a user, usually at sign-in. That way the problem
shows up at once, not as users being signed out on their next reload.
## Users the schema rejects
A deploy can change the schema so that it rejects users saved before it. The
client doesn't restore such a user, so the user signs in again. A user saved by
another tab that the schema rejects signs every tab out, with
`USER_VALIDATION_FAILED` in `state.error`.
# Refresh (https://session-kit.pages.dev/docs/configuration/refresh)
`createAuth`'s `refresh` option is a callback that exchanges the refresh
token for new tokens. This page explains what its results mean and when the
client calls it. For a callback to start from, see `refresh` in
[Getting started](https://session-kit.pages.dev/docs/getting-started#1-talk-to-your-backend).
## What the callback returns
The callback ends in one of three ways, and the client handles each
differently:
| The callback | What the client does |
| -------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Returns tokens | Saves them. A refresh token or user it leaves out keeps the current one. |
| Returns `null` | Treats the refresh token as rejected: signs the user out and clears storage. |
| Throws | Treats it as an outage: keeps the session and reports `REFRESH_FAILED`. See [When a refresh fails](#when-a-refresh-fails). |
When the client can tell
[when the new access token expires](https://session-kit.pages.dev/docs/configuration/expiry), the token
must last at least five seconds. A shorter one is an `INVALID_SESSION` error.
Return `null` only when your backend rejected the refresh token, such as with
a 401. Throw for anything else, such as a network error or a 500, so that an
outage doesn't sign anyone out. To sign the user out on every failure, return
`null` instead of throwing.
## When a refresh runs
The client refreshes at one of three moments:
- **Early.** While the client is mounted, 60 seconds before the access token
expires, or halfway through when the token lasts under two minutes. It
tries once per token. If that fails, the token stays usable until it
expires.
- **At expiry, or after a 401.** `status` is `refreshing` while it runs, and
the user stays on screen.
- **On request.** `auth.refresh()` runs one now.
## When a refresh fails
If a refresh the session needed fails, `status` becomes `unavailable`.
`getSession()` then rejects with the same error until you call `auth.retry()`,
or the user signs in or out. Guards that run again therefore can't start a
refresh loop.
## Deadlines
The client gives each refresh, and each check of a user, 15 seconds. The
callback's `signal` aborts when that time runs out or when the session
changes, such as on sign-out. Pass it to `fetch`.
# Expiry (https://session-kit.pages.dev/docs/configuration/expiry)
The client refreshes the access token before it expires, so it needs to know
when that is. This page explains where it reads the expiry from, and which
field to send.
## Where the expiry comes from
The client takes the expiry from the first of these that your tokens have:
1. `expiresIn`, in seconds as in OAuth, or `expiresAt`, in Unix
milliseconds.
2. A JWT access token's `exp` and `iat` claims, as a lifetime.
3. A JWT access token's `exp` claim alone, as a time.
Send `expiresIn` when your backend gives a lifetime, as most do. The client
counts it from when it receives the tokens, so a wrong device clock doesn't
matter. A JWT with `iat` works the same way. Send `expiresAt` only when your
backend gives no lifetime.
Pass `expiresIn` or `expiresAt`, not both. Tokens with both are an
`INVALID_SESSION` error. See [`Tokens`](https://session-kit.pages.dev/docs/reference/core#tokens) for
each field.
To map an OAuth-style response to `Tokens`, pass `expires_in` as
`expiresIn`:
```ts title="src/tokens.ts"
import type { Tokens } from "@monarcode/session-kit";
type TokenResponse = {
access_token: string;
refresh_token: string;
expires_in: number;
};
export const toTokens = (body: TokenResponse): Tokens => ({
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresIn: body.expires_in,
});
```
## Fixed times and device clocks
`expiresAt` and a JWT's `exp` alone are fixed times, which the client
compares with the device's clock. Device clocks can be minutes off. A clock
that runs fast makes the token look already expired, and `signIn` rejects
it with `INVALID_SESSION`.
## Tokens without an expiry
When the client can't tell when the token expires, it uses the token until
your API rejects it. [`authFetch`](https://session-kit.pages.dev/docs/client/requests) then refreshes after
the 401.
# Storage (https://session-kit.pages.dev/docs/configuration/storage)
`createAuth`'s `storage` option chooses where the client saves the session.
This page helps you pick a built-in store, or write your own.
## Built-in stores
The `storage` option takes one of Session kit's stores, or any object that
implements `AuthStorage`:
| Storage | Saved in | Use it for |
| --------------------------------- | ----------------------------------------- | ---------------------------------------------------- |
| `webStorage()` | `localStorage` | Browsers. One session, shared by every tab. |
| `webStorage({ area: "session" })` | `sessionStorage` | One session per tab, ending with the tab. |
| `secureStorage()` | The iOS keychain, or encrypted on Android | Expo apps. From `@monarcode/session-kit/expo`. |
| `memoryStorage()` | Memory | Tests, and sessions that shouldn't outlive the page. |
| Your own `AuthStorage` | Anywhere | Another store. Its operations may return promises. |
For Expo, `secureStorage()` splits a session larger than 2048 bytes, which
some iOS releases refuse to store in one value, across several keys. See
[Secure storage](https://session-kit.pages.dev/docs/setups/expo#secure-storage) in the Expo guide.
## Rotating refresh tokens
If your backend rotates refresh tokens, use `localStorage`, not
`sessionStorage`. A browser copies `sessionStorage` into a duplicated tab, and
into a window opened with `window.open`. Both then start with the same
refresh token but never see each other's saves. The second to refresh sends a
spent token and is signed out. With reuse detection on the backend, both are.
## Write your own store
An `AuthStorage` needs three operations: `get`, `set` and `remove`. Each can
return a promise. `set` also receives `expiresAt`: when the saved session
stops being valid, `maxAge` after this save, in Unix milliseconds.
For example, to save the session in a key-value store your app already has,
which removes each entry when it expires:
```ts title="src/storage.ts"
import type { AuthStorage } from "@monarcode/session-kit";
declare const store: {
get(key: string): Promise;
put(
key: string,
value: string,
options: { expiresAt: number },
): Promise;
delete(key: string): Promise;
};
export const kvStorage: AuthStorage = {
get: async (key) => (await store.get(key)) ?? null,
// The store drops the session itself once it's no longer valid.
set: (key, value, { expiresAt }) => store.put(key, value, { expiresAt }),
remove: (key) => store.delete(key),
};
```
The client runs your store's operations one at a time, in the order it
issued them. A store that several tabs share also needs `subscribe` and
`lock`, so that [tabs follow each other](https://session-kit.pages.dev/docs/configuration/tabs). See
[`AuthStorage`](https://session-kit.pages.dev/docs/reference/core#authstorage) for each member.
## What the client saves
The client saves the tokens and the user in one `:auth:session` entry.
Each save is then a single write, which other tabs see whole. A
[user read from the access token](https://session-kit.pages.dev/docs/client/users-from-tokens) isn't
saved. The client reads it from the token again on restore.
# Tabs (https://session-kit.pages.dev/docs/configuration/tabs)
In a browser, each open tab runs its own client over the same saved session.
This page explains how the tabs stay in step, and how they share one refresh.
## Sign-ins and sign-outs
While the client is [mounted](https://session-kit.pages.dev/docs/client), each tab follows the others
through `storage` events:
- A sign-out in one tab signs out every tab.
- A sign-in or an account switch switches every tab.
- A profile update or a refresh shows in every tab.
With server rendering, `createMirrorAuth` clients follow each other over a
`BroadcastChannel` instead, as [Server rendering](https://session-kit.pages.dev/docs/server-rendering)
explains.
## One refresh at a time
The client refreshes under the `:auth:refresh`
[Web Lock](https://developer.mozilla.org/docs/Web/API/Web_Locks_API). One tab
at a time spends the refresh token, and the others reuse the tokens it saved.
Rotating refresh tokens therefore work with several tabs open.
Without Web Locks, two tabs that refresh at the same instant can both spend
the token. A short reuse window on your backend covers that case.
## Expo apps
An Expo app has one JavaScript context, so it has no tabs to follow and needs
no lock. In a browser, [`locking`](https://session-kit.pages.dev/docs/client/locking) stops tabs from
following each other.
# Errors (https://session-kit.pages.dev/docs/configuration/errors)
Every error from Session kit's own logic is an `AuthError`, whether a method
throws it, rejects with it, or publishes it in `state.error`. This page shows
how to match on them, how to turn them into messages for your users, and the
storage failures you need to handle.
## Error codes
Match on an error's `code`, not its message, which may change in any
release. [Error codes](https://session-kit.pages.dev/docs/reference/error-codes) lists every code and what
it means.
## Show errors to users
An error's `cause` holds whatever failed underneath, such as the error your
refresh callback threw. To keep messages out of your components, map codes
to your own wording:
```ts title="src/error-message.ts"
import { AuthError, type AuthErrorCode } from "@monarcode/session-kit";
const messages: Partial> = {
REFRESH_FAILED: "We can't reach the server. Check your connection.",
PERSISTENCE_FAILED: "We couldn't save or clear your session.",
UNAUTHENTICATED: "Sign in to continue.",
};
export function errorMessage(error: unknown): string {
if (error instanceof AuthError) return messages[error.code] ?? error.message;
return error instanceof Error ? error.message : "Something went wrong";
}
```
`authFetch` passes on the failures of `fetch` and `Request`, such as a
network error, unchanged. With server rendering, the browser client's
`signIn` passes on what your sign-in function threw, unchanged too. These
usually aren't `AuthError`s, so `errorMessage` shows their own message.
## Handle storage failures
Three storage failures need a decision from you:
- **Storage can't be read.** `status` becomes `unavailable`, with the error
in `state.error`. The saved session stays as it is, and the next
`getSession()` or `retry()` reads it again.
- **Storage can't be written.** The client ends the session and publishes
the error. That way, no one stays signed in to a session that would vanish
on reload.
- **Storage can't be cleared.** Sign-out still takes effect in this tab, but
`signOut()` rejects and `state.error` keeps the error. The saved
credentials could restore the session on reload, so call `signOut()` again
once storage works.
# Debugging (https://session-kit.pages.dev/docs/configuration/debugging)
`createAuth`'s `debug` option reports what the client does: restores,
refreshes and why they ran, timers, storage operations and how long they
took, other tabs' changes, state changes, and what ended a session. Use it to
find out why a session ended, or why a refresh ran.
## Log to the console
To log each event as one `console.info` line, pass `debug: true`. A refresh
logs a line like this one:
```text
[session-kit:my-app] refresh: started (proactive) { sessionId: "…", reason: "proactive" }
```
## Send events to your error reporting
To receive each event instead of logging it, pass a function. For example, to
leave breadcrumbs in your error reporting:
```ts title="src/debug.ts"
import type { AuthDebug } from "@monarcode/session-kit";
declare function addBreadcrumb(crumb: {
category: string;
message: string;
data: object;
}): void;
/** Pass as `createAuth`'s `debug` option. */
export const debug: AuthDebug = (event) =>
addBreadcrumb({
category: `auth.${event.type}`,
message: event.message,
data: { ...event.data, code: event.error?.code },
});
```
## Tokens and user data
Events leave out tokens, saved values and the user's fields. A session
appears as its `sessionId`, and an error as its code and message, without
its `cause` or `issues`.
To include them, pass `{ sensitive: true }`. Anyone who reads such logs can
act as the user, so keep it to development builds. To set both, pass
`{ log, sensitive }`, where `log` is your function.
## What stays stable
An event's `type` is one of a fixed set, such as `"refresh"` or `"storage"`,
so you can filter on it. The wording of `message` and the fields in `data`
may change in any release.
A storage adapter can add its own events through the optional `debug` member
of `AuthStorage`, as [`secureStorage()`](https://session-kit.pages.dev/docs/setups/expo#debugging) does.
# Effect and effetch (https://session-kit.pages.dev/docs/effect)
In this guide, you use the session from an [Effect](https://effect.website)
app. You read it as a service, call your API with
[effetch](https://github.com/monarcode/effetch), and cache each account's data
with [effetch-query](https://github.com/monarcode/effetch-query). Two entry
points do this:
- **`@monarcode/session-kit/effect`** gives your Effects the session as the
`Auth` service. It wraps the client you already have, so React, your router
and your Effects share one session. Its Effects fail with a tagged
`AuthFailure`, which `Effect.catchTag` handles like your own errors.
- **`@monarcode/session-kit/effetch`** has the `sessionBearer` plugin. It
sends the access token with each effetch call, and refreshes after a 401.
A complete app is in
[`examples/tanstack-start-spa-effetch`](https://github.com/monarcode/session-kit/tree/main/examples/tanstack-start-spa-effetch).
Every export is listed in the reference for
[`./effect`](https://session-kit.pages.dev/docs/reference/effect) and [`./effetch`](https://session-kit.pages.dev/docs/reference/effetch).
## Before you begin
- Read [Getting started](https://session-kit.pages.dev/docs/getting-started). This guide builds the same
app with Effect Schemas and effetch. [Refresh](https://session-kit.pages.dev/docs/configuration/refresh)
and [Errors](https://session-kit.pages.dev/docs/configuration/errors) explain how the client renews and
fails.
- Know Effect's generators, services and layers.
## Installation
To install the packages, run the following command:
```npm
npm install @monarcode/session-kit effect @monarcode/effetch
```
`effect` and `@monarcode/effetch` are optional peer dependencies of Session
kit. `./effect` and `./effetch` need `effect` 4.1 or newer, and `./effetch`
also needs `@monarcode/effetch`.
To [cache with effetch-query](#cache-with-effetch-query), also install
effetch-query 0.1.0-rc.2 or newer, and its `@effect/atom-react` peer for
React:
```npm
npm install @monarcode/effetch-query @effect/atom-react
```
## 1. Describe your backend
This is the backend from
[Getting started](https://session-kit.pages.dev/docs/getting-started#1-talk-to-your-backend), as Effect
Schemas: the user, the tokens, and the body DummyJSON explains a failure
with. They're schemas only, with no client, so the rest of the app, and the
[server](#on-the-server), import them from one place:
```ts title="src/backend.ts"
import * as Schema from "effect/Schema";
export const API_BASE_URL = "https://dummyjson.com";
export const SessionUser = Schema.Struct({
id: Schema.Finite,
username: Schema.String,
email: Schema.String,
firstName: Schema.String,
});
/** The user, as Session kit's `user` option takes it. */
export const userSchema = Schema.toStandardSchemaV1(SessionUser);
export const SessionTokens = Schema.Struct({
accessToken: Schema.String,
refreshToken: Schema.String,
});
/** DummyJSON returns the user's fields beside the tokens. */
export const SignInResponse = Schema.Struct({
...SessionTokens.fields,
...SessionUser.fields,
});
export const ApiError = Schema.Struct({ message: Schema.String });
```
## 2. Refresh with effetch
The refresh gets an effetch client of its own, made with its own
`Fetch.make`, for two reasons:
- The app's API client
[waits for a refresh](#6-call-your-api-with-effetch) after a 401. A refresh
sent through it would wait for itself.
- The app's runtime is built on the session client this refresh belongs to,
so the refresh can't run in it. It provides the `FetchHttpClient` effetch
sends with itself.
`refreshSession` resolves `null` when the backend rejects the refresh token,
and fails for anything else, as [Refresh](https://session-kit.pages.dev/docs/configuration/refresh)
explains. The server uses it too:
```ts title="src/refresh.ts"
import * as Fetch from "@monarcode/effetch";
import * as Effect from "effect/Effect";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as HttpClient from "effect/http/HttpClient";
import { API_BASE_URL, SessionTokens } from "./backend.js";
// Not the app's API client: its session plugin waits for this refresh.
const makeRefreshClient = Fetch.make({ baseUrl: API_BASE_URL }).pipe(
// No tracing headers, as for the API client in step 6.
Effect.provideService(HttpClient.TracerPropagationEnabled, false),
);
/** New tokens. `null` signs out; a failure keeps the session for a retry. */
export const refreshSession = (refreshToken: string, signal: AbortSignal) =>
Effect.runPromise(
Effect.gen(function* () {
const client = yield* makeRefreshClient;
return yield* client.post("/auth/refresh", {
body: { refreshToken },
output: SessionTokens,
});
}).pipe(
Effect.catchTag("HttpError", (error) =>
error.status === 401 || error.status === 403
? Effect.succeed(null)
: Effect.fail(error),
),
Effect.provide(FetchHttpClient.layer),
),
{ signal },
);
```
## 3. Register the client
Create the client as usual, then register its type once, so every Effect
carries your user. The same registration types the hooks from
`@monarcode/session-kit/react`:
```ts title="src/auth.ts"
import { createAuth, webStorage } from "@monarcode/session-kit";
import { userSchema } from "./backend.js";
import { refreshSession } from "./refresh.js";
export const auth = createAuth({
name: "app",
user: userSchema,
storage: webStorage(),
refresh: ({ refreshToken, signal }) => refreshSession(refreshToken, signal),
});
declare module "@monarcode/session-kit" {
interface Register {
auth: typeof auth;
}
}
```
## 4. Provide the service
`Auth.layer` provides the client as the `Auth` service. It doesn't mount the
client: `AuthProvider`, `SessionGate` or the router adapters still do that.
To provide it, create the layer once:
```ts title="src/layer.ts"
import { Auth } from "@monarcode/session-kit/effect";
import { auth } from "./auth.js";
export const AuthLive = Auth.layer(auth);
```
In tests, provide a layer over another client instead.
## 5. Read the session
`Auth.session` is the current session. If the access token expired, it
refreshes first. It fails with `UNAUTHENTICATED` when no one is signed in.
For code that also runs signed out, `Auth.sessionOption` gives
`Option.none()` instead:
```ts title="src/greeting.ts"
import { Auth } from "@monarcode/session-kit/effect";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import { AuthLive } from "./layer.js";
export const greeting = Auth.sessionOption.pipe(
Effect.map(
Option.match({
onNone: () => "Welcome",
onSome: (session) => `Welcome back, ${session.user.firstName}`,
}),
),
);
export const showGreeting = () =>
Effect.runPromise(Effect.provide(greeting, AuthLive));
```
## 6. Call your API with effetch
Make the app's API client with effetch's `Fetch.make`: the backend's base
URL, its error body, and the plugins every call goes through. The
`sessionBearer` plugin does what [`createAuthFetch`](https://session-kit.pages.dev/docs/client/requests)
does: it sends the access token, and refreshes after a 401.
Put `sessionBearer` last, so that a retry reads the current token. Give it the
client's `baseUrl`, since the token goes only to that origin. `Fetch.make`
then needs the `Auth` service, once:
```ts title="src/api.ts"
import * as Fetch from "@monarcode/effetch";
import { retry } from "@monarcode/effetch/plugins";
import { sessionBearer } from "@monarcode/session-kit/effetch";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as HttpClient from "effect/http/HttpClient";
import * as Layer from "effect/Layer";
import { API_BASE_URL, ApiError } from "./backend.js";
const make = Fetch.make({
baseUrl: API_BASE_URL,
defaultError: ApiError,
plugins: [retry(), sessionBearer({ baseUrl: API_BASE_URL, optional: true })],
}).pipe(
// Effect adds tracing headers to every request, which fail the preflight
// of an API that doesn't allow them.
Effect.provideService(HttpClient.TracerPropagationEnabled, false),
);
export class ApiClient extends Context.Service<
ApiClient,
Effect.Success
>()("ApiClient") {}
/** The API client, over whichever `Auth` is provided. */
export const ApiClientLive = Layer.effect(ApiClient, make);
```
Calls can fail with `AuthFailure`. When one client serves public and private
endpoints, as this one does, give the plugin `optional: true`: a call made
while signed out is then sent without a token, and a private endpoint
answers with its own 401. A locked client still fails with `LOCKED`.
Effect code yields the client and calls its methods, such as `get` and
`post`. The sign-in call skips the plugin with `sessionBearer: false`,
because it's how the user gets a token. To read the current user and to sign
in, add `src/user.ts`:
```ts title="src/user.ts"
import * as Effect from "effect/Effect";
import { ApiClient } from "./api.js";
import { SessionUser, SignInResponse } from "./backend.js";
export const currentUser = Effect.gen(function* () {
const client = yield* ApiClient;
return yield* client.get("/auth/me", { output: SessionUser });
});
export const login = (username: string, password: string) =>
Effect.gen(function* () {
const client = yield* ApiClient;
return yield* client.post("/auth/login", {
body: { username, password },
output: SignInResponse,
plugins: { sessionBearer: false },
});
});
```
With [another HTTP client](https://session-kit.pages.dev/docs/client/other-http-clients), use
`Auth.credentials`, the access token. It's kept apart from `Auth.session`, so
code that only needs the user never holds a token. After a 401,
`Auth.renew(credentials)` gets new credentials once for every caller.
## 7. Run Effects in one runtime
Compose the app's layers in one place: the API client, the session, and the
`FetchHttpClient` effetch sends with, each made once:
```ts title="src/runtime.ts"
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as Layer from "effect/Layer";
import * as ManagedRuntime from "effect/ManagedRuntime";
import { ApiClientLive } from "./api.js";
import { AuthLive } from "./layer.js";
const AppLayer = ApiClientLive.pipe(
Layer.provideMerge(AuthLive),
Layer.provide(FetchHttpClient.layer),
);
/** One runtime for the app's lifetime. */
export const appRuntime = ManagedRuntime.make(AppLayer);
export type AppRuntime = typeof appRuntime;
```
Run every Effect with this runtime, as in
`appRuntime.runPromise(currentUser)`, instead of providing `AuthLive` as
step 5 does.
## Handle failures
Every `Auth` Effect fails with `AuthFailure`. Its `code` is the client's
[`AuthErrorCode`](https://session-kit.pages.dev/docs/configuration/errors), and its `cause` is the
`AuthError` itself. Misuse, which the client reports as `INVALID_OPTIONS`, is
a defect rather than a failure.
For example, to show "Guest" when no one is signed in:
```ts title="src/account.ts"
import { Auth } from "@monarcode/session-kit/effect";
import * as Effect from "effect/Effect";
export const accountName = Auth.session.pipe(
Effect.map((session) => session.user.firstName),
Effect.catchTag("AuthFailure", (failure) =>
failure.code === "UNAUTHENTICATED"
? Effect.succeed("Guest")
: Effect.fail(failure),
),
);
```
A client method that `Auth` doesn't wrap, such as `auth.unlock()`, rejects
with an `AuthError`. `Auth.fromAuthError` turns it into an `AuthFailure`.
Anything else it's given, such as a network error from `createAuthFetch`, is
a defect.
## Update the user
`Auth.updateUser` takes the user, or an Effect that fetches it. The session is
captured before the Effect runs, so a profile fetched for one account fails
with `SESSION_CHANGED` instead of landing on another. The Effect's own
failure comes through unchanged:
```ts title="src/profile.ts"
import { Auth } from "@monarcode/session-kit/effect";
import { currentUser } from "./user.js";
export const reloadProfile = Auth.updateUser(currentUser);
```
## Watch the session
`Auth.changes` is a `Stream` of the client's `AuthState`: the current state,
then every change, until the stream ends:
```ts title="src/watch.ts"
import { Auth } from "@monarcode/session-kit/effect";
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
export const logStatus = Auth.changes.pipe(
Stream.map((state) => state.status),
Stream.changes,
Stream.runForEach((status) => Effect.log(`Auth is ${status}`)),
);
```
## Cache with effetch-query
effetch-query caches endpoints: a route and its schemas, defined with
`Fetch.endpoint`, which it calls with the app's API client. Its queries and
mutations run with the app's runtime, so components never run an Effect by
hand. Give it the client's `defaultError`, which an endpoint can't read from
the client:
```ts title="src/query.ts"
import { QueryClient } from "@monarcode/effetch-query";
import { endpoints } from "@monarcode/effetch-query/effetch";
import type * as ManagedRuntime from "effect/ManagedRuntime";
import { ApiClient } from "./api.js";
import { ApiError } from "./backend.js";
import { appRuntime, type AppRuntime } from "./runtime.js";
declare module "@monarcode/effetch-query" {
interface Register {
readonly services: ManagedRuntime.ManagedRuntime.Services;
}
}
export const api = endpoints(ApiClient, { defaultError: ApiError });
export const makeQueryClient = () => QueryClient.make({ runtime: appRuntime });
```
A cache must not outlive the account that filled it. Let `QueryProvider` own
its client (`make` instead of `client`) and pass the session ID as `resetKey`.
Signing out or switching accounts then disposes the client, with its cache
and its requests in flight, and renders the tree with a new one:
```tsx title="src/query-provider.tsx"
import type { ReactNode } from "react";
import { QueryProvider } from "@monarcode/effetch-query/react";
import { useAuth } from "@monarcode/session-kit/react";
import { makeQueryClient } from "./query.js";
export function Queries({ children }: { children: ReactNode }) {
const account = useAuth((state) => state.sessionId ?? "signed-out");
return (
{children}
);
}
```
Render it inside `AuthProvider` or `SessionGate`, above every `Suspense`
boundary. A refresh, a failed refresh or a profile update keeps the
`sessionId`, and so the cache. So does locking: queries that need a token
fail with `LOCKED` until `unlock()`, then refetch as usual. Until a saved
session is restored, the key is `"signed-out"`, and the restore replaces
that client.
A feature defines the endpoints it caches, and its components use the
queries and mutations made of them:
```tsx title="src/features/account.tsx"
import * as Fetch from "@monarcode/effetch";
import { useMutation, useQuery } from "@monarcode/effetch-query/react";
import { useAuthClient } from "@monarcode/session-kit/react";
import * as Exit from "effect/Exit";
import * as AsyncResult from "effect/reactivity/AsyncResult";
import * as Schema from "effect/Schema";
import { SessionUser, SignInResponse } from "../backend.js";
import { api } from "../query.js";
const signInMutation = api.mutation(
Fetch.endpoint("POST /auth/login", {
input: Schema.Struct({ username: Schema.String, password: Schema.String }),
output: SignInResponse,
}),
{ plugins: { sessionBearer: false } },
);
const profileQuery = api.query(
Fetch.endpoint("GET /auth/me", { output: SessionUser }),
);
export function SignInButton() {
const auth = useAuthClient();
const { result, run } = useMutation(signInMutation);
async function signIn() {
const exit = await run({
// DummyJSON's documented test user.
body: { username: "emilys", password: "emilyspass" },
});
// A failed request shows in `result`.
if (Exit.isFailure(exit)) return;
const { accessToken, refreshToken, ...user } = exit.value;
await auth.signIn({ accessToken, refreshToken, user });
}
return (
);
}
export function Email() {
const result = useQuery(profileQuery());
return AsyncResult.match(result, {
onInitial: () =>