@four-leaves/better-auth-multi-tenancy
v1.0.0
Published
A Better Auth plugin for multi-tenancy with hierarchical tenant support
Downloads
93
Maintainers
Readme
better-auth-multi-tenancy
A Better Auth plugin that adds multi-tenancy support with hierarchical tenant structures, member management, and invitation flows.
Features
- Tenant management — create, read, update, delete, and transfer ownership of tenants
- Hierarchical tenants — nest tenants under parent tenants to model org structures
- Member management — add and remove users from tenants directly or via invitation
- Invitation system — invite users by email; accept, decline, or revoke invitations
- Lifecycle hooks — granular callbacks for every create, update, delete, and membership event
- Configurable limits — cap tenants per user, hierarchy depth, and child tenant count
- Full type safety — server and client plugins with TypeScript inference
- Database agnostic — works with any Better Auth adapter (SQLite, PostgreSQL, MySQL, …)
Installation
npm install @four-leaves/better-auth-multi-tenancySetup
1. Server
Add the plugin to your Better Auth configuration:
import { betterAuth } from 'better-auth'
import { multiTenancy } from '@four-leaves/better-auth-multi-tenancy'
export const auth = betterAuth({
// ... your existing config
plugins: [
multiTenancy({
// Optional: send invitation emails
onInvitationCreated: async (invitation, tenantName) => {
await sendEmail({
to: invitation.email,
subject: `You've been invited to ${tenantName}`,
body: `Accept your invitation at https://yourapp.com/invitations/${invitation.id}/accept`,
})
},
}),
],
})2. Database migration
Run the Better Auth migration to create the tenant, tenantMember, and tenantInvitation tables:
npx better-auth migrateOr generate the SQL without applying it:
npx better-auth generate3. Client
Add the client plugin to your auth client:
import { createAuthClient } from 'better-auth/client'
import { multiTenancyClient } from '@four-leaves/better-auth-multi-tenancy/client'
export const authClient = createAuthClient({
// ... your existing config
plugins: [multiTenancyClient()],
})All methods are available under authClient.multiTenancy.*.
API Reference
Tenants
| Method | HTTP | Path | Description |
| ------------------- | ---- | --------------------------------- | ------------------------------------------------- |
| createTenant | POST | /tenants | Create a new root tenant |
| listTenants | GET | /tenants | List tenants the current user is a member of |
| getTenant | GET | /tenants/:idOrSlug | Get a tenant by id or slug |
| updateTenant | POST | /tenants/:id | Update a tenant's name or slug (owner only) |
| transferOwnership | POST | /tenants/:id/transfer-ownership | Transfer ownership to another member (owner only) |
| deleteTenant | POST | /tenants/:id/delete | Delete a tenant (owner only) |
Create a tenant
const { data } = await authClient.multiTenancy.createTenant({
name: 'Acme Corp',
slug: 'acme-corp',
})Get a tenant by slug
const { data } = await authClient.multiTenancy.getTenant({
params: { idOrSlug: 'acme-corp' },
})Transfer ownership
The new owner must already be a member of the tenant.
await authClient.multiTenancy.transferOwnership({
params: { id: 'tenant-id' },
body: { userId: 'new-owner-user-id' },
})Members
There are two ways to add someone to a tenant:
addMember— directly grants membership without any invitation step. Use this when the user is already known (e.g. same company domain). Requires the caller to be the tenant owner.inviteMember— creates a pending invitation that the invitee must explicitly accept. Any existing tenant member can invite. This is the standard onboarding path for external users.
| Method | HTTP | Path | Description |
| -------------- | ---- | --------------------------------------------- | -------------------------------------- |
| listMembers | GET | /tenants/:tenantId/members | List all members of a tenant |
| getMember | GET | /tenants/:tenantId/members/:memberId | Get a single member |
| addMember | POST | /tenants/:tenantId/members | Directly add a user by id (owner only) |
| inviteMember | POST | /tenants/:tenantId/members/invite | Invite a user by email |
| removeMember | POST | /tenants/:tenantId/members/:memberId/remove | Remove a member (owner only) |
Add a member directly (no invitation)
await authClient.multiTenancy.addMember({
params: { tenantId: 'tenant-id' },
body: { userId: 'user-id' },
})Invite a member by email
await authClient.multiTenancy.inviteMember({
params: { tenantId: 'tenant-id' },
body: { email: '[email protected]' },
})Invitations
Invitations are created via inviteMember (see above). The endpoints below manage the lifecycle of existing invitations.
| Method | HTTP | Path | Description |
| ------------------- | ---- | ------------------------------------------------------ | --------------------------------- |
| listInvitations | GET | /tenants/:tenantId/invitations | List all invitations for a tenant |
| getInvitation | GET | /tenants/:tenantId/invitations/:invitationId | Get an invitation by id |
| revokeInvitation | POST | /tenants/:tenantId/invitations/:invitationId/revoke | Cancel a pending invitation |
| acceptInvitation | POST | /tenants/:tenantId/invitations/:invitationId/accept | Accept an invitation |
| declineInvitation | POST | /tenants/:tenantId/invitations/:invitationId/decline | Decline an invitation |
Accept an invitation
await authClient.multiTenancy.acceptInvitation({
params: {
tenantId: 'tenant-id',
invitationId: 'invitation-id',
},
})The caller's email must match the invited email. On success, a tenantMember record is created automatically.
Child Tenants
Child tenants use the parentTenantId field to model a hierarchy. Only the parent tenant's owner can create, update, or delete child tenants.
| Method | HTTP | Path | Description |
| ------------------- | ---- | --------------------------------------------- | ----------------------------------------- |
| listChildTenants | GET | /tenants/:tenantId/children | List direct children of a tenant |
| getChildTenant | GET | /tenants/:tenantId/children/:childId | Get a single child tenant |
| createChildTenant | POST | /tenants/:tenantId/children | Create a child tenant (parent owner only) |
| updateChildTenant | POST | /tenants/:tenantId/children/:childId | Update a child tenant (parent owner only) |
| deleteChildTenant | POST | /tenants/:tenantId/children/:childId/delete | Delete a child tenant (parent owner only) |
Create a child tenant
const { data } = await authClient.multiTenancy.createChildTenant({
params: { tenantId: 'parent-tenant-id' },
body: {
name: 'Acme Engineering',
slug: 'acme-engineering',
},
})Plugin Options
All options are optional. The plugin works with zero configuration.
multiTenancy({
// ── Limits ──────────────────────────────────────────────────────────────────
/**
* Maximum number of tenants a single user can own simultaneously.
* Counts only tenants where the user is the ownerId.
*/
maxTenantsPerUser: 5,
/**
* Maximum nesting depth for the tenant hierarchy.
* 1 = children only (no grandchildren). 2 = children + grandchildren. Etc.
*/
maxHierarchyDepth: 3,
/**
* Maximum total descendants allowed under a root tenant tree.
* Enforced at the root ancestor level — adding a child anywhere in the tree
* counts against the root's quota. Suitable for plan/billing caps.
*/
maxChildTenantsPerTenant: 10,
// ── Generic tenant hooks ─────────────────────────────────────────────────────
/** Called after any tenant is created (root or child). */
onTenantCreated: async (tenant, userId) => { ... },
/** Called after any tenant's name or slug is updated (root or child). */
onTenantUpdated: async (tenant) => { ... },
/** Called after any tenant is deleted (root or child). */
onTenantDeleted: async (tenant) => { ... },
/** Called after ownership is transferred. `previousOwnerId` is the old owner. */
onOwnershipTransferred: async (tenant, previousOwnerId) => { ... },
// ── Root-tenant-specific hooks ───────────────────────────────────────────────
// Fire in addition to the generic hooks above, only for root tenants.
onRootTenantCreated: async (tenant, userId) => { ... },
onRootTenantUpdated: async (tenant) => { ... },
onRootTenantDeleted: async (tenant) => { ... },
// ── Child-tenant-specific hooks ──────────────────────────────────────────────
// Fire in addition to the generic hooks above, only for child tenants.
onChildTenantCreated: async (tenant, userId) => { ... },
onChildTenantUpdated: async (tenant) => { ... },
onChildTenantDeleted: async (tenant) => { ... },
// ── Member hooks ─────────────────────────────────────────────────────────────
/**
* Called after a user is added to a tenant via addMember or acceptInvitation.
* Does not fire for the creator being auto-added during tenant creation.
*/
onMemberAdded: async (member) => { ... },
/** Called after a member is removed from a tenant. */
onMemberRemoved: async (member) => { ... },
// ── Invitation hooks ─────────────────────────────────────────────────────────
/**
* Called after an invitation is created. Implement this to send invitation emails.
* Without this hook the invitation is persisted but the invitee receives no notification.
*/
onInvitationCreated: async (invitation, tenantName) => { ... },
/** Called after a pending invitation is revoked by a tenant member. */
onInvitationRevoked: async (invitation) => { ... },
/**
* Called after an invitation is accepted. Fires in addition to onMemberAdded.
* Both the updated invitation and the new member record are provided.
*/
onInvitationAccepted: async (invitation, member) => { ... },
/** Called after an invitation is declined by the invitee. */
onInvitationDeclined: async (invitation) => { ... },
})Sending Invitation Emails
The plugin does not send emails itself. Wire in your email provider via onInvitationCreated:
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
multiTenancy({
onInvitationCreated: async (invitation, tenantName) => {
await resend.emails.send({
from: '[email protected]',
to: invitation.email,
subject: `You've been invited to join ${tenantName}`,
html: `
<p>You've been invited to join <strong>${tenantName}</strong>.</p>
<p>
<a href="https://yourapp.com/invitations/${invitation.id}/accept">
Accept invitation
</a>
</p>
`,
})
},
})Hierarchical Tenants
A tenant can be nested under a parent via parentTenantId. There is no limit on depth by default; use maxHierarchyDepth to cap it, and maxChildTenantsPerTenant to cap the total number of descendants under a root tenant.
Acme Corp (root)
├── Engineering
│ ├── Frontend
│ └── Backend
└── SalesCreating this structure:
const { data: acme } = await authClient.multiTenancy.createTenant({
name: 'Acme Corp',
slug: 'acme',
})
const { data: eng } = await authClient.multiTenancy.createChildTenant({
params: { tenantId: acme.tenant.id },
body: { name: 'Engineering', slug: 'acme-engineering' },
})
await authClient.multiTenancy.createChildTenant({
params: { tenantId: eng.tenant.id },
body: { name: 'Frontend', slug: 'acme-frontend' },
})Deleting a parent tenant sets parentTenantId to null on its direct children — they become root tenants. Grandchildren and deeper descendants are unaffected.
Database Schema
The plugin creates three tables:
| Table | Description |
| ------------------ | ----------------------------------------------------------- |
| tenant | Stores tenant metadata (name, slug, creator, owner, parent) |
| tenantMember | Links users to tenants |
| tenantInvitation | Tracks pending, accepted, and declined invitations |
License
MIT — see LICENSE.
