organization-best-practices

Multi-tenant organization setup with member management, role-based access control, and team support via Better Auth. Configure organizations with customizable creation rules, membership limits, and ownership constraints; creators automatically receive the owner role Manage members and invitations with email delivery, expiration windows, and shareable invitation URLs; support multiple roles per member Define custom roles and permissions with dynamic access control; check permissions server-side via hasPermission endpoint or client-side via checkRolePermission Create and manage teams within organizations with configurable member and team limits; set active teams to scope API calls Use lifecycle hooks (beforeCreate, afterCreate, beforeDelete) and schema customization to extend default behavior and integrate with external systems

INSTALLATION
npx skills add https://github.com/better-auth/skills --skill organization-best-practices
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Setup

  • Add organization() plugin to server config
  • Add organizationClient() plugin to client config
  • Run npx @better-auth/cli migrate
  • Verify: check that organization, member, invitation tables exist in your database
import { betterAuth } from "better-auth";

import { organization } from "better-auth/plugins";

export const auth = betterAuth({

plugins: [

organization({

allowUserToCreateOrganization: true,

organizationLimit: 5, // Max orgs per user

membershipLimit: 100, // Max members per org

}),

],

});

### Client-Side Setup

import { createAuthClient } from "better-auth/client";

import { organizationClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({

plugins: [organizationClient()],

});


## Creating Organizations

The creator is automatically assigned the `owner` role.

const createOrg = async () => {

const { data, error } = await authClient.organization.create({

name: "My Company",

slug: "my-company",

logo: "https://example.com/logo.png",

metadata: { plan: "pro" },

});

};


### Controlling Organization Creation

Restrict who can create organizations based on user attributes:

organization({

allowUserToCreateOrganization: async (user) => {

return user.emailVerified === true;

},

organizationLimit: async (user) => {

// Premium users get more organizations

return user.plan === "premium" ? 20 : 3;

},

});


### Creating Organizations on Behalf of Users

Administrators can create organizations for other users (server-side only):

await auth.api.createOrganization({

body: {

name: "Client Organization",

slug: "client-org",

userId: "user-id-who-will-be-owner", // userId is required

},

});


**Note**: The `userId` parameter cannot be used alongside session headers.

## Active Organizations

Stored in the session and scopes subsequent API calls. Set after user selects one.

const setActive = async (organizationId: string) => {

const { data, error } = await authClient.organization.setActive({

organizationId,

});

};


Many endpoints use the active organization when `organizationId` is not provided (`listMembers`, `listInvitations`, `inviteMember`, etc.).

Use `getFullOrganization()` to retrieve the active org with all members, invitations, and teams.

## Members

### Adding Members (Server-Side)

await auth.api.addMember({

body: {

userId: "user-id",

role: "member",

organizationId: "org-id",

},

});


For client-side member additions, use the invitation system instead.

### Assigning Multiple Roles

await auth.api.addMember({

body: {

userId: "user-id",

role: ["admin", "moderator"],

organizationId: "org-id",

},

});


### Removing Members

Use `removeMember({ memberIdOrEmail })`. The last owner cannot be removed — assign ownership to another member first.

### Updating Member Roles

Use `updateMemberRole({ memberId, role })`.

### Membership Limits

organization({

membershipLimit: async (user, organization) => {

if (organization.metadata?.plan === "enterprise") {

return 1000;

}

return 50;

},

});


## Invitations

### Setting Up Invitation Emails

import { betterAuth } from "better-auth";

import { organization } from "better-auth/plugins";

import { sendEmail } from "./email";

export const auth = betterAuth({

plugins: [

organization({

sendInvitationEmail: async (data) => {

const { email, organization, inviter, invitation } = data;

await sendEmail({

to: email,

subject: Join ${organization.name},

html:

<p>${inviter.user.name} invited you to join ${organization.name}</p>

<a href="https://yourapp.com/accept-invite?id=${invitation.id}">

Accept Invitation

</a>

,

});

},

}),

],

});


### Sending Invitations

await authClient.organization.inviteMember({

email: "newuser@example.com",

role: "member",

});


### Shareable Invitation URLs

const { data } = await authClient.organization.getInvitationURL({

email: "newuser@example.com",

role: "member",

callbackURL: "https://yourapp.com/dashboard",

});

// Share data.url via any channel


This endpoint does not call `sendInvitationEmail` — handle delivery yourself.

### Invitation Configuration

organization({

invitationExpiresIn: 60 60 24 * 7, // 7 days (default: 48 hours)

invitationLimit: 100, // Max pending invitations per org

cancelPendingInvitationsOnReInvite: true, // Cancel old invites when re-inviting

});


## Roles &#x26; Permissions

Default roles: `owner` (full access), `admin` (manage members/invitations/settings), `member` (basic access).

### Checking Permissions

const { data } = await authClient.organization.hasPermission({

permission: "member:write",

});

if (data?.hasPermission) {

// User can manage members

}


Use `checkRolePermission({ role, permissions })` for client-side UI rendering (static only). For dynamic access control, use the `hasPermission` endpoint.

## Teams

### Enabling Teams

import { organization } from "better-auth/plugins";

export const auth = betterAuth({

plugins: [

organization({

teams: {

enabled: true

}

}),

],

});


### Creating Teams

const { data } = await authClient.organization.createTeam({

name: "Engineering",

});


### Managing Team Members

Use `addTeamMember({ teamId, userId })` (member must be in org first) and `removeTeamMember({ teamId, userId })` (stays in org).

Set active team with `setActiveTeam({ teamId })`.

### Team Limits

organization({

teams: {

maximumTeams: 20, // Max teams per org

maximumMembersPerTeam: 50, // Max members per team

allowRemovingAllTeams: false, // Prevent removing last team

}

});


## Dynamic Access Control

### Enabling Dynamic Access Control

import { organization } from "better-auth/plugins";

import { dynamicAccessControl } from "@better-auth/organization/addons";

export const auth = betterAuth({

plugins: [

organization({

dynamicAccessControl: {

enabled: true

}

}),

],

});


### Creating Custom Roles

await authClient.organization.createRole({

role: "moderator",

permission: {

member: ["read"],

invitation: ["read"],

},

});


Use `updateRole({ roleId, permission })` and `deleteRole({ roleId })`. Pre-defined roles (owner, admin, member) cannot be deleted. Roles assigned to members cannot be deleted until reassigned.

## Lifecycle Hooks

Execute custom logic at various points in the organization lifecycle:

organization({

hooks: {

organization: {

beforeCreate: async ({ data, user }) => {

// Validate or modify data before creation

return {

data: {

...data,

metadata: { ...data.metadata, createdBy: user.id },

},

};

},

afterCreate: async ({ organization, member }) => {

// Post-creation logic (e.g., send welcome email, create default resources)

await createDefaultResources(organization.id);

},

beforeDelete: async ({ organization }) => {

// Cleanup before deletion

await archiveOrganizationData(organization.id);

},

},

member: {

afterCreate: async ({ member, organization }) => {

await notifyAdmins(organization.id, New member joined);

},

},

invitation: {

afterCreate: async ({ invitation, organization, inviter }) => {

await logInvitation(invitation);

},

},

},

});


## Schema Customization

Customize table names, field names, and add additional fields:

organization({

schema: {

organization: {

modelName: "workspace", // Rename table

fields: {

name: "workspaceName", // Rename fields

},

additionalFields: {

billingId: {

type: "string",

required: false,

},

},

},

member: {

additionalFields: {

department: {

type: "string",

required: false,

},

title: {

type: "string",

required: false,

},

},

},

},

});


## Security Considerations

### Owner Protection

- The last owner cannot be removed from an organization

- The last owner cannot leave the organization

- The owner role cannot be removed from the last owner

Always ensure ownership transfer before removing the current owner:

// Transfer ownership first

await authClient.organization.updateMemberRole({

memberId: "new-owner-member-id",

role: "owner",

});

// Then the previous owner can be demoted or removed


### Organization Deletion

Deleting an organization removes all associated data (members, invitations, teams). Prevent accidental deletion:

organization({

disableOrganizationDeletion: true, // Disable via config

});


Or implement soft delete via hooks:

organization({

hooks: {

organization: {

beforeDelete: async ({ organization }) => {

// Archive instead of delete

await archiveOrganization(organization.id);

throw new Error("Organization archived, not deleted");

},

},

},

});


### Invitation Security

- Invitations expire after 48 hours by default

- Only the invited email address can accept an invitation

- Pending invitations can be cancelled by organization admins

## Complete Configuration Example

import { betterAuth } from "better-auth";

import { organization } from "better-auth/plugins";

import { sendEmail } from "./email";

export const auth = betterAuth({

plugins: [

organization({

// Organization limits

allowUserToCreateOrganization: true,

organizationLimit: 10,

membershipLimit: 100,

creatorRole: "owner",

// Slugs

defaultOrganizationIdField: "slug",

// Invitations

invitationExpiresIn: 60 60 24 * 7, // 7 days

invitationLimit: 50,

sendInvitationEmail: async (data) => {

await sendEmail({

to: data.email,

subject: Join ${data.organization.name},

html: <a href="https://app.com/invite/${data.invitation.id}">Accept</a>,

});

},

// Hooks

hooks: {

organization: {

afterCreate: async ({ organization }) => {

console.log(Organization ${organization.name} created);

},

},

},

}),

],

});

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card