React Router
Root middleware, guarded loaders and actions, and an API resource route for React Router framework mode (v7.9+ and v8), with React hooks in the browser.
@better-iam/react-router integrates Better IAM with React Router framework mode, v7.9+ and v8. Loaders and
actions run on the server, so that is where are read and access is enforced. Calling the core API
directly from them would leave you to read cookies from every request and map refusals to redirects or error
responses. You would also check the Origin of form posts and copy the cookies a sign-in issues onto the
response by hand. This package does that for you:
- A root middleware creates per-request helpers (the session, memoized for the request) and adds the cookies the in-process client receives to the response, including on redirects.
- A resource route serves the IAM HTTP API for the browser client.
guardwraps loaders (login and step-up redirects, a 403 for the error boundary) andactionwraps actions (refusals returned foruseActionData).- Origin checks.
guardandactionrefuse cookie-authenticated requests from other origins before your code runs (why). - Hydration.
sessionDatagives the root loader the session for<IamProvider initialSession>, so the first browser render matches the server's HTML without a request.
It is built on @better-iam/middleware, so the helpers are the same as req.iam in
Express. Browser components use @better-iam/react (IamProvider, useSession,
useAuthorize, Can).
Install
npm i better-iamThe umbrella exposes this package as better-iam/react-router. Or install @better-iam/react-router,
@better-iam/react, and @better-iam/client next to @better-iam/server and a storage adapter.
Route middleware on React Router 7
React Router 7 ships route middleware behind the future.v8_middleware flag in react-router.config.ts; turn
it on before adding iamRouter.middleware. React Router 8 always enables middleware.
Setup
Create the instance and the router helpers
Keep this file server-only: its name ends in .server.ts, so the client bundle never contains server code.
import { betterIam } from 'better-iam';
import { sqliteAdapter } from 'better-iam/adapter-sqlite';
import { createIamRouter } from 'better-iam/react-router';
export const iam = betterIam({
database: sqliteAdapter({ filename: '.data/iam.db' }),
secret: process.env.BETTER_IAM_SECRET!,
baseURL: process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:5173',
permissions: { actions: ['projects:read', 'projects:manage'] },
resolveResource: async (reference) => loadProjectOwnership(reference),
});
export const iamRouter = createIamRouter(iam, { loginPath: '/login', stepUpPath: '/verify' });
export const ready = iam.initialize();Add the middleware, the session loader, and the provider
import { useEffect, useState } from 'react';
import { Outlet } from 'react-router';
import { createIamClient, type IamClient } from 'better-iam/client';
import { IamProvider, useSession } from 'better-iam/react';
import type { Route } from './+types/root';
import { iamRouter, ready, type iam } from './iam.server';
type Client = IamClient<typeof iam>;
export const middleware: Route.MiddlewareFunction[] = [
async (_args, next) => {
await ready; // the first request waits for migrations
return next();
},
iamRouter.middleware,
];
export async function loader(args: Route.LoaderArgs) {
return { ...(await iamRouter.sessionData(args)), origin: new URL(args.request.url).origin };
}
export default function App({ loaderData }: Route.ComponentProps) {
const [client] = useState<Client>(() => createIamClient<typeof iam>({ baseURL: loaderData.origin }));
return (
<IamProvider client={client} initialSession={loaderData.session}>
<SessionSync session={loaderData.session} />
<Outlet />
</IamProvider>
);
}
// Actions (sign in, sign out) revalidate the root loader; keep the store on the server's answer.
function SessionSync({ session }: { session: Route.ComponentProps['loaderData']['session'] }) {
const { setSession } = useSession<Client>();
useEffect(() => setSession(session), [session, setSession]);
return null;
}initialSession lets the first render show the signed-in person without a request from the browser, and
server-rendered markup matches what the browser renders.
Serve the IAM API
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('login', 'routes/login.tsx'),
// The IAM HTTP API (sign-in, sessions, and every API group the client calls).
route('api/iam/*', 'routes/api.iam.ts'),
] satisfies RouteConfig;import { iamRouter } from '../iam.server';
export const loader = iamRouter.api; // GET: /health, /metrics
export const action = iamRouter.api; // POST: every API methodWhat createIamRouter returns
createIamRouter(iam, options) accepts the instance or a factory that returns it, and gives back everything the
routes need:
| Member | What it does | Use it |
|---|---|---|
middleware | Creates the per-request helpers in the router context and appends issued Set-Cookie headers to the response | On the root route, once |
api | Passes the request to iam.handler | As the loader and action of the api/iam/* resource route |
guard(loader, spec) | Runs loader(args, session) only for a signed-in, stepped-up, allowed visitor | For loaders of protected routes |
action(fn, spec) | Runs fn(args, session) after the origin, session, step-up, and authorization checks; returns refusals as data() | For actions that change state |
helpers(args) | The per-request helpers (below) | In any loader or action |
requireSession(args, { stepUp?, loginRedirect?, returnTo? }) | The session, or a login or step-up redirect | In hand-written loaders |
require(args, action, resource?, { tenantId?, deniedRedirect? }) | Enforces one action; signed out redirects to login, denied throws a 403 data() or redirects | In hand-written loaders |
sessionData(args) | { session } for <IamProvider initialSession> | In the root loader |
context, resolve | The router context key holding the helpers, and the instance resolver | Advanced wiring |
The package also re-exports safeRedirectPath, checkStepUp, checkRequestOrigin, and isAuthenticationError
from the middleware core.
Options
Prop
Type
Loaders and actions
Wrap a loader with guard when the page must not render for the wrong person, and an action with action when
the form should show the refusal instead of an error page.
import { iamRouter } from '../iam.server';
import type { Route } from './+types/projects.$id';
export const loader = iamRouter.guard(
async (args: Route.LoaderArgs, session) => ({
project: await loadProject(args.params.id),
canManage: await iamRouter.helpers(args).can('projects:manage', { type: 'project', id: args.params.id }),
}),
{
authorize: {
action: 'projects:read',
resource: (args) => ({ type: 'project', id: args.params.id }),
},
},
);
export const action = iamRouter.action(
async (args: Route.ActionArgs, session) => renameProject(args.params.id, await args.request.formData()),
{
authorize: {
action: 'projects:manage',
resource: (args) => ({ type: 'project', id: args.params.id }),
},
},
);guard(loader, { stepUp?, authorize?, loginRedirect?, deniedRedirect? })
- Signed-out visitors are redirected to
loginPath?next=…. Document and.datarequests both work, andnextnever includes.data. - Sessions that must are redirected to
stepUpPath?next=…&reason=…. - A denial throws
data({ code, message }, { status: 403 })for the route'sErrorBoundary(isRouteErrorResponse(error),error.data.code), or redirects todeniedRedirect. authorizecallbacks receive(args, session); the resource defaults to the tenant (iam/{tenantId}) and the tenant to the session's.- It runs the same origin check as
actionfirst. Loaders usually answerGET, which the check lets through.
action(fn, { stepUp?, authorize? })
- First refuses cookie-authenticated requests from untrusted origins (
UNTRUSTED_ORIGIN, orCSRF_REJECTEDwithout anOrigin). Session cookies areSameSite=Lax, which still sends them with posts from sibling subdomains, so a page on another subdomain could otherwise act as the signed-in person. - Returns every IAM refusal as
data({ code, message }, { status })foruseActionData: 401, 403, 400 for invalid input, 429 when rate limited. Redirects and other errors propagate. - React Router v8 also refuses cross-origin document action posts on its own (400), so the origin check is a
second layer that also covers
.datarequests.
export default function Account({ loaderData, actionData }: Route.ComponentProps) {
return (
<Form method="post">
<input name="name" defaultValue={loaderData.name} />
<button>Rename</button>
{actionData && 'code' in actionData ? <p role="alert">{actionData.code}</p> : null}
</Form>
);
}import { isRouteErrorResponse } from 'react-router';
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error))
return (
<main>
<h1>{error.status}</h1>
<p>{(error.data as { code?: string } | undefined)?.code ?? error.statusText}</p>
</main>
);
return <h1>Something went wrong</h1>;
}Per-request helpers
iamRouter.helpers(args) returns the same helpers as the Node adapters:
| Member | What it does |
|---|---|
getSession() | The session, or null; memoized for the request |
requireSession({ stepUp? }) / require(action, resource?, { tenantId? }) | Throw the refusal instead of redirecting; prefer iamRouter.requireSession(args) and iamRouter.require(args, ...) in loaders |
can(action, resource?, { tenantId? }) | An advisory boolean; checks made in the same tick share one authorizeMany call |
authorize(checks, { tenantId? }) | { action, resource, allowed, reason }[] for several checks |
listAccessible({ action, type, ... }) | The managed resources the caller may act on |
assertion({ tenantId, audience, ... }) | A signed assertion for a downstream service |
credential() | { headers } for direct iam.api.* calls, including cookies set earlier in the request |
client | The typed client calling the IAM handler in process |
signOut() | Ends the session and clears its cookie |
client is what makes sign-in work without browser JavaScript. helpers(args).client.auth.signIn(...) in an
action, followed by throw redirect(next), signs the person in; the middleware puts the session cookie on the
redirect.
import { Form, data, redirect } from 'react-router';
import { safeRedirectPath } from 'better-iam/react-router';
import { iamRouter } from '../iam.server';
import type { Route } from './+types/login';
export async function action(args: Route.ActionArgs) {
const form = await args.request.formData();
const email = String(form.get('email') ?? '');
let result;
try {
result = await iamRouter.helpers(args).client.auth.signIn({
tenantId: String(form.get('tenantId') ?? ''),
email,
password: String(form.get('password') ?? ''),
});
} catch (error) {
return data({ email, message: error instanceof Error ? error.message : 'Sign-in failed' }, { status: 400 });
}
if (!('token' in result)) throw redirect(`/verify?challenge=${result.challenge}`);
throw redirect(safeRedirectPath(new URL(args.request.url).searchParams.get('next')));
}safeRedirectPath accepts only same-site paths and falls back to / for anything else, so a ?next= parameter
cannot send people to another site after they sign in.
Build notes
- Native modules. Keep the server packages out of the SSR bundle with
ssr: { external: ['better-iam'] }(or@better-iam/serverplus the adapter). - One React Router. In a monorepo, also add
resolve: { dedupe: ['react-router', 'react', 'react-dom'] }so the app and the integration share one React Router instead of each resolving its own copy. - Server-only code. Keep
iam.server.tsserver-only; the example's client bundle contains no server code.
import { reactRouter } from '@react-router/dev/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [reactRouter()],
resolve: { dedupe: ['react-router', 'react', 'react-dom'] },
ssr: { external: ['@better-iam/server', '@better-iam/adapter-sqlite'] },
});Next steps
Was this page helpful?
Last updated on
SvelteKit
A handle hook that serves the IAM API and guards sections, locals.iam for loads and form actions, and Svelte 4 and 5 stores for the browser.
NestJS
IamModule, a guard that authenticates every request, @Authorize and other decorators, audit event handlers, assertions, and testing for NestJS 11 and 12.