BETTER-CONVEX

Plugins

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

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 {
  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)

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

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

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.orm.query.customRole.findFirst({
        where: { name: role, organizationId },
      });

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

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.orm.query.organization.findFirst({
      where: { 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
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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 orgORMNo
Delete orgBetter Auth APIYes
List orgsORMNo
Check slugORMNo
Invite memberBetter Auth APIYes
Accept inviteBetter Auth APIYes
Reject inviteBetter Auth APIYes
Cancel inviteBetter Auth APIYes
List user invitesORMNo
Add memberBetter Auth APIYes
Update roleORMNo
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