Build a multi-tenant B2B SaaS
Build a Next.js B2B app where each customer is an organization with its own owner, invited members, product roles, sign-in policy, organization switcher, and authorized routes.
This guide builds the identity layer of a B2B product on the Next.js App Router. Every customer is an
organization: a with its own people, roles, and sign-in rules, served under its
own path (/acme/...). The platform team creates organizations, each organization's owner invites their
colleagues, and every route checks access inside the organization the data belongs to. You will build:
- a configured Better IAM instance with a
projectresource type and a resolver that reports which organization owns each project; - organization creation by a platform administrator, with an owner invitation email;
- an invitation page for owners and members, and default product roles (Admin, Member) created for each new organization;
- member invitations with a role, and delegation so admins other than the owner can invite;
- an organization settings page that sets the organization's own sign-in policy;
- an organization layout at
/[org], an organization switcher, and project routes that authorize every request.
Prerequisites
- Node.js 22.12 or newer, and a Next.js 15 App Router project with
"type": "module"inpackage.json. - A root administrator created with
better-iam bootstrapand enrolled in MFA. The Quickstart walks through both. Keep the root tenant's ID (tenant.idin the bootstrap output). - The Next.js integration concepts:
createIamNext,page,route, andaction. This guide uses them without re-explaining each option.
Install the packages
npm i @better-iam/next @better-iam/client @better-iam/server @better-iam/auth @better-iam/adapter-sqlite
npm i -D @better-iam/cli@better-iam/cli provides the better-iam command for migrations and scheduled jobs. To use a different database,
swap the adapter (see Installation).
Configure the instance
The configuration lives in a module the CLI can load, better-iam.config.mjs. Its default export holds the options;
your application and better-iam migrate both read it.
import { sqliteAdapter } from '@better-iam/adapter-sqlite';
import { renderDeliveryMessage } from '@better-iam/auth/templates';
import { IamError } from '@better-iam/server';
import { findProject } from './lib/projects-store.mjs';
const origin = process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:3000';
export default {
database: sqliteAdapter({ filename: process.env.BETTER_IAM_DATABASE ?? '.data/iam.db' }),
secret: process.env.BETTER_IAM_SECRET,
baseURL: origin,
authentication: {
// Development only: prints each message. In production, hand `rendered` to your mail provider.
sendEmail: async (message) => {
const rendered = renderDeliveryMessage(message, {
appName: 'Acme Cloud',
links: {
invitation: ({ kind, tenantId, token }) =>
`${origin}/invite?kind=${kind}&tenantId=${tenantId}&token=${token}`,
passwordReset: ({ tenantId, token }) => `${origin}/reset?tenantId=${tenantId}&token=${token}`,
},
});
console.log(message.to, rendered?.subject, rendered?.text);
},
},
permissions: {
resourceTypes: {
project: { actions: ['projects:read', 'projects:write', 'projects:delete'] },
},
},
// Authorization asks which organization owns a resource. Never trust a tenant ID from the request.
resolveResource: async ({ tenantId, type, id }) => {
// Product actions checked on the organization itself (such as creating a project) name iam/{tenantId}.
if (type === 'iam' && id === tenantId) return { tenantId, type, id };
const project = type === 'project' ? await findProject(id) : undefined;
if (!project) throw new IamError('NOT_FOUND', 'Resource not found', 404);
return { type, id, tenantId: project.tenantId };
},
// Every new organization starts with these sign-in rules.
tenantDefaults: {
authPolicy: { requireMfaForOwners: true },
},
// Lets one person link their accounts in several organizations, for the organization switcher.
onboarding: { mode: 'linked' },
};authentication.sendEmailis required:tenants.createfails withDELIVERY_REQUIREDwithout it, because the owner invitation is an email.renderDeliveryMessagebuilds the subject, text, and HTML of the built-in templates;links.invitationpoints owner and member invitations at the/invitepage you build in a later step.permissions.resourceTypesdeclares what your product protects, so roles that nameprojects:*actions are validated when they are saved.resolveResourcereturns the owningtenantIdof each project. When it differs from the tenant a check runs in, the check fails withRESOURCE_MISMATCH(403), which is what stops one organization from reading another's project by guessing its ID. Checks ofiam:*actions never reach the resolver, but checks of your own actions do, including a check on the organization itself (iam/{tenantId}, the default resource of<iamNext.Can>,allowed(), and the guards'authorize), so the resolver answers for that resource too.tenantDefaults.authPolicyis stamped on every tenanttenants.createcreates.onboarding: { mode: 'linked' }enables account linking (links.create); without it linking fails withLINKING_DISABLED.
The resolver reads your own data. A minimal in-memory store keeps this guide self-contained; replace it with your database:
/** @type {Map<string, { id: string; tenantId: string; name: string }>} */
const projects = new Map();
export async function findProject(id) {
return projects.get(id);
}
export async function listProjects(tenantId) {
return [...projects.values()].filter((project) => project.tenantId === tenantId);
}
export async function createProject(tenantId, name) {
const project = { id: crypto.randomUUID(), tenantId, name };
projects.set(project.id, project);
return project;
}Create the instance and the Next.js helpers:
import { betterIam } from '@better-iam/server';
import config from '../better-iam.config.mjs';
export const iam = betterIam(config);import { createIamNext } from '@better-iam/next';
import { iam } from './iam';
export const iamNext = createIamNext(iam, {
loginPath: '/login',
stepUpPath: '/reauth',
interrupts: 'forbidden',
dispatchAfterResponse: true, // deliver queued email after each response
});Keep the server and its native modules out of the bundle, and enable auth interrupts for interrupts: 'forbidden':
/** @type {import('next').NextConfig} */
export default {
serverExternalPackages: [
'@better-iam/server',
'@better-iam/auth',
'@better-iam/adapter-sqlite',
'argon2',
'better-sqlite3',
],
experimental: { authInterrupts: true },
};Migrate, mount, and add the auth pages
Create the schema with the CLI. Run it in a shell where BETTER_IAM_SECRET and BETTER_IAM_BASE_URL are set.
npx better-iam migrate --config better-iam.config.mjsMount the IAM HTTP API, which the browser client, the sign-in forms, and protocol mounts use:
import { iamNext } from '@/lib/iam-next';
export const runtime = 'nodejs';
export const { GET, POST, OPTIONS } = iamNext.handlers();Export the drop-in authentication server actions. discover: true finds the organization from a verified email
domain when the form names none.
'use server';
import { iamNext } from '@/lib/iam-next';
const auth = iamNext.authActions({ afterSignIn: '/', discover: true });
export const signIn = auth.signIn;
export const reauthenticate = auth.reauthenticate;
export const acceptInvitation = auth.acceptInvitation;
export async function signOut() {
await auth.signOut();
}The login page receives ?org= and ?next= from the organization layout's redirect:
import { SignInForm } from '@better-iam/next/client';
import { signIn } from '../auth-actions';
export default async function Login(props: {
searchParams: Promise<{ next?: string; org?: string; tenantId?: string }>;
}) {
const { next, org, tenantId } = await props.searchParams;
return (
<main>
<h1>Sign in</h1>
<SignInForm
action={signIn}
keepSignedIn
{...(org ? { org } : {})}
{...(tenantId ? { tenantId } : {})}
{...(next ? { next } : {})}
/>
</main>
);
}The invitation page serves both kinds of invitation email. Owners continue to /welcome, members to the home page:
import { InvitationForm } from '@better-iam/next/client';
import { acceptInvitation } from '../auth-actions';
export default async function Invite(props: {
searchParams: Promise<{ kind?: string; tenantId?: string; token?: string }>;
}) {
const { kind, tenantId, token } = await props.searchParams;
if (!tenantId || !token || (kind !== 'owner' && kind !== 'member'))
return <p>This invitation link is incomplete.</p>;
return (
<main>
<h1>{kind === 'owner' ? 'Set up your organization' : 'Join your team'}</h1>
<InvitationForm
action={acceptInvitation}
kind={kind}
tenantId={tenantId}
token={token}
next={kind === 'owner' ? '/welcome' : '/'}
/>
</main>
);
}Redirect signed-out visitors at the edge, keeping the invitation page public. The middleware's default protect
leaves the login path and /api/iam public, so identity providers and the browser client reach it.
import { NextResponse } from 'next/server';
import { createIamMiddleware } from '@better-iam/next/edge';
export const middleware = createIamMiddleware({
loginPath: '/login',
publicPaths: ['/', '/invite'],
next: (init) => NextResponse.next(init),
});
export const config = { matcher: ['/((?!_next|favicon.ico).*)'] };Create organizations
Organizations are created by the platform. tenants.create creates a pending child tenant under the root, claims
its alias (slug), and queues the owner-invitation email. It requires iam:tenants:create on the parent, a valid
, and a credential authenticated within the last five minutes
(authentication.recentAuthenticationMs, default 5 minutes). Better IAM has no unauthenticated "create an
organization" endpoint, so call it from a platform administration page that only root administrators reach.
import { iamNext } from '@/lib/iam-next';
import { createOrganization } from './actions';
export default iamNext.page(
async () => (
<main>
<h1>New organization</h1>
<form action={createOrganization}>
<input name="name" placeholder="Acme" required />
<input name="slug" placeholder="acme" required />
<input name="ownerEmail" type="email" placeholder="owner@acme.com" required />
<button type="submit">Create and invite the owner</button>
</form>
</main>
),
{ authorize: { action: 'iam:tenants:create' }, stepUp: { maxAgeMs: 5 * 60_000 } },
);'use server';
import { redirect } from 'next/navigation';
import { iamNext } from '@/lib/iam-next';
export async function createOrganization(form: FormData) {
// Re-checks the session and its age when the form is posted, not only when the page rendered.
const session = await iamNext.requireSession({ stepUp: { maxAgeMs: 5 * 60_000 } });
const { tenant } = await iamNext.client().tenants.create({
parentId: session.session.tenantId, // the root tenant the administrator signed in to
type: 'organization',
name: String(form.get('name')),
slug: String(form.get('slug')),
ownerEmail: String(form.get('ownerEmail')),
});
redirect(`/platform?created=${tenant.id}`);
}The page guard checks iam:tenants:create in the administrator's own tenant (the root) and sends anyone whose
sign-in is older than five minutes to the step-up page first. tenants.create enforces the permission again, so a
failed check rejects the action with an IamClientError carrying the server's code. Root administrators sign in
at /login?tenantId=<root tenant ID> and must complete MFA. For the step-up page itself, see
Step-up.
The new tenant stays pending, and its alias does not resolve, until the owner accepts. Owner invitations last 24
hours (onboarding.invitationLifetimeMs); tenants.resendInvitation sends a fresh link.
Route: POST /api/iam/tenants/create
Create the default roles
Roles belong to a tenant, so each organization needs its own copy of your product roles. Create them once, as the owner, right after the owner accepts. A role whose name already exists is skipped, so the function is safe to run again.
import { iam } from './iam';
/** `{ headers }` or `{ token }`: who is calling. */
type CredentialInput = Parameters<typeof iam.api.roles.list>[0];
const defaults = [
{
name: 'Admin',
description: 'Manages projects and members',
permissions: [
'projects:read',
'projects:write',
'projects:delete',
'iam:identities:create',
'iam:identities:read',
'iam:bindings:create',
'iam:bindings:read',
'iam:bindings:delete',
'iam:roles:read',
],
},
{ name: 'Member', description: 'Reads and edits projects', permissions: ['projects:read', 'projects:write'] },
];
export async function ensureDefaultRoles(credential: CredentialInput, tenantId: string) {
const existing = new Set((await iam.api.roles.list(credential, { tenantId })).map((role) => role.name));
for (const role of defaults)
if (!existing.has(role.name)) await iam.api.roles.create(credential, { tenantId, ...role });
}The owner lands on /welcome after accepting. The page offers one button that creates the roles and continues to
the organization:
import { iamNext } from '@/lib/iam-next';
import { finishSetup } from './actions';
export default iamNext.page(async (_props, { session }) => (
<main>
<h1>Welcome, {session.identity.name}</h1>
<form action={finishSetup}>
<button type="submit">Create the Admin and Member roles</button>
</form>
</main>
));'use server';
import { redirect } from 'next/navigation';
import { iamNext } from '@/lib/iam-next';
import { ensureDefaultRoles } from '@/lib/org-defaults';
export async function finishSetup() {
const session = await iamNext.requireSession();
const tenantId = session.session.tenantId;
await ensureDefaultRoles(await iamNext.credential(), tenantId);
const tenant = await iamNext.client().tenants.get({ tenantId });
redirect(`/${tenant.slug}`);
}A role records the grant authority of whoever creates it, and only that authority (or a root administrator) can
edit it later. Running this as the owner makes the owner's authority the roles' issuer, so the organization manages
its own roles instead of depending on the platform. roles.create needs iam:roles:create and a grant authority;
the owner's protected Owner role allows every action in the tenant. See Roles and bindings.
Route: POST /api/iam/roles/create
Serve each organization under its path
requireTenantSession({ slug }) resolves the alias in the URL and requires a session in that organization. Unknown
or inactive aliases render a 404, and a visitor signed in to a different organization is sent to
/login?org={slug}&next=....
import type { ReactNode } from 'react';
import Link from 'next/link';
import { iamNext } from '@/lib/iam-next';
import { signOut } from '../auth-actions';
import { OrgSwitcher } from './org-switcher';
export default async function OrgLayout(props: { params: Promise<{ org: string }>; children: ReactNode }) {
const { org } = await props.params;
const { tenant, session } = await iamNext.requireTenantSession({ slug: org });
return (
<>
<header>
<Link href={`/${org}`}>
<strong>{tenant.name}</strong>
</Link>
<OrgSwitcher current={org} />
<form action={signOut}>
<span>{session.identity.name} </span>
<button type="submit">Sign out</button>
</form>
</header>
{props.children}
</>
);
}tenant is { tenantId, name, type, slug }. Wrap each page below the layout with iamNext.page too, because
Next.js keeps layouts across client-side navigations instead of re-rendering them.
Add the organization switcher
Identities are per organization: a consultant who works with Acme and Globex has two separate accounts, each with
its own password, factors, and roles. Linking the accounts lets the switcher list both. links.list returns the
current identity's linked accounts with tenantName and tenantSlug.
import Link from 'next/link';
import { iamNext } from '@/lib/iam-next';
export async function OrgSwitcher({ current }: { current: string }) {
const linked = await iamNext.client().links.list();
const others = linked.filter((account) => account.tenantSlug && account.tenantSlug !== current);
if (!others.length) return null;
return (
<nav aria-label="Switch organization">
{others.map((account) => (
<Link key={account.id} href={`/${account.tenantSlug}`}>
{account.tenantName}
</Link>
))}
</nav>
);
}Following a link to another organization's path makes its layout's requireTenantSession send the person to
/login?org={slug}, where they sign in to that account. The browser holds one session at a time, so switching is
a sign-in, never a shared session: each organization's MFA and sign-in policy still apply.
links.switch does the same from the API, and also requires a recently
authenticated credential for the target account.
Accounts are linked with links.create({ targetCredential }), which needs recent authentication on both accounts.
An owner can link their existing account while accepting an owner invitation by passing linkCredential to
tenants.acceptInvitation. Root administrators can never link. See
Account linking.
Route: POST /api/iam/links/list
Invite members with a role
identities.invite records the email with the roles to apply, stores only a hash of the token, and queues a
member-invitation email that links to your /invite page. The inviter needs iam:identities:create, plus
iam:bindings:create on each role, so an invitation never grants more than the inviter could grant directly.
import { iamNext } from '@/lib/iam-next';
import { InviteForm } from './invite-form';
export default iamNext.page(
async (_props: { params: Promise<{ org: string }> }, { session }) => {
const tenantId = session.session.tenantId;
const [members, roles] = await Promise.all([
iamNext.client().identities.list({ tenantId, kind: 'user' }),
iamNext.client().roles.list({ tenantId }),
]);
const assignable = roles.filter((role) => !role.protected);
return (
<main>
<h1>Members</h1>
<ul>
{members.map((member) => (
<li key={member.id}>
{member.name} ({member.email})
</li>
))}
</ul>
<InviteForm roles={assignable.map((role) => ({ id: role.id, name: role.name }))} />
</main>
);
},
{ authorize: { action: 'iam:identities:read' } },
);'use server';
import { revalidatePath } from 'next/cache';
import { iamNext } from '@/lib/iam-next';
export const inviteMember = iamNext.action(
async (session, _previous: unknown, form: FormData) => {
const invitation = await iamNext.client().identities.invite({
tenantId: session.session.tenantId,
email: String(form.get('email')),
roleIds: [String(form.get('roleId'))],
});
revalidatePath('/[org]/members', 'page');
return { email: invitation.email, expiresAt: invitation.expiresAt };
},
{ authorize: { action: 'iam:identities:create' } },
);'use client';
import { useActionState } from 'react';
import { inviteMember } from './actions';
export function InviteForm({ roles }: { roles: { id: string; name: string }[] }) {
const [state, formAction, pending] = useActionState(inviteMember, null);
return (
<form action={formAction}>
<input name="email" type="email" required />
<select name="roleId">
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<button disabled={pending}>Invite</button>
{state?.ok === true && <p role="status">Invitation sent to {state.data.email}.</p>}
{state?.ok === false && <p role="alert">{state.error.message}</p>}
</form>
);
}Acceptance creates the identity with a verified email and binds the roles under the inviter's grant authority,
which is checked again at that moment. Inviting an email that already has an identity in the organization fails
with IDENTITY_EXISTS. identities.listInvitations, identities.resendInvitation, and
identities.revokeInvitation manage pending invitations.
Route: POST /api/iam/identities/invite
Let admins other than the owner invite
Creating bindings, which an invitation with roles does, needs a grant authority as well as the permission. The
owner holds one; anyone else has none until it is delegated, and their invitations with roles fail with
GRANT_AUTHORITY_REQUIRED. The owner delegates an authority whose ceiling caps everything the admin can ever
grant:
'use server';
import { iamNext } from '@/lib/iam-next';
// Called by the owner when they make someone an Admin.
export const delegateInviting = iamNext.action(
async (session, identityId: string) =>
iamNext.client().authorities.create({
tenantId: session.session.tenantId,
identityId,
ceiling: {
version: 1,
statements: [{ effect: 'allow', actions: ['projects:*'], resources: ['*'] }],
},
}),
{ stepUp: { maxAgeMs: 5 * 60_000 } },
);With this ceiling, an admin can invite people as Member, but whatever a role says, a binding they issue grants no
more than projects:*. authorities.create requires iam:authorities:create and recent authentication. Revoking
the authority (authorities.revoke) disables every grant issued under it. See
Grant authorities.
Route: POST /api/iam/authorities/create
Let each organization set its sign-in policy
tenants.setAuthPolicy replaces an organization's sign-in rules. A policy can only tighten what the deployment
allows. It needs iam:tenants:update and recent authentication, and tenants.get returns the current policy as
authPolicy.
import { iamNext } from '@/lib/iam-next';
import { savePolicy } from './actions';
export default iamNext.page(
async (_props: { params: Promise<{ org: string }> }, { session }) => {
const tenant = await iamNext.client().tenants.get({ tenantId: session.session.tenantId });
const policy = tenant.authPolicy ?? {};
return (
<form action={savePolicy}>
<label>
<input type="checkbox" name="requireMfa" defaultChecked={policy.requireMfa === true} /> Require MFA for
everyone
</label>
<label>
Idle timeout (minutes)
<input name="idleMinutes" type="number" min={1} defaultValue={(policy.sessionIdleTimeoutMs ?? 0) / 60_000 || ''} />
</label>
<button type="submit">Save</button>
</form>
);
},
{ authorize: { action: 'iam:tenants:update' }, stepUp: { maxAgeMs: 5 * 60_000 } },
);'use server';
import { revalidatePath } from 'next/cache';
import { iamNext } from '@/lib/iam-next';
export async function savePolicy(form: FormData) {
const session = await iamNext.requireSession({ stepUp: { maxAgeMs: 5 * 60_000 } });
const tenantId = session.session.tenantId;
const current = (await iamNext.client().tenants.get({ tenantId })).authPolicy ?? {};
const idleMinutes = Number(form.get('idleMinutes'));
// setAuthPolicy replaces the whole policy and enforces iam:tenants:update itself.
await iamNext.client().tenants.setAuthPolicy({
tenantId,
authPolicy: {
...current,
requireMfa: form.get('requireMfa') === 'on',
...(idleMinutes > 0 ? { sessionIdleTimeoutMs: idleMinutes * 60_000 } : {}),
},
});
revalidatePath('/[org]/settings/security', 'page');
}Existing sessions follow the new policy at their next use: turning on requireMfa stops sessions that did not
complete MFA. Every field (allowed methods, IP ranges, password rules, session limits) is listed in
Tenant authentication policy.
Route: POST /api/iam/tenants/setAuthPolicy
Authorize every project route
Pages, route handlers, and server actions each check the action on the specific project. The tenant defaults to the
session's organization, and resolveResource confirms the project belongs to it.
import { iamNext } from '@/lib/iam-next';
import { findProject } from '@/lib/projects-store.mjs';
export const runtime = 'nodejs';
export const GET = iamNext.route<{ id: string }>(
async (_request, { params }) => findProject(params.id),
{
authorize: {
action: 'projects:read',
resource: ({ params }) => ({ type: 'project', id: params.id }),
},
},
);'use server';
import { revalidatePath } from 'next/cache';
import { iamNext } from '@/lib/iam-next';
import { createProject } from '@/lib/projects-store.mjs';
export async function newProject(form: FormData) {
const session = await iamNext.requireSession();
const tenantId = session.session.tenantId;
// Creating a project is checked on the organization itself (iam/{tenantId}).
await iamNext.require({ tenantId, action: 'projects:write', resource: { type: 'iam', id: tenantId } });
await createProject(tenantId, String(form.get('name')));
revalidatePath('/[org]/projects', 'page');
}import { iamNext } from '@/lib/iam-next';
import { listProjects } from '@/lib/projects-store.mjs';
import { newProject } from './actions';
export default iamNext.page(async (_props: { params: Promise<{ org: string }> }, { session }) => {
const projects = await listProjects(session.session.tenantId);
return (
<main>
<h1>Projects</h1>
<iamNext.Can action="projects:write" fallback={<p>You can view projects but not create them.</p>}>
<form action={newProject}>
<input name="name" required />
<button type="submit">New project</button>
</form>
</iamNext.Can>
<ul>
{projects.map((project) => (
<li key={project.id}>{project.name}</li>
))}
</ul>
</main>
);
});route()answers a signed-out request with401and a denial with403, as a JSON error envelope ({ error: { code, message } }).- A project from another organization fails with
RESOURCE_MISMATCH(403), even for that organization's owner. <iamNext.Can>is an advisory check for rendering. Enforce the mutation itself withaction()orroute().
For lists, filter by session.session.tenantId in your own query as above, or turn policies into a query filter
with data filtering.
Verify it works
- Start the app, sign in at
/login?tenantId=<root tenant ID>as the root administrator, and complete MFA. - On
/platform, createAcmewith the slugacmeand your own address as the owner. The terminal prints the owner invitation with its/invite?kind=owner...link. - Open the link, choose a name and password, and enroll an authenticator when asked (
requireMfaForOwnersfromtenantDefaultsapplies to the new owner). Press the button on/welcome. You land on/acme. - On
/acme/projects, create a project. On/acme/members, invite a second address as Member and accept that invitation in a private window. - As the member, open
/api/projects/<id>for theacmeproject: it answers200with the project. Create a second organization the same way, add a project there, and open its ID as theacmemember: it answers403withRESOURCE_MISMATCH. - As the owner, turn on Require MFA for everyone on
/acme/settings/security. The member's session stops working at its next use, and their next sign-in asks them to enroll an authenticator.
The audit log records each step: tenant:activate when the owner accepts, identity:invitation:accept for the
member, and tenant:auth-policy for the policy change.
Troubleshooting
Next steps
Let one organization sign in with its own identity provider.
SCIM provisioningLet a customer's directory create and remove its members.
Tenant authentication policyEvery per-organization sign-in rule.
Roles and bindingsRole inheritance, group bindings, and grant authorities.
Tenants and identitiesTenant trees, plan limits, suspension, and deletion.
Usage-based billingMeter usage and invoice each organization.
Better IAM is created by Sean Filimon
Last updated
Guides
Task-oriented walkthroughs for building, securing, extending, running, migrating, and upgrading applications on Better IAM, plus a cookbook of short recipes.
SAML single sign-on with Okta
Connect one organization's Okta to its Better IAM tenant with a tenant-managed SAML connection, verified domain, just-in-time accounts, default access, and enforced SSO.