Organization plugin and the active org
The starter runs, but every tenant-aware surface is hollow.
There are no organization, member, or invitation tables, the session records no active org, and the role primitives the privileged paths depend on are stubs that always return false.
Sign in, open /inspector, and the active-org banner reads “No active organization”.
By the end of this lesson, /inspector resolves a real org context: the banner shows the acting user’s org name and role, and the dev org and acting-user switchers re-render it against live data.
Your mission
Section titled “Your mission”You are wiring the tenancy spine in three pieces.
First, the schema.
The organization, member, and invitation tables come from Better Auth’s organization plugin: you register the plugin, and a CLI reads your config to generate the Drizzle schema.
This project adds two server-managed columns to invitation (tokenHash and acceptedAt) through the plugin’s additionalFields, marked so the app sets them and an API caller cannot.
Run the plugin with teams disabled and a seven-day invitation expiry in a named constant.
Second, the active org on the session.
Every path that mints a session (sign-in, sign-up, post-verification) runs through one Better Auth hook, so seed activeOrganizationId from the user’s membership there and no flow can forget it.
Third, the access primitives.
roleAtLeast orders the three roles so a gate can ask “is this identity at least an admin?”.
requireOrgUser resolves the active-org context for a protected request and reads the role fresh from the database, since the session cookie cache can hold a stale role for the freshAge window after a role change.
The active-org id comes only from the server-validated session, never from a query string or route param.
The generated schema file is machine output: after you change the plugin config, regenerate and commit the diff, never hand-edit it.
Out of scope: the create-org onboarding page and the org / acting-user switcher client components are provided. Verify them, don’t re-implement them.
organization, member, and invitation tables exist in the migrated schema, and session carries an activeOrganizationId column, once the regenerated auth schema is committed and migrated.activeOrganizationId populated to the user’s first membership, confirmed for the four seeded users.roleAtLeast orders the three roles correctly: a member does not satisfy admin; an admin satisfies admin but not owner; an owner satisfies all three.requireOrgUser() returns { user, orgId, role } for a member of the active org, redirects to /onboarding/create-org when no active org is set, and redirects there again when no membership is found for that org./onboarding/create-org; submitting creates the org and redirects to /dashboard with the new org active.Coding time
Section titled “Coding time”Build it against the brief and run pnpm test:lesson 2 before opening the walkthrough.
The TODO stubs live in src/lib/auth/roles.ts, src/lib/auth.ts, src/lib/auth-schema.config.ts, and src/app/(protected)/inspector/_data.ts; pnpm auth:generate then regenerates src/db/schema/auth.ts.
Reference solution and walkthrough
The role vocabulary
Section titled “The role vocabulary”Start with src/lib/auth/roles.ts, the smallest piece, since everything else references it.
It defines the three roles and one comparison:
// No `import 'server-only'` — pure role vocabulary, safe for client components.
export type Role = 'owner' | 'admin' | 'member';
export const ROLE_RANK = { member: 0, admin: 1, owner: 2,} as const satisfies Record<Role, number>;
export const roleAtLeast = (role: Role, required: Role): boolean => ROLE_RANK[role] >= ROLE_RANK[required];Ranking the roles as integers turns “is this identity privileged enough?” into a >= comparison.
The satisfies Record<Role, number> forces the object to cover every role, so adding a fourth role without a rank is a compile error.
The module skips import 'server-only' because the inspector’s role select is a client component that imports it, and a server-only module would break the build.
Registering the organization plugin
Section titled “Registering the organization plugin”In src/lib/auth.ts, add organization() to the plugins array.
Its position in the array matters, and so do three of its options.
plugins: [ organization({ teams: { enabled: false }, invitationExpiresIn: INVITATION_TTL_SECONDS, schema: { invitation: { additionalFields: { tokenHash: { type: 'string', required: true, input: false }, acceptedAt: { type: 'date', required: false, input: false }, }, }, }, }), nextCookies(),],nextCookies() flushes the Set-Cookie header out of the Server Action response, so it must stay last. Put organization() after it and sign-in still succeeds on the server, but no session cookie reaches the browser and the next request looks logged-out — with nothing in the logs to explain it.
plugins: [ organization({ teams: { enabled: false }, invitationExpiresIn: INVITATION_TTL_SECONDS, schema: { invitation: { additionalFields: { tokenHash: { type: 'string', required: true, input: false }, acceptedAt: { type: 'date', required: false, input: false }, }, }, }, }), nextCookies(),],Teams off gives each org one flat membership layer, no nested teams. INVITATION_TTL_SECONDS is the seven-day constant declared at module scope (60 * 60 * 24 * 7), naming the expiry once for the send and accept flows to share.
plugins: [ organization({ teams: { enabled: false }, invitationExpiresIn: INVITATION_TTL_SECONDS, schema: { invitation: { additionalFields: { tokenHash: { type: 'string', required: true, input: false }, acceptedAt: { type: 'date', required: false, input: false }, }, }, }, }), nextCookies(),],Two columns this project needs: tokenHash (the SHA-256 of the invite token) and acceptedAt. input: false makes them server-managed — the app sets them, an API caller cannot — so an attacker can’t supply their own tokenHash.
Seeding the active org once
Section titled “Seeding the active org once”The session needs to know which org it is acting inside, and the before hook on session creation is the one place every session-minting flow — sign-in, sign-up, post-verification — passes through.
Seed activeOrganizationId there and no flow can forget it.
// The session-create hook seeds activeOrganizationId from the user's most-recent// membership. One org per user in this project, so findFirst is enough.const pickInitialActiveOrg = async (userId: string): Promise<string | null> => { const membership = await db.query.member.findFirst({ where: eq(authSchema.member.userId, userId), }); return membership?.organizationId ?? null;};pickInitialActiveOrg returns the user’s organizationId, or null for a fresh signup with no org yet; findFirst suffices because each user has one org.
The hook spreads the session Better Auth is about to write and adds the field:
// Seed activeOrganizationId on every session mint — the one place all sign-in / // sign-up / verification paths flow through, so the setter is never sprinkled // across the individual flows. databaseHooks: { session: { create: { before: async (session) => ({ data: { ...session, activeOrganizationId: await pickInitialActiveOrg(session.userId), }, }), }, }, },Better Auth persists the returned { data: ... } as the row, so spreading ...session keeps everything the plugin computed and adds one field.
Mirroring the config for the schema generator
Section titled “Mirroring the config for the schema generator”The CLI generates the organization / member / invitation tables, but it cannot import src/lib/auth.ts: that file opens with import 'server-only', which throws when the CLI runs the import graph in plain Node.
So a second config, src/lib/auth-schema.config.ts, mirrors only the options that shape the schema.
plugins: [ organization({ teams: { enabled: false }, invitationExpiresIn: INVITATION_TTL_SECONDS, schema: { invitation: { additionalFields: { tokenHash: { type: 'string', required: true, input: false }, acceptedAt: { type: 'date', required: false, input: false }, }, }, }, }), nextCookies(), ],The live instance. Carries the databaseHooks, invitationExpiresIn, and input: false flags that govern runtime writes; the generator ignores all of it.
import { betterAuth } from 'better-auth';import { drizzleAdapter } from 'better-auth/adapters/drizzle';import { organization } from 'better-auth/plugins';
import { db } from '@/db';
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg' }), emailAndPassword: { enabled: true }, plugins: [ organization({ teams: { enabled: false }, schema: { invitation: { additionalFields: { tokenHash: { type: 'string', required: true }, acceptedAt: { type: 'date', required: false }, }, }, }, }), ],});The mirror. Only what changes the table shape — teams off, plus the two extra invitation columns; a schema cares whether a column exists, not who may write it, and it imports nothing server-only.
Two configs is a real maintenance cost: change the plugin’s table shape in both, then regenerate.
pnpm auth:generate— the CLI readsauth-schema.config.tsand rewritessrc/db/schema/auth.tswith the new tables and columns.pnpm db:generate— Drizzle Kit diffs the schema and emits a SQL migration.pnpm db:migrate— applies the migration to your local Postgres.- Commit the generated
schema/auth.tsand the new migration together. Never hand-edit the generated schema; if it is wrong, fix the config and regenerate.
The generated schema adds three tables and a column:
export const session = pgTable('session', { // ...existing columns activeOrganizationId: text('active_organization_id'),});
export const organization = pgTable('organization', { id: text('id').primaryKey(), name: text('name').notNull(), slug: text('slug').notNull().unique(), // ...});
export const member = pgTable('member', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull().references(() => organization.id, ...), userId: text('user_id').notNull().references(() => user.id, ...), role: text('role').default('member').notNull(), // ...});
export const invitation = pgTable('invitation', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull().references(() => organization.id, ...), email: text('email').notNull(), role: text('role'), status: text('status').default('pending').notNull(), tokenHash: text('token_hash').notNull(), // the additionalField — required acceptedAt: timestamp('accepted_at'), // the additionalField — nullable // ...});tokenHash landed NOT NULL and acceptedAt nullable — the config’s required: true / required: false carried through into real DDL.
requireOrgUser: the active-org gate
Section titled “requireOrgUser: the active-org gate”Every privileged read and write calls this function to answer three questions at once: who is the user, which org are they acting inside, and what is their role there. The starter shipped a placeholder; you are replacing it with the real gate.
export const requireOrgUser = cache( async (): Promise<{ user: User; orgId: string; role: Role }> => { const session = await getSession(); if (!session) { redirect('/sign-in' as Route); }
const orgId = session.session.activeOrganizationId; if (!orgId) { redirect('/onboarding/create-org' as Route); }
const activeMember = await auth.api.getActiveMember({ headers: await headers(), }); if (!activeMember) { redirect('/onboarding/create-org' as Route); }
return { user: session.user, orgId, role: activeMember.role as Role }; },);No session means no signed-in user, so bounce to sign-in. getSession is the one cached session read the whole module shares.
export const requireOrgUser = cache( async (): Promise<{ user: User; orgId: string; role: Role }> => { const session = await getSession(); if (!session) { redirect('/sign-in' as Route); }
const orgId = session.session.activeOrganizationId; if (!orgId) { redirect('/onboarding/create-org' as Route); }
const activeMember = await auth.api.getActiveMember({ headers: await headers(), }); if (!activeMember) { redirect('/onboarding/create-org' as Route); }
return { user: session.user, orgId, role: activeMember.role as Role }; },);The org id comes only from the server-validated session, never a query string or route param. A signed-in user with no active org belongs to no org yet, so send them to create one.
export const requireOrgUser = cache( async (): Promise<{ user: User; orgId: string; role: Role }> => { const session = await getSession(); if (!session) { redirect('/sign-in' as Route); }
const orgId = session.session.activeOrganizationId; if (!orgId) { redirect('/onboarding/create-org' as Route); }
const activeMember = await auth.api.getActiveMember({ headers: await headers(), }); if (!activeMember) { redirect('/onboarding/create-org' as Route); }
return { user: session.user, orgId, role: activeMember.role as Role }; },);A fresh database read of the membership row. Taking the role from here rather than the session cookie closes the stale-role window, explained below; a missing membership also redirects to create-org.
export const requireOrgUser = cache( async (): Promise<{ user: User; orgId: string; role: Role }> => { const session = await getSession(); if (!session) { redirect('/sign-in' as Route); }
const orgId = session.session.activeOrganizationId; if (!orgId) { redirect('/onboarding/create-org' as Route); }
const activeMember = await auth.api.getActiveMember({ headers: await headers(), }); if (!activeMember) { redirect('/onboarding/create-org' as Route); }
return { user: session.user, orgId, role: activeMember.role as Role }; },);cache() dedupes the resolution per request, so the inspector’s several Suspense panels share one set of reads instead of one per panel.
Better Auth caches the session role in a cookie for freshAge, avoiding a database hit on every request.
An authorization gate cannot tolerate a stale role: when an owner demotes an admin to member, the demoted user must lose admin powers before the cookie expires.
Reading the role through getActiveMember spends one extra query so the change takes effect within seconds.
Resolving the inspector’s identity
Section titled “Resolving the inspector’s identity”The last stub is src/app/(protected)/inspector/_data.ts.
The inspector is a dev verification surface with a trick the rest of the app lacks: a cookie that renders the page as any seeded user, so you can check each role’s view without a real sign-in.
The design point is where that override lives.
import 'server-only';
import { asc, eq } from 'drizzle-orm';import { cookies } from 'next/headers';import { cache } from 'react';
import { ACTING_USER_COOKIE } from '@/app/(protected)/inspector/constants';import { db } from '@/db';import { member, organization } from '@/db/schema/auth';import { requireOrgUser } from '@/lib/auth';import type { Role } from '@/lib/auth/roles';
const isDev = process.env.NODE_ENV !== 'production';
type SwitchableOrg = { id: string; name: string };type SeededUser = { id: string; name: string; role: string };
type InspectorContext = { userId: string; orgId: string; orgName: string; role: Role; orgs: SwitchableOrg[]; members: SeededUser[];};
// Resolve the identity the inspector renders as. In production this is exactly the// session identity. In development, an `inspector-acting-user` cookie naming a seeded// user swaps the resolved identity/org/role to that user's active membership.const resolveActingIdentity = async (): Promise<{ userId: string; orgId: string; role: Role;}> => { const sessionContext = await requireOrgUser(); const base = { userId: sessionContext.user.id, orgId: sessionContext.orgId, role: sessionContext.role, };
if (!isDev) { return base; }
const jar = await cookies(); const actingUserId = jar.get(ACTING_USER_COOKIE)?.value; if (!actingUserId) { return base; }
const membership = await db.query.member.findFirst({ where: eq(member.userId, actingUserId), }); if (!membership) { return base; }
return { userId: actingUserId, orgId: membership.organizationId, role: membership.role as Role, };};
// `cache` dedupes the resolution across the page's Suspense-wrapped panels so they// all render against the same acting identity in one request.export const getInspectorContext = cache( async (): Promise<InspectorContext> => { const identity = await resolveActingIdentity();
const org = await db.query.organization.findFirst({ where: eq(organization.id, identity.orgId), });
const memberships = await db.query.member.findMany({ where: eq(member.userId, identity.userId), with: { organization: true }, }); const orgs = memberships.map((m) => ({ id: m.organization.id, name: m.organization.name, }));
const orgMembers = await db.query.member.findMany({ where: eq(member.organizationId, identity.orgId), with: { user: true }, orderBy: asc(member.createdAt), }); const members = orgMembers.map((m) => ({ id: m.userId, name: m.user?.name ?? m.userId, role: m.role, }));
return { userId: identity.userId, orgId: identity.orgId, orgName: org?.name ?? 'No active organization', role: identity.role, orgs, members, }; },);InspectorContext is the shape the banner and switchers consume: the acting identity, the orgs the user can switch between, and the org’s members for the acting-user dropdown.
import 'server-only';
import { asc, eq } from 'drizzle-orm';import { cookies } from 'next/headers';import { cache } from 'react';
import { ACTING_USER_COOKIE } from '@/app/(protected)/inspector/constants';import { db } from '@/db';import { member, organization } from '@/db/schema/auth';import { requireOrgUser } from '@/lib/auth';import type { Role } from '@/lib/auth/roles';
const isDev = process.env.NODE_ENV !== 'production';
type SwitchableOrg = { id: string; name: string };type SeededUser = { id: string; name: string; role: string };
type InspectorContext = { userId: string; orgId: string; orgName: string; role: Role; orgs: SwitchableOrg[]; members: SeededUser[];};
// Resolve the identity the inspector renders as. In production this is exactly the// session identity. In development, an `inspector-acting-user` cookie naming a seeded// user swaps the resolved identity/org/role to that user's active membership.const resolveActingIdentity = async (): Promise<{ userId: string; orgId: string; role: Role;}> => { const sessionContext = await requireOrgUser(); const base = { userId: sessionContext.user.id, orgId: sessionContext.orgId, role: sessionContext.role, };
if (!isDev) { return base; }
const jar = await cookies(); const actingUserId = jar.get(ACTING_USER_COOKIE)?.value; if (!actingUserId) { return base; }
const membership = await db.query.member.findFirst({ where: eq(member.userId, actingUserId), }); if (!membership) { return base; }
return { userId: actingUserId, orgId: membership.organizationId, role: membership.role as Role, };};
// `cache` dedupes the resolution across the page's Suspense-wrapped panels so they// all render against the same acting identity in one request.export const getInspectorContext = cache( async (): Promise<InspectorContext> => { const identity = await resolveActingIdentity();
const org = await db.query.organization.findFirst({ where: eq(organization.id, identity.orgId), });
const memberships = await db.query.member.findMany({ where: eq(member.userId, identity.userId), with: { organization: true }, }); const orgs = memberships.map((m) => ({ id: m.organization.id, name: m.organization.name, }));
const orgMembers = await db.query.member.findMany({ where: eq(member.organizationId, identity.orgId), with: { user: true }, orderBy: asc(member.createdAt), }); const members = orgMembers.map((m) => ({ id: m.userId, name: m.user?.name ?? m.userId, role: m.role, }));
return { userId: identity.userId, orgId: identity.orgId, orgName: org?.name ?? 'No active organization', role: identity.role, orgs, members, }; },);resolveActingIdentity starts from the real requireOrgUser result, which production returns unchanged — there is no override path below this point.
import 'server-only';
import { asc, eq } from 'drizzle-orm';import { cookies } from 'next/headers';import { cache } from 'react';
import { ACTING_USER_COOKIE } from '@/app/(protected)/inspector/constants';import { db } from '@/db';import { member, organization } from '@/db/schema/auth';import { requireOrgUser } from '@/lib/auth';import type { Role } from '@/lib/auth/roles';
const isDev = process.env.NODE_ENV !== 'production';
type SwitchableOrg = { id: string; name: string };type SeededUser = { id: string; name: string; role: string };
type InspectorContext = { userId: string; orgId: string; orgName: string; role: Role; orgs: SwitchableOrg[]; members: SeededUser[];};
// Resolve the identity the inspector renders as. In production this is exactly the// session identity. In development, an `inspector-acting-user` cookie naming a seeded// user swaps the resolved identity/org/role to that user's active membership.const resolveActingIdentity = async (): Promise<{ userId: string; orgId: string; role: Role;}> => { const sessionContext = await requireOrgUser(); const base = { userId: sessionContext.user.id, orgId: sessionContext.orgId, role: sessionContext.role, };
if (!isDev) { return base; }
const jar = await cookies(); const actingUserId = jar.get(ACTING_USER_COOKIE)?.value; if (!actingUserId) { return base; }
const membership = await db.query.member.findFirst({ where: eq(member.userId, actingUserId), }); if (!membership) { return base; }
return { userId: actingUserId, orgId: membership.organizationId, role: membership.role as Role, };};
// `cache` dedupes the resolution across the page's Suspense-wrapped panels so they// all render against the same acting identity in one request.export const getInspectorContext = cache( async (): Promise<InspectorContext> => { const identity = await resolveActingIdentity();
const org = await db.query.organization.findFirst({ where: eq(organization.id, identity.orgId), });
const memberships = await db.query.member.findMany({ where: eq(member.userId, identity.userId), with: { organization: true }, }); const orgs = memberships.map((m) => ({ id: m.organization.id, name: m.organization.name, }));
const orgMembers = await db.query.member.findMany({ where: eq(member.organizationId, identity.orgId), with: { user: true }, orderBy: asc(member.createdAt), }); const members = orgMembers.map((m) => ({ id: m.userId, name: m.user?.name ?? m.userId, role: m.role, }));
return { userId: identity.userId, orgId: identity.orgId, orgName: org?.name ?? 'No active organization', role: identity.role, orgs, members, }; },);The dev-only override lives here and nowhere else. If the inspector-acting-user cookie names a seeded user with a membership, it swaps the rendered identity to that user’s org and role. It never touches requireOrgUser, so a privileged action still derives its actor from the validated session: the cookie cannot spoof a mutation.
import 'server-only';
import { asc, eq } from 'drizzle-orm';import { cookies } from 'next/headers';import { cache } from 'react';
import { ACTING_USER_COOKIE } from '@/app/(protected)/inspector/constants';import { db } from '@/db';import { member, organization } from '@/db/schema/auth';import { requireOrgUser } from '@/lib/auth';import type { Role } from '@/lib/auth/roles';
const isDev = process.env.NODE_ENV !== 'production';
type SwitchableOrg = { id: string; name: string };type SeededUser = { id: string; name: string; role: string };
type InspectorContext = { userId: string; orgId: string; orgName: string; role: Role; orgs: SwitchableOrg[]; members: SeededUser[];};
// Resolve the identity the inspector renders as. In production this is exactly the// session identity. In development, an `inspector-acting-user` cookie naming a seeded// user swaps the resolved identity/org/role to that user's active membership.const resolveActingIdentity = async (): Promise<{ userId: string; orgId: string; role: Role;}> => { const sessionContext = await requireOrgUser(); const base = { userId: sessionContext.user.id, orgId: sessionContext.orgId, role: sessionContext.role, };
if (!isDev) { return base; }
const jar = await cookies(); const actingUserId = jar.get(ACTING_USER_COOKIE)?.value; if (!actingUserId) { return base; }
const membership = await db.query.member.findFirst({ where: eq(member.userId, actingUserId), }); if (!membership) { return base; }
return { userId: actingUserId, orgId: membership.organizationId, role: membership.role as Role, };};
// `cache` dedupes the resolution across the page's Suspense-wrapped panels so they// all render against the same acting identity in one request.export const getInspectorContext = cache( async (): Promise<InspectorContext> => { const identity = await resolveActingIdentity();
const org = await db.query.organization.findFirst({ where: eq(organization.id, identity.orgId), });
const memberships = await db.query.member.findMany({ where: eq(member.userId, identity.userId), with: { organization: true }, }); const orgs = memberships.map((m) => ({ id: m.organization.id, name: m.organization.name, }));
const orgMembers = await db.query.member.findMany({ where: eq(member.organizationId, identity.orgId), with: { user: true }, orderBy: asc(member.createdAt), }); const members = orgMembers.map((m) => ({ id: m.userId, name: m.user?.name ?? m.userId, role: m.role, }));
return { userId: identity.userId, orgId: identity.orgId, orgName: org?.name ?? 'No active organization', role: identity.role, orgs, members, }; },);getInspectorContext fans out the page’s data, wrapped in cache() like requireOrgUser so every Suspense panel reads one identity from one set of queries per request.
You can render the page as Carol the member to see her view, but the cookie cannot perform an admin mutation: a write like changeMemberRole resolves its actor through requireOrgUser, where the cookie has no say.
The provided carry-in
Section titled “The provided carry-in”Two pieces you read rather than write, so you trust the loop.
src/app/onboarding/create-org/page.tsx is where requireOrgUser sends a user with no org; it calls authClient.organization.create({ name, slug }), which the plugin makes active by default, then pushes to /dashboard.
src/app/(protected)/dashboard/org-switcher.tsx calls authClient.organization.setActive(...) then router.refresh(), so every Server Component re-reads requireOrgUser against the newly active org.
Create lands you in an org, switch moves you between them, and the gate reads whichever one is active.
Canonical reference for the plugin you register here: additionalFields, activeOrganizationId on the session, and setActive.
The generate command behind pnpm auth:generate, and how it writes the Drizzle schema for you.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 2Six tests cover the two tested requirements: roleAtLeast’s ordering and requireOrgUser’s resolve-or-redirect behavior.
A passing run is all green:
✓ req 3 — roleAtLeast orders the three roles (3 tests) ✓ req 4 — requireOrgUser resolves or redirects the active-org context (3 tests)
Test Files 1 passed (1) Tests 6 passed (6)The tests can’t reach the schema, the seeded data, or the rendered UI. Confirm those by hand:
pnpm db:migrate, pnpm db:studio shows the organization, member, and invitation tables and the session.activeOrganizationId column./inspector renders the active-org banner with Alice’s owner role for Acme, and the four seeded users appear in the acting-user switcher./onboarding/create-org, and submitting redirects to /dashboard with the new org active.The Members and Pending panels and the Audit log tail show empty states for now; you build the audit log infrastructure next.