The five member-management flows
Compose the five member-management Server Actions of an RBAC org, listing, role changes, removal, leaving, and ownership transfer, from the role-gated action wrapper and atomic audit-logged transactions.
Picture the settings page of any team product you’ve used. It has a “Members” tab that shows everyone in the org with their role and the date they joined. If you’re an admin or the owner, each row has a small menu next to it: change this person’s role, or remove them. Somewhere there’s a “Leave organization” button for yourself, and if you’re the owner, a way to hand the org to someone else before you go.
That’s five operations, and you already own every tool needed to build them. authedAction(role, schema, fn) from two lessons ago gives you the role gate and a tenant-scoped ctx. roleAtLeast, the Role union, and isLastOwner from the roles lesson give you the invariant checks. withTenant(orgId, fn) from the org chapter gives you a tenant-scoped transaction for atomic multi-row writes. Nothing in this lesson is a new primitive. The lesson is about how those primitives compose into four near-identical actions plus one read, and about the handful of traps each flow hides that will catch you if you aren’t looking for them.
Here’s the map. One read and four mutations:
- List members is a read, the simplest surface, and where we’ll settle the difference between hiding a button and refusing an action.
- Change a member’s role is the reference mutation. We’ll build this one end to end; the next three are variations on it.
- Remove a member has the same shape with different invariants, plus one genuinely new decision: hard delete, not soft delete.
- Leave the organization is self-service, with a session side effect the others don’t have.
- Transfer ownership is the only flow that writes two rows at once, and the only one that earns a diagram.
Every mutation is an authedAction. Every mutation guards its domain invariants in the body, the rules the UI can’t be trusted to enforce. Every mutation writes exactly one audit row, in the same transaction as the change. By the end, that description will read as a single shape rather than five separate things to memorize.
The members settings page
Section titled “The members settings page”Start with the read, since it’s the easiest of the five.
The /settings/members page is a Server Component. Its job is to list the members of the active org with their name, email, role, and joined date. The query lives in db/queries/members.ts, the home for tenant-scoped reads you established in the org chapter, and it closes over tenantDb(orgId), so the scoping is already done for you.
import { tenantDb } from '@/db/tenant';
export async function listMembers(orgId: string) { const db = tenantDb(orgId); return db.query.member.findMany({ with: { user: true }, orderBy: (member, { asc }) => asc(member.createdAt), });}Notice what isn’t here. There’s no where org_id = ... clause, because tenantDb(orgId) already pins every read to this tenant, exactly as it did in the org chapter. The with: { user: true } pulls each member’s name and email alongside the role and createdAt that already live on the member row, all in one query.
This read deliberately doesn’t paginate. Most orgs are under fifty seats, and a list of fifty rows needs no cursor. The unbounded case, orgs with thousands of members, is a problem you’ll solve later when we build production list views. Reaching for pagination here would solve a problem you don’t have.
Gate the UI for UX, gate the action for security
Section titled “Gate the UI for UX, gate the action for security”Here is the distinction that every later section in this lesson leans on.
The page reads the current user’s role and uses it to decide what to render. If roleAtLeast(role, 'admin') is false, it doesn’t draw the role dropdowns or the remove buttons, so a plain member sees a read-only roster. This is the UI gate, and its purpose is purely cosmetic: don’t show someone a button that will only ever tell them “no.”
The UI gate is not security; it’s a hint. A member who opens devtools, reads your network traffic, and crafts a request to changeMemberRole directly has bypassed the rendered UI entirely, since there was never a button standing between them and your server. So the action re-checks the role on the server every time it runs. That’s the security gate, and it’s the one that actually holds. It lives inside authedAction, which you built to make the role check the only call shape that compiles.
Hold onto this in one sentence: gate the UI for UX, gate the action for security. A user can lie about the UI, but they cannot lie their way past the wrapper. Every flow below renders a gated control and re-checks the same role server-side. The rendering is a courtesy; the wrapper is the boundary.
The shape every member action shares
Section titled “The shape every member action shares”Before we write a single action, let’s write the shape of all four. Once you can see the skeleton, the four mutations stop being four things to learn and become one thing plus four small variations.
Every member-management action follows the same five-step body. In the forms chapter you learned the universal Server Action seam: parse → authorize → mutate → revalidate → return. Here the first two steps have already been lifted out of the body and into the wrapper, so what’s left is a tight skeleton:
authedAction(role, schema, fn)supplies the gate. It validates the session, checks the role, parses the input, and hands youctx = { user, orgId, role, db }withdbalready tenant-scoped. Your body never re-checks the session, never re-checks the transport role, and never touches the baredb.- Read what you need through
ctx.db: the target member’s row, the count of owners, whatever the invariant requires. - Check the domain invariant. If it’s violated, return a typed domain reason with
err(...), such as'last-owner','cannot-demote-owner', or'not-a-member'. - Write inside
withTenant(ctx.orgId, async (tx) => …). The membership change and the audit row go together in one transaction .withTenant(imported from@/db:import { withTenant } from '@/db';) is the tenant-scoped transaction helper you built in the org chapter. You must use it here rather than a bare transaction, because the audit row’s org-isolation policy only lets the insert through whenapp.org_idis set, andwithTenantis the only primitive that sets it. - After the transaction commits,
revalidatePath('/settings/members'), thenreturn ok(...).
Here’s that skeleton with nothing filled in. Every action below amounts to filling in steps 1 through 3:
export const someMemberAction = authedAction(role, someSchema, async (input, ctx) => { // 1. read the rows the invariant needs, through ctx.db // 2. check the domain invariant; on failure: return err('reason', '…') await withTenant(ctx.orgId, async (tx) => { // 3. mutate the member row // 4. logAudit(tx, { … }) — same transaction as the mutation }); revalidatePath('/settings/members'); // 5. after commit, revalidate and return ok return ok(/* … */);});There’s one more rule baked into that skeleton, and it’s worth weighing carefully. Look at where the membership write happens: inside withTenant, through Drizzle, not through auth.api.updateMemberRole or auth.api.removeMember.
Write the membership row through Drizzle, not through auth.api
Section titled “Write the membership row through Drizzle, not through auth.api”Better Auth’s organization plugin ships its own methods for these operations: auth.api.updateMemberRole, auth.api.removeMember, and auth.api.leaveOrganization. You consume Better Auth directly everywhere else in this app, so the instinct to call them here is reasonable. For member management, you don’t, and here’s why.
This chapter’s contract for the audit log is that the mutation and its audit row land in one transaction, so the audit row exists if and only if the work landed. That’s what makes the log trustworthy three months later. Better Auth’s org methods write through the plugin’s own adapter, and since Better Auth 1.5 they run their after hooks after that internal transaction has already committed. So if you called auth.api.removeMember and then tried to write the audit row from the plugin’s after hook, your audit write would land in a different transaction than the membership delete. The membership could vanish and the audit write could fail right after, which is exactly the partial state the contract forbids.
The fix is to own the write. The member table is a normal table in db/schema.ts. Better Auth’s adapter maps to it, but it doesn’t own writes that need to be atomic with your audit trail. So you write the member row directly through Drizzle, inside the same withTenant transaction as logAudit(tx, …), and the two are now genuinely inseparable.
There’s a trade here worth naming honestly. By writing the row yourself, you give up the plugin’s built-in permission checks and its built-in last-owner guard on these specific operations. That’s not a loss to regret: it’s the reason this chapter built authedAction and isLastOwner in the first place. The app owns the gate (authedAction), the app owns the invariant (isLastOwner), and now the app owns the write. This is the same posture the chapter took for tenantDb and authedAction: consume the library directly almost everywhere, but own the one seam where a real class of bugs lives.
That’s the whole pattern: five steps, the write through Drizzle, the audit row in the same transaction. Now let’s fill it in four times.
This pattern holds because of one database property: atomicity, the A in ACID. If the word “transaction” still feels abstract, the video below is worth five minutes before you read on.
Changing a member’s role
Section titled “Changing a member’s role”This is the reference implementation. We’ll walk every line, because the next three actions are variations on this one. Once you’ve read this carefully, you’ve read most of the lesson.
The signature follows the naming contract from this chapter: verb plus noun, no Action suffix.
The schema is small, just a member id and a target role:
const changeMemberRoleSchema = z.object({ memberId: z.uuid(), role: z.enum(['owner', 'admin', 'member']),});z.uuid() is the Zod 4 top-level format builder; use it rather than the old z.string().uuid() chain. z.enum over the three role literals means a request with role: 'superadmin' fails parsing in the wrapper before your body ever runs.
Now the action itself. The wrapper’s first argument is 'admin', the minimum role required to change anyone’s role. Then come the body’s three domain invariants, each refusing with its own typed code. Step through it:
export const changeMemberRole = authedAction( 'admin', changeMemberRoleSchema, async ({ memberId, role }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: (m, { eq }) => eq(m.id, memberId), }); if (!target) return err('not-a-member', 'That member no longer exists.'); if (role === 'owner') return err('cannot-promote-to-owner', 'Use "Transfer ownership" to make someone an owner.'); if (target.role === 'owner' && ctx.role !== 'owner') return err('cannot-demote-owner', 'Only an owner can change another owner.'); if (target.role === 'owner' && (await isLastOwner(ctx.orgId))) return err('last-owner', 'This org must always have an owner.');
const updated = await withTenant(ctx.orgId, async (tx) => { const [row] = await tx.update(member).set({ role }).where(eq(member.id, memberId)).returning(); await logAudit(tx, { action: 'member.role-changed', subjectId: memberId, payload: { before: target.role, after: role } }); return row; }); revalidatePath('/settings/members'); return ok(updated); },);The security gate. By the time the body runs, the wrapper has already confirmed a valid session, confirmed the caller is at least an admin, and parsed the input against changeMemberRoleSchema. Everything below is only domain logic.
export const changeMemberRole = authedAction( 'admin', changeMemberRoleSchema, async ({ memberId, role }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: (m, { eq }) => eq(m.id, memberId), }); if (!target) return err('not-a-member', 'That member no longer exists.'); if (role === 'owner') return err('cannot-promote-to-owner', 'Use "Transfer ownership" to make someone an owner.'); if (target.role === 'owner' && ctx.role !== 'owner') return err('cannot-demote-owner', 'Only an owner can change another owner.'); if (target.role === 'owner' && (await isLastOwner(ctx.orgId))) return err('last-owner', 'This org must always have an owner.');
const updated = await withTenant(ctx.orgId, async (tx) => { const [row] = await tx.update(member).set({ role }).where(eq(member.id, memberId)).returning(); await logAudit(tx, { action: 'member.role-changed', subjectId: memberId, payload: { before: target.role, after: role } }); return row; }); revalidatePath('/settings/members'); return ok(updated); },);Read the target member through the tenant-scoped ctx.db. If there’s no row, the member was already removed or never existed in this org, so refuse with 'not-a-member'. This is a body check, not a Zod check: Zod validated the shape of memberId, but only a database read can confirm the row exists.
export const changeMemberRole = authedAction( 'admin', changeMemberRoleSchema, async ({ memberId, role }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: (m, { eq }) => eq(m.id, memberId), }); if (!target) return err('not-a-member', 'That member no longer exists.'); if (role === 'owner') return err('cannot-promote-to-owner', 'Use "Transfer ownership" to make someone an owner.'); if (target.role === 'owner' && ctx.role !== 'owner') return err('cannot-demote-owner', 'Only an owner can change another owner.'); if (target.role === 'owner' && (await isLastOwner(ctx.orgId))) return err('last-owner', 'This org must always have an owner.');
const updated = await withTenant(ctx.orgId, async (tx) => { const [row] = await tx.update(member).set({ role }).where(eq(member.id, memberId)).returning(); await logAudit(tx, { action: 'member.role-changed', subjectId: memberId, payload: { before: target.role, after: role } }); return row; }); revalidatePath('/settings/members'); return ok(updated); },);The domain invariants, each with its own typed reason. Nobody becomes an owner here, because promotion to owner is the transfer flow. An admin can’t touch an existing owner; only a fellow owner can change an owner. And the org’s last owner can’t be demoted, which isLastOwner guards. That single check also covers a sole owner trying to demote themselves, so you need no separate self-check.
export const changeMemberRole = authedAction( 'admin', changeMemberRoleSchema, async ({ memberId, role }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: (m, { eq }) => eq(m.id, memberId), }); if (!target) return err('not-a-member', 'That member no longer exists.'); if (role === 'owner') return err('cannot-promote-to-owner', 'Use "Transfer ownership" to make someone an owner.'); if (target.role === 'owner' && ctx.role !== 'owner') return err('cannot-demote-owner', 'Only an owner can change another owner.'); if (target.role === 'owner' && (await isLastOwner(ctx.orgId))) return err('last-owner', 'This org must always have an owner.');
const updated = await withTenant(ctx.orgId, async (tx) => { const [row] = await tx.update(member).set({ role }).where(eq(member.id, memberId)).returning(); await logAudit(tx, { action: 'member.role-changed', subjectId: memberId, payload: { before: target.role, after: role } }); return row; }); revalidatePath('/settings/members'); return ok(updated); },);The atomic write: one member row update and one audit row, in a single transaction. The event is 'member.role-changed' with { before, after }, the role diff the next lesson formalizes. Because both writes share the transaction, the audit row exists if and only if the role actually changed.
export const changeMemberRole = authedAction( 'admin', changeMemberRoleSchema, async ({ memberId, role }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: (m, { eq }) => eq(m.id, memberId), }); if (!target) return err('not-a-member', 'That member no longer exists.'); if (role === 'owner') return err('cannot-promote-to-owner', 'Use "Transfer ownership" to make someone an owner.'); if (target.role === 'owner' && ctx.role !== 'owner') return err('cannot-demote-owner', 'Only an owner can change another owner.'); if (target.role === 'owner' && (await isLastOwner(ctx.orgId))) return err('last-owner', 'This org must always have an owner.');
const updated = await withTenant(ctx.orgId, async (tx) => { const [row] = await tx.update(member).set({ role }).where(eq(member.id, memberId)).returning(); await logAudit(tx, { action: 'member.role-changed', subjectId: memberId, payload: { before: target.role, after: role } }); return row; }); revalidatePath('/settings/members'); return ok(updated); },);After the transaction commits, revalidate the members page so the table reflects the new role, then return ok with the updated row. Revalidation lands after the commit and outside the transaction, never inside it.
A few things are worth sitting with. The three invariant checks in step 3 do not rely on the UI to prevent anything. The UI does hide the relevant options, but these checks are the real defense, because they return a typed code regardless of what the caller sent. Notice too that the last-owner check covers a case you might have written a separate guard for: a sole owner trying to demote themselves. They’re an owner, they’re the last owner, so the 'last-owner' refusal fires. No redundant self-check needed.
That 'cannot-promote-to-owner' refusal is also doing quiet structural work. By making it impossible to become an owner through the role-change action, it forces every ownership change down a single, deliberate path, the transfer flow, where the extra invariants and the two-row write live. That’s why transfer is a separate action rather than just changeMemberRole({ role: 'owner' }).
Removing a member
Section titled “Removing a member”Removal uses the same skeleton as change-role. Steps 1, 4, and 5 are identical in spirit; only the invariants and the kind of write differ. So rather than re-show the skeleton, we’ll show the diff.
The schema is just a member id. The wrapper minimum is still 'admin'. Three invariants are unique to removal:
- You can’t remove yourself, because the path to self-exit is
leaveOrganization, not removal. Compare the target’suserIdtoctx.user.idand refuse with'cannot-target-self'. - You can’t remove the last owner, which
isLastOwnerguards again, refusing with'last-owner'. - An
admincan removeadmins andmembers but notowners, refusing with'cannot-remove-owner'.
Here it is against the empty skeleton:
export const someMemberAction = authedAction(role, someSchema, async (input, ctx) => { // 1. read the rows the invariant needs, through ctx.db // 2. check the domain invariant; on failure: return err('reason', '…') await withTenant(ctx.orgId, async (tx) => { // 3. mutate the member row // 4. logAudit(tx, { … }) — same transaction as the mutation }); revalidatePath('/settings/members'); // 5. after commit, revalidate and return ok return ok(/* … */);});The shape you already know. Fill in the read, the checks, and the write.
export const removeMember = authedAction( 'admin', removeMemberSchema, async ({ memberId }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: (m, { eq }) => eq(m.id, memberId), }); if (!target) return err('not-a-member', 'That member no longer exists.'); if (target.userId === ctx.user.id) return err('cannot-target-self', 'Use "Leave organization" to remove yourself.'); if (target.role === 'owner') return err('cannot-remove-owner', 'Owners cannot be removed.');
await withTenant(ctx.orgId, async (tx) => { await tx.delete(member).where(eq(member.id, memberId)); await logAudit(tx, { action: 'member.removed', subjectId: memberId, payload: { previousRole: target.role } }); }); revalidatePath('/settings/members'); return ok({ memberId }); },);Membership is not an entity with its own history, so removal is a hard delete, and the audit row is the record that it happened. The two highlights are all that’s new versus change-role: the cannot-target-self check, and the delete where change-role had an update. The schema is just const removeMemberSchema = z.object({ memberId: z.uuid() });.
The one genuinely new decision here is hard delete, not soft delete. You may have a reflex by now to flag a row deletedAt rather than physically removing it, so you can restore it and keep its history. That reflex is right for content like invoices. It’s wrong for membership. A membership isn’t a document with a lifecycle worth preserving; it’s a join between a user and an org that’s either present or absent. When you remove someone, you want them gone, with no orphaned soft-deleted row to leak into queries or seat counts. And you don’t lose the history, because the audit row records who removed whom and when. The membership row is the live state; the audit log is the trail. Delete the row, keep the log.
The delete and the audit write share one transaction, same as before: if the audit write fails, the delete rolls back and the member stays. The audit event carries previousRole in its payload, because once the row is gone that information is only recoverable from the log.
You might be wondering what happens to the removed person’s session, since they were signed in a second ago. Hold that thought; there’s a short section on it once all the actions are built. The short version is that you do nothing, and it’s fine.
Leaving the organization
Section titled “Leaving the organization”Leaving is removal pointed at yourself, but with one new wrinkle the others don’t have: it’s the first flow that has to touch the session, and that’s the part worth your attention.
Any role may attempt to leave, so the wrapper’s minimum is 'member'. There’s no input, since you’re always leaving as yourself, identified by ctx.user.id, so the schema is an empty object, which the chapter names emptySchema (z.object({})).
There’s exactly one invariant: an owner can’t abandon the org. If you’re an owner and isLastOwner(ctx.orgId) is true, you must transfer ownership before you can leave, so refuse with 'last-owner-must-transfer'. A non-last owner can’t trigger this, because by definition another owner remains. This is the third place the single-owner invariant shows up; the roles lesson named all three: removal, demotion, and now leaving.
The write is a delete of your own member row plus the audit event 'member.left', in one transaction. So far this is identical to removal. Here’s the new part:
export const leaveOrganization = authedAction('member', emptySchema, async (_input, ctx) => { if (ctx.role === 'owner' && (await isLastOwner(ctx.orgId))) { return err('last-owner-must-transfer', 'Transfer ownership before you leave.'); }
await withTenant(ctx.orgId, async (tx) => { await tx .delete(member) .where(and(eq(member.organizationId, ctx.orgId), eq(member.userId, ctx.user.id))); await logAudit(tx, { action: 'member.left' }); });
const remaining = await listMemberships(ctx.user.id); const fallback = remaining[0]?.organizationId ?? null; await auth.api.setActiveOrganization({ headers: await headers(), body: { organizationId: fallback }, });
revalidatePath('/settings/members'); redirect(fallback ? '/dashboard' : '/onboarding/create-org');});listMemberships(userId) is a small sibling read of the user’s remaining memberships across every org. It isn’t tenant-scoped, because it deliberately spans orgs. The blue-highlighted block is the side effect that runs once the transaction commits.
Look at what that block does. The user just deleted their membership in this org, but their session still points at it via activeOrganizationId. If you left that pointer dangling, their next request would resolve to an org they’re no longer a member of. So you fix the session pointer: find their first remaining membership (or null if they have none), and call auth.api.setActiveOrganization to move the pointer there. Then redirect, to /dashboard if they still belong to an org, or to the create-org route if this was their last one.
That auth.api call should make you pause, because the last big rule of this lesson was to write membership through Drizzle, not auth.api. This is the sanctioned exception, and the reason it’s allowed is precise. The session, including which org is active, is Better Auth’s state to own, not yours. And moving the pointer is a post-commit side effect, not part of the membership-delete atom. Contrast the two writes cleanly:
- The audit row must be inside the transaction. It has to be atomic with the delete, or the contract breaks.
- The session pointer must be outside the transaction. It’s an external call into Better Auth, and the chapter’s transaction rule has always been to make no external service calls inside
db.transaction.
So the rule isn’t “never call auth.api.” It’s that the membership write is yours and must co-transact with the audit row, while the session is Better Auth’s and lives after the commit. Two different owners, two different positions relative to the transaction bracket.
Transferring ownership
Section titled “Transferring ownership”This is the most complex flow, and the only one that writes more than one row. It’s also the flow where students reach for an auth.api call, and there isn’t one: there is no auth.api.transferOwnership. You compose a transfer yourself out of two role changes, and you make them atomic. That’s why this flow earns the lesson’s one diagram: order and atomicity genuinely matter here in a way they didn’t for the single-row flows.
Only an owner can initiate a transfer, so the wrapper minimum is 'owner'. The schema takes the new owner’s member id. There are two invariants:
- The target must be an existing member of this org, or refuse with
'not-a-member'. This is a body check: you read the target’smemberrow throughctx.db. The rule holds once more here: Zod validates thatnewOwnerIdis shaped like a UUID, and only a database read confirms it points at a real member. - You can’t transfer to yourself, or refuse with
'cannot-target-self'.
Now the write, which is the heart of it. Better Auth has no transfer endpoint, so a transfer is two role updates: promote the target to 'owner', and demote yourself. Demote to what? The year-1 default is 'admin', so the former owner keeps administrative access but loses billing. ('member' is a perfectly valid choice if your product wants the outgoing owner fully stepped back; that’s a product decision, not a technical one. We’ll go with 'admin'.) Both updates, plus the audit row, go inside one transaction, so a half-transferred org with two owners or zero is impossible.
The schema takes only the new owner’s member id: const transferOwnershipSchema = z.object({ newOwnerId: z.uuid() });.
export const transferOwnership = authedAction( 'owner', transferOwnershipSchema, async ({ newOwnerId }, ctx) => { const target = await ctx.db.query.member.findFirst({ where: (m, { eq }) => eq(m.id, newOwnerId), }); if (!target) return err('not-a-member', 'That person is not a member of this org.'); if (target.userId === ctx.user.id) return err('cannot-target-self', 'You already own this org.');
await withTenant(ctx.orgId, async (tx) => { await tx.update(member).set({ role: 'owner' }).where(eq(member.id, newOwnerId)); await tx .update(member) .set({ role: 'admin' }) .where(and(eq(member.organizationId, ctx.orgId), eq(member.userId, ctx.user.id))); await logAudit(tx, { action: 'org.ownership-transferred', subjectId: newOwnerId, payload: { from: ctx.user.id, to: target.userId, demotedTo: 'admin' }, }); }); revalidatePath('/settings/members'); return ok({ newOwnerId }); },);The two highlighted update calls are the whole transfer: promote the target to 'owner', demote yourself to 'admin'. Both, plus the audit row, sit inside one transaction.
The diagram below is the point of this section. Walk the request from the owner’s browser through the action and into the transaction, and watch where the writes happen. The thing to see is the transaction bracket: the two role updates and the audit insert all sit inside it, and nothing is committed until COMMIT. If the demote failed after the promote, the promote would roll back with it. There is no moment when the org has two owners or none.
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 18px !important; } .actor { font-size: 16px !important; } .noteText, .noteText tspan { font-size: 15px !important; }'} }%%
sequenceDiagram
actor Owner
participant action as transferOwnership
participant tx as withTenant
participant DB
Owner->>action: submit newOwnerId
Note over action: wrapper resolves session +<br/>roleAtLeast('owner') — fails here → 'forbidden'
action->>DB: read target member
DB-->>action: target row
Note over action: no row → return 'not-a-member',<br/>nothing written
rect rgba(129, 140, 248, 0.18)
Note over tx,DB: one transaction — all or nothing
action->>tx: BEGIN
tx->>DB: UPDATE target.role = 'owner'
tx->>DB: UPDATE self.role = 'admin'
tx->>DB: INSERT audit row (org.ownership-transferred)
tx->>DB: COMMIT
end
action->>action: revalidatePath('/settings/members')
action-->>Owner: ok Let’s drill the ordering, because it’s the one thing worth getting into muscle memory: the audit write and both role updates go inside the transaction, and the revalidate comes after it. Order these steps.
Order the steps `transferOwnership` runs when an owner hands the org to a teammate. Drag the items into the correct order, then press Check.
roleAtLeast('owner') ctx.db 'not-a-member' if no row exists, 'cannot-target-self' if it’s you member.role = 'owner' member.role = 'admin' org.ownership-transferred audit row revalidatePath('/settings/members') and return ok One more thing about transfer, named so you know it exists but not built here. Ownership transfer is a high-stakes mutation, the kind where a production app re-checks that the session is recent (session.freshAge) and returns 'requires-re-authentication' if it’s stale, forcing a password re-prompt before such a consequential change. That gate belongs to the auth chapter’s fresh-session machinery. We’re naming it, not wiring it, to keep this lesson’s focus on the membership writes.
Why removed members don’t need a logout
Section titled “Why removed members don’t need a logout”Back to the question that’s been waiting since the remove section: you just deleted Bob’s membership, but Bob is still signed in, with a valid session cookie sitting in his browser. How do you force him out of the org he can no longer access?
You don’t, and the reason is the cleanest payoff of everything this chapter built.
Bob’s session is still valid, since he’s still a signed-in user of your app. But that session carries activeOrganizationId = thisOrg. On Bob’s very next request, requireOrgUser runs, exactly as it does on every request, and reads his role and membership fresh from the database, because the roles lesson established that authority is read per request and never trusted from the token. It looks for a member row matching (thisOrg, bobUserId), finds none, and redirects him to the no-org route, /onboarding/create-org. That’s the exact same exit requireOrgUser takes for an active org you aren’t a member of, because that’s a broken state, not a role to invent. Bob’s session cookie is never touched; it stays valid. The removal action did nothing special to make this happen. Correctness simply falls out of the per-request membership read the chapter already built.
Scrub through the two steps below to see the self-heal play out:
admin
removeMember
deletes one row
Bob’s browser
session cookie
still validnot touched by the delete
Bob’s browser
session cookie
still validactive org = this org
per request
requireOrgUser
reads membership fresh
redirect
→ /onboarding/create-org
There’s one bound on how fast this takes effect. Better Auth caches the decoded session for a short window of minutes to avoid a database hit on every request, so if Bob’s request happens to hit that cache, the change can take up to the cache window to apply. For the stricter case of instantly killing the removed or demoted user’s other sessions, there’s revokeOtherSessions, the production hardening from the auth chapter. We’re not building it here; the per-request read already self-heals within seconds, and that’s the right default.
The thread to pull on is that the remove action is boring: it deletes a row and writes a log. All the “how do we revoke their access” complexity you might have braced for simply doesn’t exist, because the system reads authority fresh every request. Build the read once, correctly, and removal is a one-line delete forever after.
What the user sees when an action fails
Section titled “What the user sees when an action fails”The actions are only useful if their typed failures reach the user as words. Let’s close the loop from err(code, message) to a rendered message.
Recall the UI surface. The members table renders a role dropdown and a remove button per row, both gated on roleAtLeast(role, 'admin') and both hidden on the current user’s own row (you don’t demote or remove yourself from this table; you leave or transfer). Owners additionally see a “Transfer ownership” affordance, and every user sees a “Leave organization” button for themselves. Same rule as always: the rendering is gated for UX, the action is gated for security.
Every one of these actions returns a Result: ok on success, or err(code, userMessage) on failure. At the form root, useActionState exposes that result, and the UI reads state.error.userMessage to show a toast or an inline error. Your job at the UI is to wire one message per code. Here’s the distinction that’s run through the entire chapter, now made concrete: some of those codes come from the wrapper (transport-level, meaning the role gate failed or the input didn’t parse) and some come from your action body (domain-level, meaning an invariant was violated). Same Result shape, two different origins.
Here’s the full surface in one table:
| Code | Origin | Emitted by | What the user sees |
| --- | --- | --- | --- |
| forbidden | wrapper | any action (role gate fails) | “You don’t have permission to do this.” |
| validation | wrapper | any action (bad input) | “Something’s off with that request.” |
| not-a-member | body | change role, transfer | “That member is no longer part of this org.” |
| cannot-promote-to-owner | body | change role | “Use “Transfer ownership” to make someone an owner.” |
| cannot-demote-owner | body | change role | “Only an owner can change another owner.” |
| cannot-remove-owner | body | remove | “Owners can’t be removed.” |
| cannot-target-self | body | remove, transfer | “You can’t do that to yourself.” |
| last-owner | body | change role | “This org must always have an owner.” |
| last-owner-must-transfer | body | leave | “Transfer ownership before you leave.” |
Read that table top to bottom and you can see the chapter’s architecture in miniature: two transport codes the wrapper owns, a stack of domain codes the bodies own, and every one of them a typed string the UI maps to a sentence. There’s no try/catch guessing at error messages and no stringly-typed comparisons. The action tells the UI exactly what went wrong, and the UI decides how to say it.
Two senior options are worth naming and not reaching for by default:
Optimistic updates. A role change is a single, fast row, so flipping the dropdown the instant the user picks a new role, before the server confirms, is a reasonable reach with useOptimistic . You’d show the new role immediately and roll it back with a toast if the action returns an error code. It’s a nicer feel, but it’s not the default. The default is the plain useActionState flow, which waits for the real result. Reach for optimism when the latency is actually felt.
The last-write-wins race. Two admins open the same member’s dropdown and both change the role within a few seconds of each other. The second write wins, and the first is silently overwritten. For role changes that’s acceptable: no invariant is violated, and the audit log records both writes, so “who changed what, when” always has an answer. The heavier fix is a version column that rejects a write made against stale data, which belongs to the optimistic-concurrency chapter and matters mostly for genuinely conflicting transfers. The year-1 default is last-write-wins, with the audit log as the source of truth.
Before you go, one quick check on the distinction the whole chapter turns on. Sort these refusal codes by where they come from.
Each of these refusals is a code an action can return. Which come from the `authedAction` wrapper, and which from the action body? Drag each item into the bucket it belongs to, then press Check.
forbiddenvalidationlast-ownercannot-demote-ownercannot-remove-ownercannot-target-selfnot-a-memberlast-owner-must-transferThat’s the whole surface. Five flows, one skeleton: authedAction for the role gate, and a body that reads, checks one invariant, writes the mutation and its audit row in one transaction, revalidates, and returns. The wrapper makes the role check structural, the transaction makes the audit trail honest, and the per-request role read makes removal self-healing. Next lesson, you’ll build the other half of every one of these flows: logAudit and the append-only audit_logs table that has been quietly receiving a row from each transaction all along.
External resources
Section titled “External resources”The members/roles API this lesson deliberately writes around — removeMember, updateMemberRole, leaveOrganization.
The db.transaction API that withTenant wraps to make the membership write and its audit row atomic.
Step 5 of every flow: invalidate the members page cache after the transaction commits.