BetterIAM
Next.js

Server actions and forms

Call Better IAM from Next.js server actions with the in-process client, guard mutations with action(), and drop in sign-in, step-up, and reset forms.

Server actions are where Next.js lets you change cookies, so they are where people sign in, sign out, step up, and mutate data. @better-iam/next gives you three layers:

  • iamNext.client(): the full typed API, bound to the current request and running in process.
  • iamNext.action(fn, spec): a guard that turns IAM failures into a serializable result for useActionState.
  • iamNext.authActions() and the forms in @better-iam/next/client: complete authentication flows that work with JavaScript disabled.

The in-process client

Why not call iam.api.auth.signIn directly from the action? It would verify the password and return a session token, but nothing would reach the browser. The session cookie, its attributes (host prefix, HttpOnly, SameSite, lifetime), and the remembered-device cookie are set by the IAM HTTP handler. iamNext.client() runs each call through that handler, in process, and copies what it sets onto your response.

iamNext.client() returns createIamClient<typeof iam> whose transport is iam.handler in the same process. For each call it:

  1. copies the caller's cookie, authorization, user-agent, accept-language, and forwarding headers (x-forwarded-for, x-forwarded-host, x-forwarded-proto, x-real-ip);
  2. sets Origin to the IAM origin (iam.endpoint.origin, from baseURL) and sends JSON with X-Better-IAM: 1, which satisfies the server's CSRF boundary;
  3. writes every Set-Cookie the handler returns (session, trusted device, sign-out clearing) through cookies().set.
app/account/actions.ts
'use server';
import { iamNext } from '@/lib/iam-next';

// Any IAM call from an action: cookies the server issues are written with cookies().set.
export async function revokeOtherDevices() {
  await iamNext.client().auth.revokeOtherSessions();
}

This makes sign-in, MFA, sign-out, password change, and trusted-device flows work as <form action> without client JavaScript. Rate limits, tenant authentication policies, session metadata (clientInfo), and audit events behave exactly as they do for browser calls. Next checks the Origin of the incoming server action POST itself, so the forwarded cookie cannot be replayed cross-site. Failures reject with an IamClientError carrying the server's code.

Cookie writes need an action or a route handler

Next only allows cookie writes in server actions and route handlers. A server component can call read-only methods, such as iamNext.client().identities.list(...). A method that issues a cookie throws an error naming the cookie ("could not update the ... cookie; call this method from a server action or route handler").

iamNext.clearSessionCookie() expires the session cookie in the current response. Sign-out calls it when the server no longer knows the presented session, so a stale cookie does not linger.

Guarded mutations

iamNext.action(fn, spec) requires a session, optionally a step-up, and optionally an , then calls fn(session, ...args). It returns an ActionResult: { ok: true, data } or { ok: false, error: { code, message } }, so IAM failures reach the form instead of an error boundary.

app/projects/[id]/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { iamNext } from '@/lib/iam-next';

export const rename = iamNext.action(
  async (session, _previous: unknown, form: FormData) => {
    const id = String(form.get('id'));
    await db.projects.rename(id, String(form.get('name')));
    revalidatePath(`/projects/${id}`);
    return { id };
  },
  {
    authorize: {
      action: 'projects:write',
      resource: ({ args: [, form] }) => ({ type: 'project', id: String(form.get('id')) }),
    },
  },
);
  • Codes you will see. UNAUTHENTICATED (no session), MFA_REQUIRED, RECENT_AUTH_REQUIRED, or IMPERSONATION_RESTRICTED (step-up), ACCESS_DENIED, RATE_LIMITED, and validation codes.
  • Your own failures. Throw an IamError (from @better-iam/core, or better-iam/core) inside fn, for example new IamError('INVALID_INPUT', 'Write something first', 400), to report it the same way.
  • Everything else propagates. Other errors, even with a code field, and redirect() or other Next control flow thrown by fn are rethrown unchanged.
  • Argument order. Pass the handler first and the spec second, so TypeScript infers the arguments from the handler's annotations and checks the resource callback against them. authorize callbacks receive { session, args }.

Server actions never redirect or interrupt on an IAM failure, and they need no Origin check of their own.

Auth forms

Signing in is rarely one request. A password may lead to an authenticator code, an emailed code, a recovery code, or first-time enrollment. A reset needs an email step and a link step, and an invitation may require MFA before the first session. Writing those flows as server actions means carrying the pending challenge between steps without leaking secrets, redirecting only to safe paths, and keeping every step usable without JavaScript. iamNext.authActions(options) returns server actions that do this, and @better-iam/next/client has a form for each. Export the actions from a 'use server' module and pass them to the forms.

Export the actions

app/auth-actions.ts
'use server';
import { iamNext } from '@/lib/iam-next';

const auth = iamNext.authActions({ afterSignIn: '/dashboard', discover: true });
export const signIn = auth.signIn;
export const reauthenticate = auth.reauthenticate;
export const requestPasswordReset = auth.requestPasswordReset;
export const resetPassword = auth.resetPassword;
export const signUp = auth.signUp;
export const verifyEmail = auth.verifyEmail;
export const acceptInvitation = auth.acceptInvitation;
export async function signOut() {
  await auth.signOut();
}

Export each action as its own const: Next registers every export of a 'use server' module as an action. signOut is wrapped because it takes no state.

Render the forms

app/login/page.tsx
import Link from 'next/link';
import { SignInForm } from '@better-iam/next/client';
import { signIn } from '../auth-actions';

export default async function Login(props: {
  searchParams: Promise<{ next?: string; org?: string; reset?: string }>;
}) {
  const { next, org, reset } = await props.searchParams;
  return (
    <main>
      <h1>Sign in</h1>
      {reset && <p>Your password was changed. Sign in with the new one.</p>}
      <SignInForm action={signIn} org={org} keepSignedIn passwordless {...(next ? { next } : {})} />
      <Link href="/forgot">Forgot your password?</Link>
    </main>
  );
}

Add the other pages

app/forgot/page.tsx
import { PasswordResetRequestForm } from '@better-iam/next/client';
import { requestPasswordReset } from '../auth-actions';

export default function Forgot() {
  return <PasswordResetRequestForm action={requestPasswordReset} org="acme" />;
}

The actions

Each action moves through steps; the submit button the person presses sends an intent field that picks the next one.

ActionSteps and intentsFinishes with
signInpassword or send-code then code (emailed sign-in code); then mfa, email-code, recovery, or enroll for the second factor; cancel starts overA redirect to a safe next (or afterSignIn); after enrollment, the done step shows the recovery codes once
reauthenticatepassword, then the same second-factor intentsA redirect to next; the replaced session is ended
requestPasswordResetEmail (and organization)The sent step, with the same notice whether or not the account exists
resetPasswordtenantId and token from the email link, the new password, an optional confirmationA redirect to loginPath?reset=1
signUp / verifyEmailSelf-registration (authentication.signUpEnabled) / the email linksent, or a redirect to loginPath?registered=1 / ?verified=1
acceptInvitationkind member or owner, tenantId, token, name, password, then enrollment when the tenant requires MFAA redirect to next
signOutNoneClears the cookie, also when the server no longer knows the session, and redirects to the login path

Every action except signOut takes (previousState, formData) and returns an AuthFormState:

FieldMeaning
stepcredentials, mfa, enroll, code-sent, sent, or done
error{ code, message, field?, retryAfterMs? }: the server's code, the offending form field, and the wait for RATE_LIMITED
mfaThe pending challenge: tenantId, challenge, enrollmentRequired, emailCodeAvailable, passkeyAvailable, and while enrolling enrollment: { secret, uri }
recoveryCodesShown once after enrolling an authenticator
noticeA short status, for example "We sent a code to ada@example.com"
valuesNon-secret inputs to refill the form: email, org, tenantId, name
next, keepSignedInThe safe return path and the "keep me signed in" choice, carried through later steps

Passwords, codes, session tokens, and session records never enter the state. The MFA challenge travels in hidden fields rather than server memory, which is what lets a form with JavaScript disabled post its second step. The server still validates every challenge, so a forged field only ever fails. Every redirect goes through safeRedirectPath.

  • Organization. The actions find the tenant from a tenantId field, an org slug (tenants.lookup), your resolveTenant(form), or, with discover: true, the verified domain of the email (domains.discover).
  • Keeping a session. "Keep me signed in" (keepSignedIn) sends X-Better-IAM-Persistent, so the cookie lasts or ends with the browser. Step-up keeps a browser-session cookie as one unless the form opts in. "Remember this device" (rememberDevice) on the authenticator step returns a device cookie that later sign-ins present automatically.
  • Error text. messages overrides the text for any error code or notice.

Prop

Type

The forms

Each form renders the fields and steps of the action it is paired with, so a page only passes the action and a few props:

FormProps besides action
SignInFormnext, org, tenantId, email, showOrganization (default true unless tenantId or org is given), keepSignedIn (offer the checkbox), passwordless (offer an emailed sign-in code)
ReauthenticateFormnext, keepSignedIn (whether the new cookie outlives the browser session; off by default)
PasswordResetRequestFormorg, tenantId
PasswordResetFormtenantId, token
SignUpFormtenantId, next
InvitationFormtenantId, token, kind (member or owner), next

Every form also takes labels (override any default English string) and className.

The forms render unstyled semantic HTML with hooks for your CSS: <form data-better-iam="sign-in" data-step="mfa"> and <div data-field="code">. Labels are bound, errors are tied to their field with aria-invalid and aria-describedby and announced with role="alert", notices use role="status", and the first field of each step takes focus. Alternative steps are named submit buttons (name="intent"), so a form works with JavaScript disabled, and useActionState keeps it in place when JavaScript is on.

Each form has a *View twin (SignInFormView, ReauthenticateFormView, ...) that takes { state, formAction, pending } instead of action, for your own container or for tests.

Passkeys need the browser

Passkey sign-in and passkey MFA run the WebAuthn ceremony in the browser, so the server actions cannot do them. Use better-iam/client/passkeys with the typed client for those.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page