Organizations
Multi-tenant organization features with Better Auth and Convex.
In this guide, we'll explore multi-tenant organization features with better-convex. You'll learn to set up the organization plugin, define roles and permissions, manage members and invitations, and implement team functionality.
Overview
Multi-tenant organization system with Better Auth's organization plugin:
| Feature | Description |
|---|---|
| Organizations | Create, update, delete workspaces |
| Members | Role-based access within orgs |
| Invitations | Email invites with expiration |
| Teams | Group members within orgs |
| Access control | Custom permissions per role |
| Hooks | Lifecycle events for all operations |
Let's set up organizations step by step.
Prerequisites
Ensure you have Auth Server set up before adding organizations.
1. Server Configuration
Add the organization plugin to your auth options:
import { organization } from 'better-auth/plugins';
import type { ActionCtx } from './_generated/server';
export const getAuthOptions = (ctx: GenericCtx) =>
({
// ... existing config
plugins: [
convex({ authConfig, jwks: process.env.JWKS }),
admin(),
organization({
ac,
roles,
allowUserToCreateOrganization: true,
organizationLimit: 5,
membershipLimit: 100,
creatorRole: 'owner',
invitationExpiresIn: 48 * 60 * 60, // 48 hours
teams: {
enabled: true,
maximumTeams: 10,
},
sendInvitationEmail: async (data) => {
await (ctx as ActionCtx).scheduler.runAfter(
0,
internal.email.sendOrganizationInviteEmail,
{
acceptUrl: `${process.env.SITE_URL!}/w/${data.organization.slug}?invite=${data.id}`,
invitationId: data.id,
inviterEmail: data.inviter.user.email,
inviterName: data.inviter.user.name || 'Team Admin',
organizationName: data.organization.name,
role: data.role,
to: data.email,
}
);
},
}),
],
}) satisfies BetterAuthOptions;2. Client Configuration
Add the organization client plugin:
import { organizationClient } from 'better-auth/client/plugins';
import { ac, roles } from '@convex/auth-shared';
export const authClient = createAuthClient({
// ... existing config
plugins: [
inferAdditionalFields<Auth>(),
convexClient(),
organizationClient({
ac,
roles,
teams: { enabled: true },
}),
],
});3. Schema
Add organization tables to your schema:
import {
convexTable,
defineSchema,
id,
index,
integer,
json,
text,
timestamp,
} from 'better-convex/orm';
export const organization = convexTable(
'organization',
{
name: text().notNull(),
slug: text().notNull(),
logo: text(),
createdAt: timestamp().notNull().defaultNow(),
metadata: json<Record<string, unknown>>(),
},
(t) => [index('slug').on(t.slug), index('name').on(t.name)]
);
export const member = convexTable(
'member',
{
organizationId: id('organization').notNull(),
userId: id('user').notNull(),
role: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
},
(t) => [
index('userId').on(t.userId),
index('organizationId_userId').on(t.organizationId, t.userId),
index('organizationId_role').on(t.organizationId, t.role),
]
);
export const invitation = convexTable(
'invitation',
{
organizationId: id('organization').notNull(),
inviterId: id('user').notNull(),
email: text().notNull(),
role: text(),
status: text().notNull(),
expiresAt: integer().notNull(),
createdAt: timestamp().notNull().defaultNow(),
},
(t) => [
index('email').on(t.email),
index('status').on(t.status),
index('email_organizationId_status').on(t.email, t.organizationId, t.status),
index('organizationId_status').on(t.organizationId, t.status),
]
);
// Add these fields to your existing session table
export const session = convexTable('session', {
// ... existing session fields
activeOrganizationId: id('organization'),
activeTeamId: id('team'),
});
export const tables = { organization, member, invitation, session };
export default defineSchema(tables, { strict: false });Teams (Optional)
import { convexTable, id, index, integer, text, timestamp } from 'better-convex/orm';
export const team = convexTable(
'team',
{
name: text().notNull(),
organizationId: id('organization').notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: integer(),
},
(t) => [index('organizationId').on(t.organizationId)]
);
export const teamMember = convexTable(
'teamMember',
{
teamId: id('team').notNull(),
userId: id('user').notNull(),
createdAt: timestamp(),
},
(t) => [index('teamId').on(t.teamId), index('userId').on(t.userId)]
);Additional Fields
Add custom fields to organization tables:
organization({
schema: {
organization: {
fields: {
description: v.optional(v.string()),
website: v.optional(v.string()),
industry: v.optional(v.string()),
size: v.optional(v.string()),
},
},
member: {
fields: {
title: v.optional(v.string()),
department: v.optional(v.string()),
},
},
invitation: {
fields: {
message: v.optional(v.string()),
},
},
},
}),Then update your schema to include these fields:
import { convexTable, text } from 'better-convex/orm';
export const organization = convexTable('organization', {
// ... existing fields
description: text(),
website: text(),
industry: text(),
size: text(),
});
export const member = convexTable('member', {
// ... existing fields
title: text(),
department: text(),
});
export const invitation = convexTable('invitation', {
// ... existing fields
message: text(),
});4. Access Control
Define roles and permissions using Better Auth's access control system.
Basic Setup
import { createAccessControl } from 'better-auth/plugins/access';
import {
defaultStatements,
memberAc,
ownerAc,
} from 'better-auth/plugins/organization/access';
const statement = {
...defaultStatements,
// Add custom resources as needed
} as const;
export const ac = createAccessControl(statement);
const member = ac.newRole({
...memberAc.statements,
});
const owner = ac.newRole({
...ownerAc.statements,
});
export const roles = { member, owner };Custom Permissions
Define custom resources and permissions beyond the defaults:
const statement = {
...defaultStatements,
// Custom resources
project: ['create', 'read', 'update', 'delete'],
billing: ['read', 'update'],
analytics: ['read'],
} as const;
export const ac = createAccessControl(statement);
// Custom roles with granular permissions
const viewer = ac.newRole({
project: ['read'],
analytics: ['read'],
});
const editor = ac.newRole({
...memberAc.statements,
project: ['create', 'read', 'update'],
analytics: ['read'],
});
const admin = ac.newRole({
...ownerAc.statements,
project: ['create', 'read', 'update', 'delete'],
billing: ['read', 'update'],
analytics: ['read'],
});
export const roles = { viewer, editor, admin };Check Role Permissions
// Check if a role has specific permission
const canEdit = ac.checkRolePermission({
role: 'editor',
permission: { project: ['update'] },
});Dynamic Access Control
For runtime role creation (e.g., user-defined roles):
organization({
ac: {
...ac,
resolveRole: async ({ role, organizationId }) => {
// Check if it's a built-in role
if (roles[role]) return roles[role];
// Fetch custom role from database
const customRole = await ctx.orm.query.customRole.findFirst({
where: { name: role, organizationId },
});
if (customRole) {
return ac.newRole(customRole.permissions);
}
return null;
},
},
}),Permission Helper
import { CRPCError } from 'better-convex/server';
import type { AuthCtx } from '../crpc';
export const hasPermission = async (
ctx: AuthCtx,
body: { permissions: Record<string, string[]> },
shouldThrow = true
) => {
const result = await ctx.auth.api.hasPermission({
body,
headers: ctx.auth.headers,
});
if (shouldThrow && !result.success) {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'Insufficient permissions',
});
}
return result.success;
};Check Permission (Client)
// Check permission before showing UI
const { data } = await authClient.organization.hasPermission({
permissions: { project: ['delete'] },
});
if (data?.success) {
// Show delete button
}Organization Functions
Pattern: Use Better Auth API for multi-table operations (create, delete, invitations). Use ctx.orm for simple reads/updates.
Function examples use the ORM via ctx.orm.
Check Slug Availability
Before creating an organization, validate the slug is unique:
export const checkSlug = authQuery
.input(z.object({ slug: z.string() }))
.output(z.object({ available: z.boolean() }))
.query(async ({ ctx, input }) => {
const existing = await ctx.orm.query.organization.findFirst({
where: { slug: input.slug },
});
return { available: !existing };
});const { data } = await authClient.organization.checkSlug({ slug: 'my-org' });
if (!data.available) {
// Show error: slug already taken
}List Organizations
export const listOrganizations = authQuery
.output(
z.object({
canCreateOrganization: z.boolean(),
organizations: z.array(
z.object({
id: z.string(),
createdAt: z.date(),
isPersonal: z.boolean(),
logo: z.string().nullish(),
name: z.string(),
plan: z.string(),
slug: z.string(),
})
),
})
)
.query(async ({ ctx }) => {
const orgs = await listUserOrganizations(ctx, ctx.userId);
if (!orgs || orgs.length === 0) {
return { canCreateOrganization: true, organizations: [] };
}
const activeOrgId = ctx.user.activeOrganization?.id;
const organizations = orgs
.filter((org) => org.id !== activeOrgId)
.map((org) => ({
id: org.id,
createdAt: org.createdAt,
isPersonal: org.id === ctx.user.personalOrganizationId,
logo: org.logo || null,
name: org.name,
plan: DEFAULT_PLAN,
slug: org.slug,
}));
return { canCreateOrganization: true, organizations };
});Create Organization
export const createOrganization = authMutation
.meta({ rateLimit: 'organization/create' })
.input(z.object({ name: z.string().min(1).max(100) }))
.output(z.object({ id: z.string(), slug: z.string() }))
.mutation(async ({ ctx, input }) => {
let slug = input.name;
let attempt = 0;
while (attempt < 10) {
const existingOrg = await ctx.orm.query.organization.findFirst({
where: { slug },
});
if (!existingOrg) break;
slug = `${slug}-${Math.random().toString(36).slice(2, 10)}`;
attempt++;
}
if (attempt >= 10) {
throw new CRPCError({
code: 'BAD_REQUEST',
message: 'Could not generate a unique slug. Please provide a custom slug.',
});
}
const org = await ctx.auth.api.createOrganization({
body: { monthlyCredits: 0, name: input.name, slug },
headers: ctx.auth.headers,
});
if (!org) {
throw new CRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to create organization',
});
}
await setActiveOrganizationHandler(ctx, { organizationId: org.id });
return { id: org.id, slug: org.slug };
});Update Organization
export const updateOrganization = authMutation
.meta({ rateLimit: 'organization/update' })
.input(
z.object({
organizationId: z.string(),
logo: z.string().url().optional(),
name: z.string().min(1).max(100).optional(),
slug: z.string().optional(),
})
)
.output(z.null())
.mutation(async ({ ctx, input }) => {
await hasPermission(ctx, {
organizationId: input.organizationId,
permissions: { organization: ['update'] },
});
let slug = input.slug;
if (input.slug) {
if (input.organizationId === ctx.user.personalOrganizationId) {
slug = undefined;
} else {
slugSchema.parse(input.slug);
const existingOrg = await ctx.orm.query.organization.findFirst({
where: { slug: input.slug },
});
if (existingOrg && existingOrg.id !== input.organizationId) {
throw new CRPCError({ code: 'BAD_REQUEST', message: 'This slug is already taken' });
}
}
}
const data: { logo?: string; name?: string; slug?: string } = {};
if (input.logo !== undefined) data.logo = input.logo;
if (input.name !== undefined) data.name = input.name;
if (slug !== undefined) data.slug = slug;
await ctx.auth.api.updateOrganization({
body: { data, organizationId: input.organizationId },
headers: ctx.auth.headers,
});
return null;
});Delete Organization
export const deleteOrganization = authMutation
.input(z.object({ organizationId: z.string() }))
.output(z.null())
.mutation(async ({ ctx, input }) => {
await hasPermission(ctx, {
organizationId: input.organizationId,
permissions: { organization: ['delete'] },
});
if (input.organizationId === ctx.user.personalOrganizationId) {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'Personal organizations can be deleted only by deleting your account.',
});
}
if (input.organizationId === ctx.user.activeOrganization?.id) {
await setActiveOrganizationHandler(ctx, {
organizationId: ctx.user.personalOrganizationId!,
});
}
await ctx.auth.api.deleteOrganization({
body: { organizationId: input.organizationId },
headers: ctx.auth.headers,
});
return null;
});Set Active Organization
export const setActiveOrganization = authMutation
.meta({ rateLimit: 'organization/setActive' })
.input(z.object({ organizationId: z.string() }))
.output(z.null())
.mutation(async ({ ctx, input }) => setActiveOrganizationHandler(ctx, input));Invitation Functions
Send Invitation
export const inviteMember = authMutation
.meta({ rateLimit: 'organization/invite' })
.input(
z.object({
email: z.string().email(),
organizationId: z.string(),
role: z.enum(['owner', 'member']),
})
)
.output(z.null())
.mutation(async ({ ctx, input }) => {
await hasPermission(ctx, {
organizationId: input.organizationId,
permissions: { invitation: ['create'] },
});
const members = await ctx.orm.query.member.findMany({
where: { organizationId: input.organizationId },
limit: DEFAULT_LIST_LIMIT,
});
const pendingInvitations = await ctx.orm.query.invitation.findMany({
where: { organizationId: input.organizationId, status: 'pending' },
limit: DEFAULT_LIST_LIMIT,
});
if (members.length + pendingInvitations.length >= MEMBER_LIMIT) {
throw new CRPCError({
code: 'FORBIDDEN',
message: `Organization member limit reached. Maximum ${MEMBER_LIMIT} members allowed.`,
});
}
const existingInvitations = await ctx.orm.query.invitation.findMany({
where: { email: input.email, organizationId: input.organizationId, status: 'pending' },
limit: DEFAULT_LIST_LIMIT,
});
for (const invitation of existingInvitations) {
await ctx.orm
.update(invitationTable)
.set({ status: 'canceled' })
.where(eq(invitationTable.id, invitation.id));
}
await ctx.auth.api.createInvitation({
body: { email: input.email, organizationId: input.organizationId, role: input.role },
headers: ctx.auth.headers,
});
return null;
});Accept Invitation
export const acceptInvitation = authMutation
.input(z.object({ invitationId: z.string() }))
.output(z.null())
.mutation(async ({ ctx, input }) => {
const invitation = await ctx.orm.query.invitation
.findFirstOrThrow({
where: { id: input.invitationId, email: ctx.user.email },
})
.catch(() => {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'This invitation is not found for your email address',
});
});
if (invitation.status !== 'pending') {
throw new CRPCError({
code: 'BAD_REQUEST',
message: 'This invitation has already been processed',
});
}
await ctx.auth.api.acceptInvitation({
body: { invitationId: input.invitationId },
headers: ctx.auth.headers,
});
return null;
});Reject Invitation
export const rejectInvitation = authMutation
.meta({ rateLimit: 'organization/rejectInvite' })
.input(z.object({ invitationId: z.string() }))
.output(z.null())
.mutation(async ({ ctx, input }) => {
const invitation = await ctx.orm.query.invitation
.findFirstOrThrow({
where: { id: input.invitationId, email: ctx.user.email },
})
.catch(() => {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'This invitation is not found for your email address',
});
});
if (invitation.status !== 'pending') {
throw new CRPCError({
code: 'BAD_REQUEST',
message: 'This invitation has already been processed',
});
}
await ctx.auth.api.rejectInvitation({
body: { invitationId: input.invitationId },
headers: ctx.auth.headers,
});
return null;
});Cancel Invitation
export const cancelInvitation = authMutation
.meta({ rateLimit: 'organization/cancelInvite' })
.input(z.object({ invitationId: z.string() }))
.output(z.null())
.mutation(async ({ ctx, input }) => {
const invitation = await ctx.orm.query.invitation.findFirstOrThrow({
where: { id: input.invitationId },
});
await hasPermission(ctx, {
organizationId: invitation.organizationId,
permissions: { invitation: ['cancel'] },
});
try {
await ctx.auth.api.cancelInvitation({
body: { invitationId: input.invitationId },
headers: ctx.auth.headers,
});
} catch (error) {
if (error instanceof Error && error.message?.includes('not found')) {
throw new CRPCError({
code: 'NOT_FOUND',
message: 'Invitation not found or already processed',
});
}
throw new CRPCError({
code: 'BAD_REQUEST',
message: `Failed to cancel invitation: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
return null;
});List User Invitations
Get invitations sent to the current user:
export const listUserInvitations = authQuery
.output(
z.array(
z.object({
id: z.string(),
expiresAt: z.date(),
inviterName: z.string().nullable(),
organizationName: z.string(),
organizationSlug: z.string(),
role: z.string(),
})
)
)
.query(async ({ ctx }) => {
const invitations = await ctx.orm.query.invitation.findMany({
where: { email: ctx.user.email, status: 'pending' },
limit: DEFAULT_LIST_LIMIT,
columns: { id: true, expiresAt: true, organizationId: true, inviterId: true, role: true },
with: {
organization: { columns: { name: true, slug: true } },
inviter: { columns: { name: true } },
},
});
return invitations.map((inv) => {
const org = inv.organization;
if (!org) {
throw new CRPCError({ code: 'NOT_FOUND', message: 'Organization not found' });
}
return {
id: inv.id,
expiresAt: inv.expiresAt,
inviterName: inv.inviter?.name ?? null,
organizationName: org.name,
organizationSlug: org.slug,
role: inv.role || 'member',
};
});
});List Pending Invitations
export const listPendingInvitations = authQuery
.input(z.object({ slug: z.string() }))
.output(
z.array(
z.object({
id: z.string(),
createdAt: z.date(),
email: z.string(),
expiresAt: z.date(),
organizationId: z.string(),
role: z.string(),
status: z.string(),
})
)
)
.query(async ({ ctx, input }) => {
const org = await ctx.orm.query.organization.findFirst({
where: { slug: input.slug },
});
if (!org) return [];
const canManageInvites = await hasPermission(
ctx,
{ organizationId: org.id, permissions: { invitation: ['create'] } },
false
);
if (!canManageInvites) return [];
const invitations = await ctx.orm.query.invitation.findMany({
where: { organizationId: org.id, status: 'pending' },
limit: DEFAULT_LIST_LIMIT,
columns: {
id: true,
createdAt: true,
email: true,
expiresAt: true,
organizationId: true,
role: true,
status: true,
},
});
return invitations.map((invitation) => ({
id: invitation.id,
createdAt: invitation.createdAt,
email: invitation.email,
expiresAt: invitation.expiresAt,
organizationId: invitation.organizationId,
role: invitation.role || 'member',
status: invitation.status,
}));
});Member Functions
Get Active Member
Get the current user's membership in the active organization:
export const getActiveMember = authQuery
.output(
z
.object({
id: z.string(),
createdAt: z.date(),
role: z.string(),
})
.nullable()
)
.query(async ({ ctx }) => {
if (!ctx.user.activeOrganization) return null;
const member = await ctx.orm.query.member.findFirst({
where: {
organizationId: ctx.user.activeOrganization!.id,
userId: ctx.userId,
},
});
if (!member) return null;
return {
id: member.id,
createdAt: member.createdAt,
role: member.role,
};
});Add Member Directly
Add a member without invitation (admin use):
export const addMember = authMutation
.meta({ rateLimit: 'organization/addMember' })
.input(
z.object({
role: z.enum(['owner', 'member']),
userId: z.string(),
})
)
.output(z.null())
.mutation(async ({ ctx, input }) => {
await hasPermission(ctx, { permissions: { member: ['create'] } });
await ctx.auth.api.addMember({
body: {
userId: input.userId,
organizationId: ctx.user.activeOrganization!.id,
role: input.role,
},
headers: ctx.auth.headers,
});
return null;
});List Members
export const listMembers = authQuery
.input(z.object({ slug: z.string() }))
.output(
z.object({
currentUserRole: z.string().optional(),
isPersonal: z.boolean(),
members: z.array(
z.object({
id: z.string(),
createdAt: z.date(),
organizationId: z.string(),
role: z.string(),
user: z.object({
id: z.string(),
email: z.string(),
image: z.string().nullish(),
name: z.string().nullable(),
}),
userId: z.string(),
})
),
})
)
.query(async ({ ctx, input }) => {
const user = ctx.user;
const org = await ctx.orm.query.organization.findFirst({
where: { slug: input.slug },
});
if (!org) return { isPersonal: false, members: [] };
const currentMember = await ctx.orm.query.member.findFirst({
where: { organizationId: org.id, userId: ctx.userId },
});
if (!currentMember) {
return { isPersonal: org.id === user.personalOrganizationId, members: [] };
}
const members = await ctx.orm.query.member.findMany({
where: { organizationId: org.id },
limit: DEFAULT_LIST_LIMIT,
with: { user: true },
});
if (!members || members.length === 0) {
return { isPersonal: org.id === user.personalOrganizationId, members: [] };
}
const enrichedMembers = members
.map((member) => {
if (!member.user) return null;
return {
id: member.id,
createdAt: member.createdAt,
organizationId: org.id,
role: member.role,
user: {
id: member.user.id,
email: member.user.email,
image: member.user.image,
name: member.user.name,
},
userId: member.userId,
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
return {
currentUserRole: currentMember.role,
isPersonal: org.id === user.personalOrganizationId,
members: enrichedMembers,
};
});Update Member Role
export const updateMemberRole = authMutation
.meta({ rateLimit: 'organization/updateRole' })
.input(
z.object({
memberId: z.string(),
role: z.enum(['owner', 'member']),
})
)
.output(z.null())
.mutation(async ({ ctx, input }) => {
const member = await ctx.orm.query.member.findFirstOrThrow({
where: { id: input.memberId },
});
await hasPermission(ctx, {
organizationId: member.organizationId,
permissions: { member: ['update'] },
});
await ctx.auth.api.updateMemberRole({
body: {
memberId: input.memberId,
organizationId: member.organizationId,
role: input.role,
},
headers: ctx.auth.headers,
});
return null;
});Remove Member
export const removeMember = authMutation
.meta({ rateLimit: 'organization/removeMember' })
.input(z.object({ memberId: z.string() }))
.output(z.null())
.mutation(async ({ ctx, input }) => {
const member = await ctx.orm.query.member.findFirstOrThrow({
where: { id: input.memberId },
});
await hasPermission(ctx, {
organizationId: member.organizationId,
permissions: { member: ['delete'] },
});
await ctx.auth.api.removeMember({
body: {
memberIdOrEmail: input.memberId,
organizationId: member.organizationId,
},
headers: ctx.auth.headers,
});
return null;
});Leave Organization
export const leaveOrganization = authMutation
.meta({ rateLimit: 'organization/leave' })
.input(z.object({ organizationId: z.string() }))
.output(z.null())
.mutation(async ({ ctx, input }) => {
if (input.organizationId === ctx.user.personalOrganizationId) {
throw new CRPCError({
code: 'BAD_REQUEST',
message:
'You cannot leave your personal organization. Personal organizations are required for your account.',
});
}
const currentMember = await ctx.orm.query.member
.findFirstOrThrow({
where: { organizationId: input.organizationId, userId: ctx.userId },
})
.catch(() => {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'You are not a member of this organization',
});
});
if (currentMember.role === 'owner') {
const owners = await ctx.orm.query.member.findMany({
where: { organizationId: input.organizationId, role: 'owner' },
limit: 2,
});
if (owners.length <= 1) {
throw new CRPCError({
code: 'FORBIDDEN',
message:
'Cannot leave organization as the only owner. Transfer ownership or add another owner first.',
});
}
}
await ctx.auth.api.leaveOrganization({
body: { organizationId: input.organizationId },
headers: ctx.auth.headers,
});
if (input.organizationId === ctx.user.activeOrganization?.id) {
await setActiveOrganizationHandler(ctx, {
organizationId: ctx.user.personalOrganizationId!,
});
}
return null;
});Teams
Teams allow grouping members within an organization.
No dedicated team wrappers are defined in example/convex/functions/organization.ts yet. Use Better Auth team APIs directly where needed.
Team Operations
// List teams
const teams = await ctx.auth.api.listTeams({
query: { organizationId: ctx.user.activeOrganization!.id },
headers: ctx.auth.headers,
});
// Add member to team
await ctx.auth.api.addTeamMember({
body: { teamId, userId },
headers: ctx.auth.headers,
});
// Remove member from team
await ctx.auth.api.removeTeamMember({
body: { teamId, userId },
headers: ctx.auth.headers,
});
// List team members
const members = await ctx.auth.api.listTeamMembers({
body: { teamId },
headers: ctx.auth.headers,
});Hooks
Hooks allow custom logic during organization lifecycle events.
Organization Hooks
organization({
// Organization creation
organizationCreation: {
beforeCreate: async ({ organization, user }) => {
// Validate or modify before creation
return { data: organization };
},
afterCreate: async ({ organization, member, user }) => {
// Setup default resources, create initial data
},
},
// Organization deletion
organizationDeletion: {
beforeDelete: async (data) => {
// Cleanup related resources before deletion
},
afterDelete: async (data) => {
// Post-deletion cleanup
},
},
}),Member Hooks
organization({
membershipManagement: {
beforeAddMember: async ({ organization, member, user }) => {
// Validate member addition
return { data: member };
},
afterAddMember: async ({ organization, member, user }) => {
// Post-addition logic (notifications, etc.)
},
beforeRemoveMember: async ({ organization, member, user }) => {
// Cleanup before removal
},
afterRemoveMember: async ({ organization, member, user }) => {
// Post-removal logic
},
beforeUpdateRole: async ({ organization, member, role }) => {
return { data: { role } };
},
afterUpdateRole: async ({ organization, member, role }) => {
// Role change notifications
},
},
}),Invitation Hooks
organization({
invitationManagement: {
beforeCreateInvitation: async ({ invitation, organization, inviter }) => {
return { data: invitation };
},
afterCreateInvitation: async ({ invitation, organization, inviter }) => {
// Send custom notification
},
beforeAcceptInvitation: async ({ invitation, user }) => {
return { data: invitation };
},
afterAcceptInvitation: async ({ invitation, member, user }) => {
// Welcome logic
},
},
}),Team Hooks
organization({
teamManagement: {
beforeCreateTeam: async ({ team, organization }) => {
return { data: team };
},
afterCreateTeam: async ({ team, organization }) => {},
beforeAddTeamMember: async ({ team, user }) => {
return { data: { team, user } };
},
afterAddTeamMember: async ({ team, user }) => {},
},
}),Client Usage
React Hooks
import { authClient } from '@/lib/convex/auth-client';
function OrgSwitcher() {
// Active organization (reactive)
const { data: activeOrg } = authClient.useActiveOrganization();
// List organizations (reactive)
const { data: orgs } = authClient.useListOrganizations();
// Set active organization
const handleSwitch = (orgId: string) => {
authClient.organization.setActive({ organizationId: orgId });
};
// Create organization
const handleCreate = () => {
authClient.organization.create({ name: 'New Org', slug: 'new-org' });
};
return (
<select onChange={(e) => handleSwitch(e.target.value)}>
{orgs?.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
);
}Get Full Organization
// Get full organization details (non-reactive)
const { data: fullOrg } = await authClient.organization.getFullOrganization({
query: { organizationId: orgId },
});
// Returns: organization + members + invitationsInvitation Operations
// Get user's pending invitations
const { data: invitations } = await authClient.organization.listInvitations();
// Accept invitation
await authClient.organization.acceptInvitation({
invitationId: 'inv_123',
});
// Reject invitation
await authClient.organization.rejectInvitation({
invitationId: 'inv_123',
});
// Cancel invitation (as org admin)
await authClient.organization.cancelInvitation({
invitationId: 'inv_123',
});Member Operations
// Get active member info
const { data: member } = await authClient.organization.getActiveMember();
// Leave organization
await authClient.organization.leave();
// Remove member (as admin)
await authClient.organization.removeMember({
memberIdOrEmail: 'member_123',
});
// Update member role
await authClient.organization.updateMemberRole({
memberId: 'member_123',
role: 'admin',
});Permission Check
// Check permission before showing UI
const { data } = await authClient.organization.hasPermission({
permissions: { organization: ['delete'] },
});
if (data?.success) {
// Show delete button
}Team Operations (Client)
// List teams
const { data: teams } = await authClient.organization.listTeams();
// Create team
await authClient.organization.createTeam({
name: 'Engineering',
});
// Set active team (stored in session)
await authClient.organization.setActiveTeam({
teamId: 'team_123',
});API Reference
| Operation | Method | Multi-table |
|---|---|---|
| Create org | Better Auth API | Yes |
| Update org | ORM | No |
| Delete org | Better Auth API | Yes |
| List orgs | ORM | No |
| Check slug | ORM | No |
| Invite member | Better Auth API | Yes |
| Accept invite | Better Auth API | Yes |
| Reject invite | Better Auth API | Yes |
| Cancel invite | Better Auth API | Yes |
| List user invites | ORM | No |
| Add member | Better Auth API | Yes |
| Update role | ORM | No |
| Remove member | Better Auth API | Yes |
| Leave org | Better Auth API | Yes |
| Create team | Better Auth API | Yes |
| Add team member | Better Auth API | Yes |
| Remove team member | Better Auth API | Yes |
Use Better Auth API for operations that modify multiple tables. Use direct table operations for simple single-table reads/updates.