BETTER-CONVEX

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:

FeatureDescription
OrganizationsCreate, update, delete workspaces
MembersRole-based access within orgs
InvitationsEmail invites with expiration
TeamsGroup members within orgs
Access controlCustom permissions per role
HooksLifecycle 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:

convex/functions/auth.ts
import { organization } from 'better-auth/plugins';
import type { ActionCtx } from './_generated/server';

export const createAuthOptions = (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:

src/lib/convex/auth-client.ts
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:

convex/functions/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  // ... auth tables

  organization: defineTable({
    name: v.string(),
    slug: v.string(),
    logo: v.optional(v.string()),
    createdAt: v.number(),
    metadata: v.optional(v.string()),
  })
    .index('slug', ['slug'])
    .index('name', ['name']),

  member: defineTable({
    organizationId: v.id('organization'),
    userId: v.id('user'),
    role: v.string(),
    createdAt: v.number(),
  })
    .index('userId', ['userId'])
    .index('organizationId_userId', ['organizationId', 'userId'])
    .index('organizationId_role', ['organizationId', 'role']),

  invitation: defineTable({
    organizationId: v.id('organization'),
    inviterId: v.id('user'),
    email: v.string(),
    role: v.optional(v.string()),
    status: v.string(),
    expiresAt: v.number(),
    createdAt: v.number(),
  })
    .index('email', ['email'])
    .index('status', ['status'])
    .index('email_organizationId_status', ['email', 'organizationId', 'status'])
    .index('organizationId_status', ['organizationId', 'status']),

  // Session needs activeOrganizationId
  session: defineTable({
    // ... existing session fields
    activeOrganizationId: v.optional(v.string()),
    activeTeamId: v.optional(v.string()),
  }),
});
convex/functions/schema.ts
import { v } from 'convex/values';
import { defineEnt, defineEntSchema } from 'convex-ents';

const schema = defineEntSchema({
  // ... auth tables

  user: defineEnt({
    // ... existing fields
  })
    .edges('members', { to: 'member', ref: 'userId' })
    .edges('invitations', { to: 'invitation', ref: 'inviterId' }),

  organization: defineEnt({
    logo: v.optional(v.string()),
    createdAt: v.number(),
    metadata: v.optional(v.string()),
  })
    .field('slug', v.string(), { unique: true })
    .field('name', v.string(), { index: true })
    .edges('members', { to: 'member', ref: true })
    .edges('invitations', { to: 'invitation', ref: true }),

  member: defineEnt({
    createdAt: v.number(),
  })
    .field('role', v.string(), { index: true })
    .edge('organization', { to: 'organization', field: 'organizationId' })
    .edge('user', { to: 'user', field: 'userId' })
    .index('organizationId_userId', ['organizationId', 'userId'])
    .index('organizationId_role', ['organizationId', 'role']),

  invitation: defineEnt({
    role: v.optional(v.string()),
    expiresAt: v.number(),
    createdAt: v.number(),
  })
    .field('email', v.string(), { index: true })
    .field('status', v.string(), { index: true })
    .edge('organization', { to: 'organization', field: 'organizationId' })
    .edge('inviter', { to: 'user', field: 'inviterId' })
    .index('email_organizationId_status', ['email', 'organizationId', 'status'])
    .index('organizationId_status', ['organizationId', 'status']),

  // Session needs activeOrganizationId
  session: defineEnt({
    // ... existing session fields
    activeOrganizationId: v.optional(v.string()),
    activeTeamId: v.optional(v.string()),
  }),
});

Teams (Optional)

convex/functions/schema.ts
team: defineTable({
  name: v.string(),
  organizationId: v.id('organization'),
  createdAt: v.number(),
  updatedAt: v.optional(v.number()),
})
  .index('organizationId', ['organizationId']),

teamMember: defineTable({
  teamId: v.id('team'),
  userId: v.id('user'),
  createdAt: v.optional(v.number()),
})
  .index('teamId', ['teamId'])
  .index('userId', ['userId']),
convex/functions/schema.ts
team: defineEnt({
  name: v.string(),
  createdAt: v.number(),
  updatedAt: v.optional(v.number()),
})
  .edge('organization', { to: 'organization', field: 'organizationId' })
  .index('organizationId', ['organizationId']),

teamMember: defineEnt({
  createdAt: v.optional(v.number()),
})
  .edge('team', { to: 'team', field: 'teamId' })
  .edge('user', { to: 'user', field: 'userId' })
  .index('userId', ['userId']),

Additional Fields

Add custom fields to organization tables:

convex/functions/auth.ts
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:

convex/functions/schema.ts
organization: defineTable({
  // ... existing fields
  description: v.optional(v.string()),
  website: v.optional(v.string()),
  industry: v.optional(v.string()),
  size: v.optional(v.string()),
}),

member: defineTable({
  // ... existing fields
  title: v.optional(v.string()),
  department: v.optional(v.string()),
}),
convex/functions/schema.ts
organization: defineEnt({
  // ... existing fields
  description: v.optional(v.string()),
  website: v.optional(v.string()),
  industry: v.optional(v.string()),
  size: v.optional(v.string()),
}),

member: defineEnt({
  // ... existing fields
  title: v.optional(v.string()),
  department: v.optional(v.string()),
}),

4. Access Control

Define roles and permissions using Better Auth's access control system.

Basic Setup

convex/shared/auth-shared.ts
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:

convex/shared/auth-shared.ts
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):

convex/functions/auth.ts
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.table('customRole')
        .filter((q) => q.eq(q.field('name'), role))
        .first();

      if (customRole) {
        return ac.newRole(customRole.permissions);
      }

      return null;
    },
  },
}),

Permission Helper

convex/lib/auth/auth-helpers.ts
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 direct table operations for simple reads/updates.

Function examples use ctx.table (Convex Ents). For ctx.db, replace edge traversal with manual joins. See Ents for migration patterns.

Check Slug Availability

Before creating an organization, validate the slug is unique:

convex/functions/organization.ts
export const checkSlug = authQuery
  .input(z.object({ slug: z.string() }))
  .output(z.object({ available: z.boolean() }))
  .query(async ({ ctx, input }) => {
    const existing = await ctx.table('organization').get('slug', input.slug);
    return { available: !existing };
  });
Client usage
const { data } = await authClient.organization.checkSlug({ slug: 'my-org' });
if (!data.available) {
  // Show error: slug already taken
}

List Organizations

convex/functions/organization.ts
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod';
import { asyncMap } from 'convex-helpers';
import { authQuery } from '../lib/crpc';

export const list = authQuery
  .output(
    z.object({
      organizations: z.array(
        z.object({
          id: zid('organization'),
          name: z.string(),
          slug: z.string(),
          logo: z.string().nullish(),
          isPersonal: z.boolean(),
        })
      ),
    })
  )
  .query(async ({ ctx }) => {
    const members = await ctx.table('member', 'userId', (q) =>
      q.eq('userId', ctx.userId)
    );

    const orgs = await asyncMap(members, async (member) => {
      const org = await member.edgeX('organization');
      return {
        id: org._id,
        name: org.name,
        slug: org.slug,
        logo: org.logo,
        isPersonal: org._id === ctx.user.personalOrganizationId,
      };
    });

    return { organizations: orgs };
  });

Create Organization

convex/functions/organization.ts
export const create = authMutation
  .input(
    z.object({
      name: z.string().min(1).max(100),
    })
  )
  .output(
    z.object({
      id: zid('organization'),
      slug: z.string(),
    })
  )
  .mutation(async ({ ctx, input }) => {
    const slug = generateSlug(input.name);

    // Use Better Auth API (handles org + member creation)
    const org = await ctx.auth.api.createOrganization({
      body: { name: input.name, slug },
      headers: ctx.auth.headers,
    });

    if (!org) {
      throw new CRPCError({
        code: 'INTERNAL_SERVER_ERROR',
        message: 'Failed to create organization',
      });
    }

    // Set as active
    await ctx.auth.api.setActiveOrganization({
      body: { organizationId: org.id },
      headers: ctx.auth.headers,
    });

    return { id: org.id, slug: org.slug };
  });

Update Organization

convex/functions/organization.ts
// Direct table patch (simpler for single-table updates)
export const update = authMutation
  .input(
    z.object({
      name: z.string().min(1).max(100).optional(),
      logo: z.string().url().optional(),
      slug: z.string().optional(),
    })
  )
  .output(z.null())
  .mutation(async ({ ctx, input }) => {
    await hasPermission(ctx, { permissions: { organization: ['update'] } });

    await ctx
      .table('organization')
      .getX(ctx.user.activeOrganization!.id)
      .patch({
        name: input.name,
        logo: input.logo,
        ...(input.slug ? { slug: input.slug } : {}),
      });

    return null;
  });

Delete Organization

convex/functions/organization.ts
// Use Better Auth API (handles cleanup)
export const remove = authMutation
  .output(z.null())
  .mutation(async ({ ctx }) => {
    await hasPermission(ctx, { permissions: { organization: ['delete'] } });

    const organizationId = ctx.user.activeOrganization!.id;

    // Prevent deletion of personal organizations
    if (organizationId === ctx.user.personalOrganizationId) {
      throw new CRPCError({
        code: 'FORBIDDEN',
        message: 'Personal organizations can only be deleted by deleting your account.',
      });
    }

    // Switch to personal org first
    await ctx.auth.api.setActiveOrganization({
      body: { organizationId: ctx.user.personalOrganizationId! },
      headers: ctx.auth.headers,
    });

    await ctx.auth.api.deleteOrganization({
      body: { organizationId },
      headers: ctx.auth.headers,
    });

    return null;
  });

Set Active Organization

convex/functions/organization.ts
export const setActive = authMutation
  .input(z.object({ organizationId: zid('organization') }))
  .output(z.null())
  .mutation(async ({ ctx, input }) => {
    await ctx.auth.api.setActiveOrganization({
      body: { organizationId: input.organizationId },
      headers: ctx.auth.headers,
    });

    return null;
  });

Invitation Functions

Send Invitation

convex/functions/organization.ts
export const inviteMember = authMutation
  .input(
    z.object({
      email: z.string().email(),
      role: z.enum(['owner', 'member']),
    })
  )
  .output(z.null())
  .mutation(async ({ ctx, input }) => {
    await hasPermission(ctx, { permissions: { invitation: ['create'] } });

    await ctx.auth.api.createInvitation({
      body: {
        email: input.email,
        role: input.role,
        organizationId: ctx.user.activeOrganization!.id,
      },
      headers: ctx.auth.headers,
    });

    return null;
  });

Accept Invitation

convex/functions/organization.ts
export const acceptInvitation = authMutation
  .input(z.object({ invitationId: zid('invitation') }))
  .output(z.null())
  .mutation(async ({ ctx, input }) => {
    const invitation = await ctx.table('invitation').get(input.invitationId);

    if (!invitation || invitation.email !== ctx.user.email) {
      throw new CRPCError({
        code: 'FORBIDDEN',
        message: 'Invitation not found',
      });
    }

    if (invitation.status !== 'pending') {
      throw new CRPCError({
        code: 'BAD_REQUEST',
        message: 'Invitation already processed',
      });
    }

    await ctx.auth.api.acceptInvitation({
      body: { invitationId: input.invitationId },
      headers: ctx.auth.headers,
    });

    return null;
  });

Reject Invitation

convex/functions/organization.ts
export const rejectInvitation = authMutation
  .input(z.object({ invitationId: zid('invitation') }))
  .output(z.null())
  .mutation(async ({ ctx, input }) => {
    const invitation = await ctx.table('invitation').get(input.invitationId);

    if (!invitation || invitation.email !== ctx.user.email) {
      throw new CRPCError({
        code: 'FORBIDDEN',
        message: 'Invitation not found',
      });
    }

    await ctx.auth.api.rejectInvitation({
      body: { invitationId: input.invitationId },
      headers: ctx.auth.headers,
    });

    return null;
  });

Cancel Invitation

convex/functions/organization.ts
export const cancelInvitation = authMutation
  .input(z.object({ invitationId: zid('invitation') }))
  .output(z.null())
  .mutation(async ({ ctx, input }) => {
    await hasPermission(ctx, { permissions: { invitation: ['cancel'] } });

    await ctx.auth.api.cancelInvitation({
      body: { invitationId: input.invitationId },
      headers: ctx.auth.headers,
    });

    return null;
  });

List User Invitations

Get invitations sent to the current user:

convex/functions/organization.ts
export const listUserInvitations = authQuery
  .output(
    z.array(
      z.object({
        id: zid('invitation'),
        organizationName: z.string(),
        organizationSlug: z.string(),
        role: z.string(),
        inviterName: z.string().nullable(),
        expiresAt: z.number(),
      })
    )
  )
  .query(async ({ ctx }) => {
    const invitations = await ctx
      .table('invitation', 'email', (q) => q.eq('email', ctx.user.email))
      .filter((q) => q.eq(q.field('status'), 'pending'));

    return asyncMap(invitations, async (inv) => {
      const org = await inv.edgeX('organization');
      const inviter = await inv.edgeX('inviter');
      return {
        id: inv._id,
        organizationName: org.name,
        organizationSlug: org.slug,
        role: inv.role || 'member',
        inviterName: inviter.name,
        expiresAt: inv.expiresAt,
      };
    });
  });

List Pending Invitations

convex/functions/organization.ts
export const listPendingInvitations = authQuery
  .input(z.object({ slug: z.string() }))
  .output(
    z.array(
      z.object({
        id: zid('invitation'),
        email: z.string(),
        role: z.string(),
        expiresAt: z.number(),
      })
    )
  )
  .query(async ({ ctx, input }) => {
    const org = await ctx.table('organization').get('slug', input.slug);
    if (!org || ctx.user.activeOrganization?.id !== org._id) return [];

    const invitations = await ctx
      .table('invitation', 'organizationId_status', (q) =>
        q.eq('organizationId', org._id).eq('status', 'pending')
      )
      .take(100);

    return invitations.map((inv) => ({
      id: inv._id,
      email: inv.email,
      role: inv.role || 'member',
      expiresAt: inv.expiresAt,
    }));
  });

Member Functions

Get Active Member

Get the current user's membership in the active organization:

convex/functions/organization.ts
export const getActiveMember = authQuery
  .output(
    z.object({
      id: zid('member'),
      role: z.string(),
      createdAt: z.number(),
    }).nullable()
  )
  .query(async ({ ctx }) => {
    if (!ctx.user.activeOrganization) return null;

    const member = await ctx
      .table('member', 'organizationId_userId', (q) =>
        q
          .eq('organizationId', ctx.user.activeOrganization!.id)
          .eq('userId', ctx.userId)
      )
      .first();

    if (!member) return null;

    return {
      id: member._id,
      role: member.role,
      createdAt: member.createdAt,
    };
  });

Add Member Directly

Add a member without invitation (admin use):

convex/functions/organization.ts
export const addMember = authMutation
  .input(
    z.object({
      userId: zid('user'),
      role: z.enum(['owner', 'member']),
    })
  )
  .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

convex/functions/organization.ts
export const listMembers = authQuery
  .input(z.object({ slug: z.string() }))
  .output(
    z.object({
      members: z.array(
        z.object({
          id: zid('member'),
          role: z.string(),
          user: z.object({
            id: zid('user'),
            name: z.string().nullable(),
            email: z.string(),
            image: z.string().nullish(),
          }),
        })
      ),
    })
  )
  .query(async ({ ctx, input }) => {
    const org = await ctx.table('organization').get('slug', input.slug);
    if (!org) return { members: [] };

    const members = await org.edge('members').take(100);

    const enrichedMembers = await Promise.all(
      members.map(async (member) => {
        const user = await member.edgeX('user');
        return {
          id: member._id,
          role: member.role,
          user: {
            id: user._id,
            name: user.name,
            email: user.email,
            image: user.image,
          },
        };
      })
    );

    return { members: enrichedMembers };
  });

Update Member Role

convex/functions/organization.ts
export const updateMemberRole = authMutation
  .input(
    z.object({
      memberId: zid('member'),
      role: z.enum(['owner', 'member']),
    })
  )
  .output(z.null())
  .mutation(async ({ ctx, input }) => {
    await hasPermission(ctx, { permissions: { member: ['update'] } });

    await ctx.table('member').getX(input.memberId).patch({ role: input.role });

    return null;
  });

Remove Member

convex/functions/organization.ts
export const removeMember = authMutation
  .input(z.object({ memberId: zid('member') }))
  .output(z.null())
  .mutation(async ({ ctx, input }) => {
    await hasPermission(ctx, { permissions: { member: ['delete'] } });

    await ctx.auth.api.removeMember({
      body: {
        memberIdOrEmail: input.memberId,
        organizationId: ctx.user.activeOrganization?.id,
      },
      headers: ctx.auth.headers,
    });

    return null;
  });

Leave Organization

convex/functions/organization.ts
export const leave = authMutation
  .output(z.null())
  .mutation(async ({ ctx }) => {
    // Prevent last owner from leaving
    if (ctx.user.activeOrganization?.role === 'owner') {
      const owners = await ctx
        .table('member', 'organizationId_role', (q) =>
          q
            .eq('organizationId', ctx.user.activeOrganization!.id)
            .eq('role', 'owner')
        )
        .take(2);

      if (owners.length <= 1) {
        throw new CRPCError({
          code: 'FORBIDDEN',
          message: 'Cannot leave as the only owner',
        });
      }
    }

    await ctx.auth.api.leaveOrganization({
      body: { organizationId: ctx.user.activeOrganization!.id },
      headers: ctx.auth.headers,
    });

    return null;
  });

Teams

Teams allow grouping members within an organization.

Create Team

convex/functions/organization.ts
export const createTeam = authMutation
  .input(z.object({ name: z.string().min(1).max(100) }))
  .output(zid('team'))
  .mutation(async ({ ctx, input }) => {
    const team = await ctx.auth.api.createTeam({
      body: {
        name: input.name,
        organizationId: ctx.user.activeOrganization!.id,
      },
      headers: ctx.auth.headers,
    });

    return team.id;
  });

Team Operations

convex/functions/organization.ts
// 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

convex/functions/auth.ts
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

convex/functions/auth.ts
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

convex/functions/auth.ts
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

convex/functions/auth.ts
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

src/components/org-switcher.tsx
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 + invitations

Invitation 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

OperationMethodMulti-table
Create orgBetter Auth APIYes
Update orgDirect tableNo
Delete orgBetter Auth APIYes
List orgsDirect tableNo
Check slugDirect tableNo
Invite memberBetter Auth APIYes
Accept inviteBetter Auth APIYes
Reject inviteBetter Auth APIYes
Cancel inviteBetter Auth APIYes
List user invitesDirect tableNo
Add memberBetter Auth APIYes
Update roleDirect tableNo
Remove memberBetter Auth APIYes
Leave orgBetter Auth APIYes
Create teamBetter Auth APIYes
Add team memberBetter Auth APIYes
Remove team memberBetter Auth APIYes

Use Better Auth API for operations that modify multiple tables. Use direct table operations for simple single-table reads/updates.

Next Steps

On this page