BetterIAM
Next.js

Next.js

Set up @better-iam/next in an App Router project, mount the IAM API, and read the session in server components, layouts, and client components.

@better-iam/next integrates Better IAM with the Next.js 15 App Router (React 19). The App Router spreads a request across server components, layouts, route handlers, server actions, and middleware, and each exposes the request differently: headers() and cookies() in components, a Request in route handlers, form data in actions, and an edge runtime without database access in middleware. Calling the core API directly would mean building the , redirecting with a safe ?next=, mapping refusals to status codes, and writing cookies by hand in each of those places. One createIamNext(iam) call does it once and gives your server code:

  • Sessions in server components: getSession() and requireSession(), memoized per request.
  • Guards: page() for pages and layouts, route() and apiRoute() for route handlers, action() for server actions, and require() for one-off checks.
  • Rendering by permission: can(), allowed(), and the <iamNext.Can> server component, batched into one authorizeMany per request.
  • A server-side typed client: client() calls the IAM handler in process and writes the cookies it issues, so server actions can sign people in and out without client JavaScript.
  • Cross-site checks: route(), apiRoute(), and pages.api() refuse cookie-authenticated mutations sent by another site's page (details). Next checks server actions itself.
  • Hydration: sessionForClient() hands the session to IamNextProvider, so client components render the signed-in person on the first pass, matching the server's HTML (Client components).
  • Auth forms: drop-in server actions and unstyled, accessible forms for sign-in, MFA, step-up, password reset, sign-up, email verification, and invitations.
  • Organizations in the URL, edge middleware, step-up, service credentials, webhooks and assertions, background delivery, and the Pages Router.

The package has three entries:

EntryUse fromContents
@better-iam/nextServer components, route handlers, server actionscreateIamNext and everything in the edge entry
@better-iam/next/edgeMiddleware, edge route handlers, any Web runtimecreateIamMiddleware, safeRedirectPath, assertion and webhook verification. No Node, React, or database imports, so the edge bundle stays small
@better-iam/next/clientClient components ('use client')IamNextProvider, useSignOut, the auth forms, and the React hooks

The umbrella package exposes the same entries as better-iam/next, better-iam/next/edge, and better-iam/next/client.

Install

npm i @better-iam/next @better-iam/client @better-iam/server @better-iam/adapter-sqlite

Or install the umbrella package, better-iam, and import from its subpaths. See Installation for other storage adapters.

Application layout

A complete App Router setup spreads over these files. The instance, the helpers, and the API route are the minimum; the others add edge redirects, drop-in forms, and background delivery.

route.ts
auth-actions.ts
layout.tsx
providers.tsx
iam.ts
iam-next.ts
middleware.ts
next.config.mjs
FileHoldsRuntime
lib/iam.tsbetterIam({ ... })Node only (database, argon2)
lib/iam-next.tscreateIamNext(iam, options)Server components, actions, route handlers
app/api/iam/[...path]/route.tsiamNext.handlers()The browser client, OAuth/SAML/SCIM mounts, /health
middleware.tscreateIamMiddleware(...)Edge: cookie presence and path forwarding
app/auth-actions.tsiamNext.authActions()Sign-in, step-up, and reset server actions
app/api/webhooks/iam/route.tscreateWebhookHandler(...)Optional: consume your own IAM events
app/api/cron/route.tsiamNext.background.cron(...)Optional: scheduled delivery and maintenance

Setup

Create the instance

lib/iam.ts
import { betterIam } from '@better-iam/server';
import { sqliteAdapter } from '@better-iam/adapter-sqlite';

export const iam = betterIam({
  database: sqliteAdapter({ filename: process.env.BETTER_IAM_DATABASE ?? '.data/iam.db' }),
  secret: process.env.BETTER_IAM_SECRET!,
  baseURL: process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:3000',
  permissions: { actions: ['documents:read', 'documents:write'] },
});

baseURL matters here: it becomes iam.endpoint.origin, the origin the in-process client presents and the one the server trusts for cookie requests.

Create the Next.js helpers

createIamNext wraps the instance in the helpers your pages, route handlers, and actions use. loginPath and stepUpPath are where guards send people who must sign in or confirm who they are.

lib/iam-next.ts
import { createIamNext } from '@better-iam/next';
import { iam } from './iam';

export const iamNext = createIamNext(iam, {
  loginPath: '/login',
  stepUpPath: '/reauth',
  interrupts: 'forbidden', // denials render app/forbidden.tsx
});

Mount the IAM HTTP API

The browser client (including passkey ceremonies) and the OAuth, SAML, and SCIM protocol mounts reach the server through this catch-all route.

app/api/iam/[...path]/route.ts
import { iamNext } from '@/lib/iam-next';

export const runtime = 'nodejs';
export const { GET, POST, OPTIONS } = iamNext.handlers();

GET serves the operational /health and /metrics endpoints; every API method is a POST.

Keep native modules out of the bundle

The server and its native dependencies (argon2, better-sqlite3) must load as plain Node modules. List them in serverExternalPackages, or load the instance lazily and pass a factory instead of the instance.

next.config.mjs
/** @type {import('next').NextConfig} */
export default {
  serverExternalPackages: [
    '@better-iam/server',
    '@better-iam/auth',
    '@better-iam/adapter-sqlite',
    'argon2',
    'better-sqlite3',
  ],
  experimental: { authInterrupts: true }, // only for interrupts: true or 'forbidden'
};

serverExternalPackages only applies to packages resolved from node_modules. In a monorepo, workspace packages are symlinks outside it, so the example app and the console load the server with import(/* webpackIgnore: true */ ...) instead.

Protect routes at the edge

Add middleware that redirects visitors without a session cookie and forwards the requested path, so ?next= fills itself.

middleware.ts
import { NextResponse } from 'next/server';
import { createIamMiddleware } from '@better-iam/next/edge';

export const middleware = createIamMiddleware({
  loginPath: '/login',
  publicPaths: ['/', '/pricing', '/docs/**', '/invite/*'],
  next: (init) => NextResponse.next(init),
});
export const config = { matcher: ['/((?!_next|favicon.ico).*)'] };

Reading the session

app/dashboard/page.tsx
import { iamNext } from '@/lib/iam-next';

export default async function Dashboard() {
  const current = await iamNext.getSession();
  if (!current) return <a href="/login">Sign in</a>;
  return <h1>Welcome, {current.identity.name}</h1>;
}

iamNext.getSession() reads the request's cookies (through headers() and cookies()) and returns { identity, session }. It returns null whenever the server refuses the credential as a sign-in. That covers missing, expired, or revoked sessions, sessions that must step up, unverified email addresses, inactive tenants, and requests from a network the session or tenant does not allow. Any other failure is rethrown. requireSession() returns the session or redirects to the login page with ?next=; Guards covers it with the other wrappers.

  • Per-request memoization. The no-argument call goes through React cache, so a layout, its page, and nested server components share one lookup per render. Calls that pass headers explicitly are not memoized. The cache option replaces React's; pass (fn) => fn to disable it.
  • After a server action. The helper merges cookies() into the headers it sends. Cookies a server action just set, such as a new session after sign-in, therefore apply to the re-render Next performs in the same response.
  • Direct API calls. iamNext.credential() returns { headers } for any iam.api.* call made as the current visitor: iam.api.identities.list(await iamNext.credential(), { tenantId }).
  • No cross-request state. The helpers never cache across requests. Memoization is scoped to one render, and there is no module-level session state.

Client components

Client components get the session from the server so their first render needs no fetch. iamNext.sessionForClient() returns the session as plain JSON without the stored token hash, or null. Pass it to IamNextProvider, which is IamProvider plus a router.refresh() whenever the signed-in identity changes in the browser (details).

app/layout.tsx
import type { ReactNode } from 'react';
import { iamNext } from '@/lib/iam-next';
import { Providers } from './providers';

export default async function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers initialSession={await iamNext.sessionForClient()}>{children}</Providers>
      </body>
    </html>
  );
}

Inside the provider, the React hooks (useSession, useAuthorize, useAccessible, useIamClient, Can) work as usual; @better-iam/next/client re-exports them.

Options

createIamNext(iam, options) takes these options. Most applications set loginPath, stepUpPath, and interrupts; headers, cookies, redirect, and the other overrides exist for tests and custom setups.

Prop

Type

createIamNext accepts the instance or a factory (() => iam or () => Promise<iam>). Any object with the IamLike surface works; a custom one without endpoint passes baseURL and basePath.

iamNext at a glance

Everything createIamNext returns, and where each member is explained:

MemberWhat it doesDetails
getSession(headers?)The current { identity, session }, or null; memoized per requestReading the session
requireSession({ ... })The session, or a redirect to the login page (or the step-up page)Guards
require({ tenantId, action, resource })Enforces one action before renderingGuards
page(render, spec)Wraps a page or layout: session, step-up, authorization, then renderGuards
route(handler, spec)Wraps a route handler for user sessions; answers IAM failures as JSONGuards
apiRoute(handler, spec)route() that also accepts API keys and assumed rolesService credentials
action(fn, spec)Wraps a server action; returns { ok, data } or { ok: false, error }Server actions
can({ tenantId, checks })Several advisory decisions in one call, keyed action@type/idRendering by permission
allowed(action, resource?), <iamNext.Can>One advisory decision, batched with every other check in the renderRendering by permission
client()The typed API, running in process, writing issued cookies through cookies()In-process client
authActions(options)Drop-in server actions for sign-in, step-up, reset, sign-up, verification, and invitationsAuth forms
handlers()GET, POST, and OPTIONS for the app/api/iam/[...path] routeSetup
sessionForClient()The session as plain JSON (no token hash) for IamNextProviderClient components
requireTenantSession({ slug }), tenant(slug)Resolve an organization alias and require a session in itOrganizations
assertion({ tenantId, audience })A short-lived signed assertion for a downstream serviceAssertions
credential(){ headers } for direct iam.api.* calls as the current visitorReading the session
currentPath()The path the middleware forwarded for this request, when present and safeMiddleware
clearSessionCookie()Expires the session cookie in the current responseServer actions
backgroundOutbox and event dispatch: dispatch(), schedule(), and the cron() routeBackground work
pagesPages Router helpers: handler, withSession, api, client, getSessionPages Router

In this section

The example app (examples/nextjs) runs all of it on port 3300 with seeded accounts.

Was this page helpful?

Last updated on

On this page