Organizations and teams
Multi-tenancy with the Better Auth organization plugin, backed by Payload collections.
The Better Auth organization plugin is supported, including teams. Its records become
ordinary Payload collections with real relationships, so you can browse and edit them in
the admin panel.
Enable it
import { organization } from 'better-auth/plugins'
export const betterAuthOptions = {
plugins: [
organization({
teams: { enabled: true },
async sendInvitationEmail(data) {
const url = `${process.env.NEXT_PUBLIC_BETTER_AUTH_URL}/accept-invitation/${data.id}`
await sendEmail({ to: data.email, subject: 'You are invited', html: `<a href="${url}">Accept</a>` })
},
}),
],
} satisfies BetterAuthOptionsimport { organizationClient } from 'better-auth/client/plugins'
export const authClient = createAuthClient({
plugins: [organizationClient()],
})Collections created
| Slug | Contents |
|---|---|
organizations | name, slug, logo, metadata |
members | user + organization relationships, role |
invitations | organization, inviter, email, role, status |
organizationRoles | custom roles scoped to an organization |
teams | name, organization relationship |
teamMembers | team + user relationships |
teams and teamMembers only appear when teams.enabled is true.
Better Auth's organizationId, userId, inviterId and teamId keys are mapped to
Payload relationship fields named organization, user, inviter and team. Write
Payload queries against the Payload names:
const members = await payload.find({
collection: 'members',
where: { organization: { equals: organizationId } },
})Active organization
The active organization is stored on the session record as activeOrganizationId:
await authClient.organization.setActive({ organizationId })const { data: activeOrg } = authClient.useActiveOrganization()
const { data: orgs } = authClient.useListOrganizations()Permissions
Define an access controller and pass it to the plugin, exactly as Better Auth documents:
import { createAccessControl } from 'better-auth/plugins'
import {
defaultStatements,
defaultRoles,
ownerAc,
} from 'better-auth/plugins/organization/access'
const ac = createAccessControl({
...defaultStatements,
project: ['create', 'share', 'update', 'delete'],
})
const superAdmin = ac.newRole({
project: ['create', 'update'],
...ownerAc.statements,
})
export const betterAuthOptions = {
plugins: [
organization({ ac, roles: { ...defaultRoles, superAdmin } }),
],
} satisfies BetterAuthOptionsOrganization roles are separate from Payload roles
users.roles in the plugin options governs the Payload admin panel. Organization roles
(owner, admin, member, or your own) govern membership within an organization. They
are independent — a Payload admin is not automatically an organization owner.
Scoping your own collections
Add an organization relationship to your collections and filter on it in access control:
import type { CollectionConfig } from 'payload'
export const Projects: CollectionConfig = {
slug: 'projects',
access: {
read: async ({ req }) => {
if (!req.user) return false
const members = await req.payload.find({
collection: 'members',
where: { user: { equals: req.user.id } },
depth: 0,
})
const orgIds = members.docs.map((m) => m.organization)
return { organization: { in: orgIds } }
},
},
fields: [
{ name: 'name', type: 'text', required: true },
{
name: 'organization',
type: 'relationship',
relationTo: 'organizations',
required: true,
},
],
}Types
import type { getPayload } from '@/lib/payload'
type PayloadWithAuth = Awaited<ReturnType<typeof getPayload>>
export type ActiveOrganization =
PayloadWithAuth['betterAuth']['$Infer']['ActiveOrganization']
export type Invitation = PayloadWithAuth['betterAuth']['$Infer']['Invitation']