Schema-driven browser sessions for React, with TanStack Router and React Router support.
Version: 0.1.0-alpha.4. Early development; not ready for production.
session-kit keeps the tokens your backend issues and manages the session around them. Your backend does the authentication; session-kit does the rest:
- Schema-driven users. A Standard Schema
validates the user at sign-in, on refresh, on restore, and when another tab
saves one, and supplies its TypeScript types. Or read the user from a JWT
access token with
fromAccessToken. - Refresh you can trust. Early refresh before expiry, one refresh at a time across tabs, recovery after a 401, and a clear split between "sign in again" and "try again later".
- Tabs that agree. Sign-in, sign-out, account switches, profile updates, and refreshes reach every open tab.
- Router guards. Redirect signed-out users with a return URL, keep guarded pages in sync with the session, and re-run guards when auth changes.
- Tokens stay out of reach. Reactive state and sessions hold no tokens, so
they are safe to render and to put in route context. Tokens go only to code
that attaches them to requests, such as
createAuthFetch.
- Supported setups
- Installation
- Quick start: TanStack Router
- Other setups
- Using the client
- Schemas, refresh, and storage
- Security notes
- Entry points
- Development
Each router setup has a runnable app in examples/, all signing
in against DummyJSON's public test API.
| Setup | Import | Example app |
|---|---|---|
| TanStack Router | @monarcode/session-kit/tanstack-router |
tanstack-router |
| TanStack Start, SPA mode | @monarcode/session-kit/tanstack-router |
tanstack-start-spa |
| React Router, declarative mode | @monarcode/session-kit/react-router |
react-router-declarative |
| React Router, data mode | @monarcode/session-kit/react-router |
react-router-data |
React Router, framework mode, ssr: false |
@monarcode/session-kit/react-router |
react-router-framework-spa |
| React without a router | @monarcode/session-kit/react |
None |
Storage adapters: webStorage() for localStorage or sessionStorage,
memoryStorage(), or your own AuthStorage, which may be
asynchronous.
Planned for releases after 0.1.0, not yet available:
- Server rendering: TanStack Start with SSR, and React Router framework
mode with SSR, keeping tokens in an encrypted
httpOnlycookie on the server. AcookieStorage()adapter would read and write that cookie. - Expo (React Native): a
secureStorage()adapter forexpo-secure-store, and refreshing when the app returns to the foreground.
Alpha releases are published to the latest distribution tag:
pnpm add @monarcode/session-kitPin @monarcode/session-kit@0.1.0-alpha.4 to use this exact version. Alpha
releases may change the public API; see the release notes.
All peer dependencies are optional, so install only what you use:
| Peer | Range | Needed for |
|---|---|---|
react |
^18.0.0 || ^19.0.0 |
/react, /tanstack-router, /react-router |
@tanstack/react-router |
^1.132.0 |
/tanstack-router |
react-router |
^7.0.0 || ^8.0.0 |
/react-router |
CI tests the oldest and newest versions in each range. session-kit never imports React DOM. Any Standard Schema V1 library describes users; the examples use Zod:
pnpm add react react-dom @tanstack/react-router zodThe package is ESM-only and checked with TypeScript 6.0.3 under NodeNext and
Bundler resolution. It needs AbortController, and uses crypto.randomUUID and
Web Locks when present, falling back without them.
This example signs in against DummyJSON's auth API,
a public test backend, so it runs as written. It is the
tanstack-router example app trimmed to its
essentials. It uses file-based routing, so add the Router plugin before the
React plugin:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
export default defineConfig({
plugins: [
tanstackRouter({ target: "react", autoCodeSplitting: true }),
react(),
],
});The plugin generates src/routeTree.gen.ts from the files under src/routes.
src/auth.ts
import { createAuth, webStorage } from "@monarcode/session-kit";
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(),
});
// DummyJSON returns the user's fields beside the tokens.
const loginResponseSchema = tokensSchema.extend(userSchema.shape);
export const auth = createAuth({
name: "my-app",
user: userSchema,
storage: webStorage(),
refresh: async ({ refreshToken, signal }) => {
const response = await fetch(`${API}/auth/refresh`, {
method: "POST",
signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
// Rejected refresh tokens mean "sign in again": DummyJSON answers 403
// for an invalid one and 401 for a missing one.
if (response.status === 401 || response.status === 403) return null;
// Anything else is operational: keep the session and report the error.
if (!response.ok) throw new Error(`Refresh failed (${response.status})`);
return tokensSchema.parse(await response.json());
},
});
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 });
}
export type AppAuth = typeof auth;Create one client per auth name in each tab. DummyJSON's tokens are JWTs, so
expiry comes from their exp and iat claims; see
Expiry. Requests omit credentials: "include": session-kit keeps
tokens out of cookies and attaches them itself.
src/auth-hooks.ts
import { createAuthHooks } from "@monarcode/session-kit/react";
import { useRouterAuth } from "@monarcode/session-kit/tanstack-router";
export const { useAuth, useAuthClient } = createAuthHooks({
useClient: useRouterAuth,
});useRouterAuth reads the client from Router context, so the hooks need no
provider, and Router registration in step 6 gives them your user type.
useAuth() returns the reactive state; useAuth(selector) re-renders only when
the selection changes. useAuthClient() returns the client for actions.
src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import type { AppAuth } from "../auth.js";
import { useAuth } from "../auth-hooks.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 could not clear storage.
const error = useAuth((state) => state.error);
return (
<>
{error && <p role="alert">{error.message}</p>}
<Outlet />
</>
);
}src/routes/login.tsx
import { useState, type FormEvent } from "react";
import { createFileRoute, useRouter } from "@tanstack/react-router";
import { safeReturnTo } from "@monarcode/session-kit";
import { redirectIfSignedIn } from "@monarcode/session-kit/tanstack-router";
import { signInWithPassword } from "../auth.js";
export const Route = createFileRoute("/login")({
validateSearch: (search: Record<string, unknown>) => ({
redirectTo: safeReturnTo(search.redirectTo),
}),
beforeLoad: ({ context, search }) =>
redirectIfSignedIn(context.auth, { redirectTo: search.redirectTo }),
component: Login,
});
function Login() {
const router = useRouter();
const { redirectTo } = Route.useSearch();
const [error, setError] = useState("");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
setError("");
try {
await signInWithPassword(
String(form.get("username")),
String(form.get("password")),
);
await router.navigate({ href: redirectTo, replace: true });
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not sign in");
}
}
return (
<form onSubmit={submit}>
<h1>Sign in</h1>
{/* DummyJSON's documented test user. */}
<input
name="username"
aria-label="Username"
defaultValue="emilys"
autoComplete="username"
/>
<input
name="password"
aria-label="Password"
type="password"
defaultValue="emilyspass"
autoComplete="current-password"
/>
{error && <p role="alert">{error}</p>}
<button>Sign in</button>
</form>
);
}safeReturnTo accepts only same-origin paths and returns / for anything else,
including the sign-in page itself, so a stale redirectTo cannot loop. Pass
{ loginPath } if sign-in lives somewhere other than /login.
redirectIfSignedIn sends signed-in users on to redirectTo, checked the
same way.
src/routes/_authenticated.tsx is a pathless layout: its child routes are
guarded without authenticated appearing in their URLs.
import {
createFileRoute,
type ErrorComponentProps,
} from "@tanstack/react-router";
import {
requireSession,
SessionOutlet,
} from "@monarcode/session-kit/tanstack-router";
import { useAuthClient } from "../auth-hooks.js";
export const Route = createFileRoute("/_authenticated")({
beforeLoad: ({ context, location }) =>
requireSession(context.auth, { location, loginPath: "/login" }),
errorComponent: SessionError,
component: () => <SessionOutlet pending={<p>Checking session…</p>} />,
});
/** Shown after a failed refresh; a repeated failure stays in state.error. */
function SessionError({ error }: ErrorComponentProps) {
const auth = useAuthClient();
return (
<div role="alert">
<p>{error instanceof Error ? error.message : "Something went wrong"}</p>
<button onClick={() => void auth.retry().catch(() => {})}>Retry</button>
</div>
);
}requireSession redirects signed-out users to loginPath with redirectTo
set to where they were going, and puts { session } in route context: child
routes get session.user, typed and never null. 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.
src/routes/_authenticated/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useAuth, useAuthClient } from "../../auth-hooks.js";
export const Route = createFileRoute("/_authenticated/")({
component: Home,
});
function Home() {
const user = useAuth((state) => state.user);
const auth = useAuthClient();
return (
<main>
<h1>Welcome, {user?.firstName}</h1>
<button onClick={() => void auth.signOut().catch(() => {})}>
Sign out
</button>
</main>
);
}src/router.tsx
import { createRouter } from "@tanstack/react-router";
import { connectAuth } from "@monarcode/session-kit/tanstack-router";
import { auth } from "./auth.js";
import { routeTree } from "./routeTree.gen.js";
export const router = createRouter({
routeTree,
context: { auth },
defaultPendingComponent: () => <p>Loading…</p>,
});
// 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;
}
}With synchronous storage and a synchronous schema, connectAuth restores the
saved session before the first render. Call disconnectAuth on app teardown or
hot-module disposal; it stops background work without signing out.
src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./router.js";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);The same client works everywhere; only the router wiring changes. The example apps show each setup complete; these are the guards each one uses:
| Setup | Guards |
|---|---|
| TanStack Router | requireSession in beforeLoad, SessionOutlet |
| TanStack Start, SPA mode | Same as TanStack Router |
| React Router, declarative mode | SessionOutlet alone |
| React Router, data mode | requireSession in loaders, SessionOutlet, useAuthRevalidation |
React Router, framework mode, ssr: false |
requireSession in clientLoader, SessionOutlet, useAuthRevalidation |
TanStack Start in SPA mode. Create the client and call connectAuth inside
getRouter(). Set defaultSsr: false in src/start.ts: Start's own server
still renders requests in SPA mode, and guards need the browser's saved
session. connectAuth does nothing while the Router renders on a server, so
the build-time shell shows your pending component.
React Router. Import from @monarcode/session-kit/react-router, and create
hooks with a provider: createAuthHooks<typeof auth>() returns AuthProvider,
useAuth, and useAuthClient. Render <AuthProvider client={auth}> around
the router; it mounts the client in a layout effect, so a synchronous restore
happens before the first paint, and never on a server.
- Declarative mode:
<SessionOutlet loginPath="/login" />is the guard. It showspendingwhile restoring, sends signed-out users tologinPathwithredirectTo, and rendersunavailable({ error, retry })after a failed refresh, or throws to the nearest error boundary without it. - Data and framework mode:
requireSession(auth, { request, loginPath })guards loaders and returns{ session }, andredirectIfSignedIn(auth, { redirectTo })guards the sign-in loader. CalluseAuthRevalidation()once in the root layout so loaders re-run when auth changes. RenderSessionOutletas the element of the guarded route: when another account signs in, it showspendinguntil the loader accepts the new session, so child routes never see the previous account's loader data. With a routerbasename, pass the samebasenametorequireSession.
Without a router. createAuthHooks<typeof auth>() and AuthProvider work
in any React app.
const state = auth.state.get();
const { unsubscribe } = auth.state.subscribe((next) => {});subscribe reports later changes, not the current value. A listener that
throws does not interrupt auth; its error is rethrown asynchronously, where
error reporting can catch it. Checking status narrows user:
status |
Meaning | user |
|---|---|---|
initializing |
The saved session is being restored. | null |
authenticated |
Access is usable. | Set |
refreshing |
A refresh is replacing expired or rejected access. | Set |
unauthenticated |
No session; sign-in is needed. | null |
unavailable |
Restoring failed, or a failed refresh left no usable access; see error. |
null |
state.sessionId is new on every sign-in and stable across refreshes and
profile updates; scope private cached data, such as TanStack Query results, to
it. state.version changes when guards must run again, which is what
connectAuth and useAuthRevalidation watch. state.error holds the latest
failure.
auth.getSession()resolves who is signed in,{ sessionId, user }, ornull, restoring or refreshing first if needed. It never holds tokens.auth.credentials.get()resolves{ sessionId, accessToken, expiresAt }for code that attaches tokens to requests.credentials.renew(credentials)recovers after a 401, andcredentials.reject(credentials)ends a session the server keeps refusing.auth.isCurrent({ sessionId })tells whether a session is still the current one.
auth.signIn({ accessToken, refreshToken?, expiresIn?, user })validates and saves a new session, replacing any current one. The latest call wins, and invalid input keeps the previous session.auth.signOut()ends the session in every tab and clears storage. The optionalrevokecallback then receives the ended tokens, so your backend can revoke them; a failed revocation is reported asREVOKE_FAILEDwithout undoing the sign-out.auth.refresh()refreshes now, even while the access token is usable.auth.retry()tries again after a failed refresh.
updateUser replaces the user and keeps the session. Pass a function to fetch
the new user: if the session changes meanwhile, the result is discarded instead
of landing on another account. createAuthFetch attaches the access token:
import { createAuthFetch } from "@monarcode/session-kit/http";
import { auth, userSchema } from "./auth.js";
const authFetch = createAuthFetch(auth, "https://dummyjson.com/");
export async function reloadProfile() {
await auth.updateUser(async () => {
const response = await authFetch("auth/me");
if (!response.ok) throw new Error(`Profile failed (${response.status})`);
return userSchema.parse(await response.json());
});
}createAuthFetch restricts requests to the configured origin and rejects
redirects. After a 401 it refreshes, then replays GET and HEAD once; other
methods are never replayed. A second 401 ends that session, unless you pass
{ signOutOnRepeated401: false } for APIs that answer 401 for other reasons.
If your backend puts the user in a JWT access token, read it from there.
signIn and the refresh callback then take tokens only, the user changes
whenever the token does, and there is no updateUser; call auth.refresh() to
pick up a changed user:
const auth = createAuth({
name: "my-app",
user: fromAccessToken(
z.object({ sub: z.string(), email: z.string() }),
(claims) => ({ id: claims.sub, email: claims.email }),
),
storage: webStorage(),
});
await auth.signIn({ accessToken, refreshToken });The token is decoded without verifying its signature, which suits a browser: a
user object in a JSON response is trusted to the same degree. Pass { decode }
to verify or decode it yourself. Leave claims that change on every refresh,
such as exp or jti, out of the user. OAuth providers may change their
access token format, so this suits backends you control. The
tanstack-start-spa and
react-router-data examples use this source.
The user schema's output must be a plain JSON object. It is saved beside the
tokens and validated again when restored, so the schema must accept its own
output and return it unchanged; sign-in rejects one that does not, such as a
one-way transform, instead of signing users out on their next reload. Accepted
users are copied and frozen. A user another tab saved that this tab's schema
rejects signs every tab out with USER_VALIDATION_FAILED, as restoring it
would.
| The refresh callback… | Meaning |
|---|---|
| Returns tokens | Install them. An omitted refresh token or user keeps the current one. |
Returns null |
The backend rejected the refresh token: sign out and clear storage. |
| Throws | Operational failure: keep the session, report REFRESH_FAILED, and wait for a retry. |
While connected, the early refresh runs at the earlier of 60 seconds before
expiry or halfway through the token's lifetime, once per token. If it fails,
access stays usable until expiry. Expired or rejected access shows
refreshing, with the user still visible, while a refresh runs. If that
refresh fails, access becomes unavailable and getSession() rethrows the
failure until auth.retry(), a sign-in, or a sign-out, so re-running guards
cannot start a refresh loop. Validation and refresh have a 15-second deadline.
To treat every refresh failure as a sign-out, return null instead of
throwing.
Expiry comes from the first of these the tokens provide:
expiresAt: Unix time in milliseconds, compared with the browser clock.expiresIn: lifetime in seconds, as in OAuthexpires_in, counted from receipt.- A JWT's
exp - iatlifetime, counted from receipt. - A JWT's
expalone, compared with the browser clock.
Browser clocks can be minutes off; options 2 and 3 are unaffected by that. Opaque tokens without expiry are refreshed only after a 401.
The required storage option chooses where the session is saved:
webStorage()useslocalStorage, shared by every tab.webStorage({ area: "session" })usessessionStorage: one session per tab, ending with the tab. Browsers copysessionStorageinto a duplicated tab and into windows opened withwindow.open, so both start with the same refresh token but never see each other's saves. If the backend rotates refresh tokens, whichever tab refreshes second sends a spent token and is signed out, or, with reuse detection, both are. PreferlocalStoragewith rotating refresh tokens.memoryStorage()never persists the session.- Any object implementing
AuthStorageworks, including one whose operations return promises, such as a native secure store. Auth applies operations one at a time, in the order it issued them.
Tokens and the user share one <name>:auth:session entry, so every save is a
single write other tabs see whole; a user read from the access token is not
saved. maxAge (seconds, default 30 days, renewed on each save) bounds how
long a saved session is restored. Sessions saved by earlier alphas are not
restored, so users sign in once after upgrading.
While connected, tabs follow each other through storage events: a sign-out
signs out every tab, a sign-in or account switch switches them, and profile
updates and refreshes appear everywhere. Refresh runs under the
<name>:auth:refresh Web Lock, so one tab at a time spends the refresh token
and the others reuse what it saved; rotating refresh tokens work with several
tabs open. Without Web Locks, two tabs refreshing at the same instant can both
spend the token; a short reuse window on the backend covers that.
state.error and rejected promises carry an AuthError whose code is one of
USER_VALIDATION_FAILED, INVALID_SESSION, PERSISTENCE_FAILED,
REFRESH_FAILED, REVOKE_FAILED, SESSION_CHANGED, or UNAUTHENTICATED.
Validation errors include the schema's issues, and underlying failures appear
in cause. Work superseded by a newer sign-in or sign-out rejects with
SESSION_CHANGED.
If storage cannot be written, the session ends and the error is published. If
it cannot be cleared, sign-out still takes effect in this tab, but the promise
rejects and the error stays visible: retry signOut() once storage is
available, because undeleted credentials could survive a reload.
- Tokens and users are readable by same-origin JavaScript, as with any browser-stored credential. Your backend must authenticate every request and authorize private operations; route guards and local user fields do not.
- Tokens never go in cookies, so browsers do not send them automatically.
- Server rendering is not supported yet: sessions live in the browser. Never put tokens in loader results or hydrated page data.
| Import | Exports |
|---|---|
@monarcode/session-kit |
createAuth, fromAccessToken, webStorage, memoryStorage, safeReturnTo, AuthError, types |
@monarcode/session-kit/react |
createAuthHooks, hook types |
@monarcode/session-kit/tanstack-router |
connectAuth, requireSession, redirectIfSignedIn, SessionOutlet, useRouterAuth, RegisteredAuth |
@monarcode/session-kit/react-router |
SessionOutlet, requireSession, redirectIfSignedIn, useAuthRevalidation |
@monarcode/session-kit/http |
createAuthFetch |
Do not import internal dist paths.
Use Node.js 24 or newer and pnpm. The repository is a pnpm workspace: the
package at the root, and the example apps in examples/.
pnpm install
pnpm run checkcheck runs Oxlint and Oxfmt, builds the package, runs the tests, type-checks
the tests, the consumer declarations, and every TypeScript example in this
README marked with a file: comment, under NodeNext and Bundler resolution.
pnpm test runs the runtime tests twice: with synchronous storage, and with
storage that settles every operation late, removals last, to catch ordering
bugs. pnpm run build:examples builds the package and every example app.
Tests are TypeScript files that Node runs directly. Behavior tests are split by
area and share the browser mocks in tests/helpers.ts; React tests mount real
React DOM, TanStack Router, and React Router in jsdom. CI also runs the tests
and type tests against the oldest supported peers, and builds the examples.
Before each commit, Husky runs the lint and format checks. The manual alpha
release workflow is documented in RELEASING.md.
Run pnpm pack, then install the tarball in your app, replacing <version>
with the version in package.json:
pnpm add /absolute/path/to/monarcode-session-kit-<version>.tgz