Skip to content
Chapter 59Lesson 2

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.

The /inspector surface this lesson resolves: the active-org banner shows a real name and role (Acme, owner), and the org and acting-user switchers re-render it against live membership. The Members, Invite, Pending, and Audit log panels come online in later lessons.

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.

The 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.
A newly created session has 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.
The inspector’s active-org banner renders the acting user’s org name and role; switching acting users via the dev cookie re-renders the banner with the new identity.
Switching the active org via the org switcher refreshes the layout and the banner shows the new org.
A fresh signup with no orgs lands on /onboarding/create-org; submitting creates the org and redirects to /dashboard with the new org active.

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

Start with src/lib/auth/roles.ts, the smallest piece, since everything else references it. It defines the three roles and one comparison:

src/lib/auth/roles.ts
// 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.

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.

1 / 1

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.

src/lib/auth.ts — pickInitialActiveOrg
// 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:

src/lib/auth.ts — databaseHooks
// 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.

Two configs is a real maintenance cost: change the plugin’s table shape in both, then regenerate.

  1. pnpm auth:generate — the CLI reads auth-schema.config.ts and rewrites src/db/schema/auth.ts with the new tables and columns.
  2. pnpm db:generate — Drizzle Kit diffs the schema and emits a SQL migration.
  3. pnpm db:migrate — applies the migration to your local Postgres.
  4. Commit the generated schema/auth.ts and 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:

src/db/schema/auth.ts (generated — diff shape)
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.

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.

1 / 1

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.

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.

1 / 1

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.

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.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 2

Six 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:

After 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.
Switching acting users re-renders the banner with the new identity; switching orgs refreshes the layout and the banner shows the new org.
A fresh signup with no orgs lands on /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.