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).
Installation
Install the package
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.
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';
import { loadProjectOwnership } from './data.server'; // your data layer
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 { loadProject, renameProject } from '../data.server'; // your data layer
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.
import { Form } from 'react-router';
import type { Route } from './+types/account';
// The route also exports a loader returning { name } and an action wrapped in iamRouter.action.
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';
import type { Route } from './+types/root';
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 { 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'] },
});Organizations
Every session acts in one (an organization). guard, action, and the helpers
default to it: the tenant is session.session.tenantId, and a check with no resource targets the tenant itself
(iam/{tenantId}). Pass tenantId (an authorize callback of (args, session), or an option on require, can,
and authorize) to check in another tenant; the server decides whether the caller's credential may act there.
When each organization has its own path (/acme/projects), resolve the alias with tenants.lookup in the
organization's layout loader and send visitors signed in elsewhere to sign in there:
import { data, redirect } from 'react-router';
import { errorCode } from 'better-iam/middleware';
import { safeRedirectPath } from 'better-iam/react-router';
import { iam, iamRouter } from '../iam.server';
import type { Route } from './+types/org-layout';
export async function loader(args: Route.LoaderArgs) {
const slug = args.params.org ?? '';
const organization = await iam.api.tenants.lookup({ slug }).catch((cause: unknown) => {
if (errorCode(cause) === 'NOT_FOUND') throw data({ code: 'NOT_FOUND' }, { status: 404 });
throw cause;
});
const session = await iamRouter.helpers(args).getSession();
if (session?.session.tenantId !== organization.tenantId) {
const url = new URL(args.request.url);
const next = safeRedirectPath(`${url.pathname}${url.search}`);
throw redirect(`/login?${new URLSearchParams({ org: slug, next })}`);
}
return { organization };
}Route: POST /api/iam/tenants/lookup
tenants.lookup({ slug }) returns { tenantId, name, type, slug } for an active organization and throws
NOT_FOUND for unknown or inactive aliases. With
organization sign-in addresses, calls through the in-process
client keep the host the visitor is on, so the server pins them to that organization. A person with identities in
several organizations can list them with links.list and switch with links.switch; see
Tenants and identities.
Related
Better IAM is created by Sean Filimon
Last updated
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.
Express
Serve the Better IAM API from an Express 4 or 5 app with createIamExpress, read the session from req.iam, and guard routes with requireSession and authorize.