Organizations
Multi-tenant organization features with Better Auth and Convex.
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 |
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';
import { defineAuth } from './generated/auth';
export default defineAuth((ctx) => ({
// ... 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) => {
const inviterName = data.inviter.user.name || 'Team Admin';
const organizationName = data.organization.name;
const roleSuffix = data.role ? ` as ${data.role}` : '';
const acceptUrl = `${process.env.SITE_URL!}/w/${data.organization.slug}?invite=${data.id}`;
await (ctx as ActionCtx).scheduler.runAfter(
0,
internal.plugins.email.sendTemplatedEmail,
{
to: data.email,
subject: `${inviterName} invited you to join ${organizationName}`,
title: `Invitation to join ${organizationName}`,
body: `${inviterName} (${data.inviter.user.email}) invited you to join ${organizationName}${roleSuffix}.`,
ctaLabel: 'Accept invitation',
ctaUrl: acceptUrl,
}
);
},
}),
],
}));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 'kitcn/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 'kitcn/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 'kitcn/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 'kitcn/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 };
});Build additional organization, invitation, and member functions following this pattern. The key conventions:
- Use
ctx.auth.api.*for multi-table operations (create, delete, invitations, member management) - Use
ctx.ormfor simple reads and updates - Check permissions with
hasPermission(ctx, { organizationId, permissions: { resource: ['action'] } }) - Use rate limiting via
.meta({ ratelimit: 'organization/action' })
See the example app for complete CRUD implementations (update, delete, invitations, member management, leave with owner transfer, etc.).
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,
});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',
});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 }) => {},
},
}),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.