Multi-tenancy in Next.js with Better Auth Organizations
Implement multi-tenancy with organizations, roles, and invitations in Next.js using the Better Auth plugin. Data isolation and common pitfalls.
Israel Palma
4 min read
Multi-tenancy is one of the most poorly-solved problems in SaaS. Get it wrong at the start and
you'll drag technical debt for years. In 2026, Better Auth has an organizations plugin that solves
most cases without reinventing the wheel.
This guide explains how to implement multi-tenancy with organizations, roles, and invitations in a
Next.js app. It assumes you already have Better Auth working with email + password.
## What the plugin solves
- Create and manage organizations (workspaces, teams, companies)
- Roles per organization (`owner`, `admin`, `member`)
- Invite users by email
- Switch active organization in the session
- Isolate data per organization in queries
## Step 1: enable the plugin
`src/lib/auth/server.ts`:
```ts
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { db } from '@/lib/db/client';
export const auth = betterAuth({
database: prismaAdapter(db, { provider: 'postgresql' }),
emailAndPassword: { enabled: true },
plugins: [
organization({
allowUserToCreateOrganization: true,
organizationLimit: 5,
}),
],
});
```
## Step 2: Prisma schema
```prisma
model Organization {
id String @id
name String
slug String @unique
logo String?
createdAt DateTime @default(now())
members Member[]
invitations Invitation[]
}
model Member {
id String @id
organizationId String
userId String
role String // owner | admin | member
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId])
}
model Invitation {
id String @id
email String
organizationId String
role String
status String // pending | accepted | rejected
expiresAt DateTime
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
}
```
Then migrate:
```bash
bunx prisma migrate dev --name add-organizations
```
## Step 3: create an organization
When a user signs up, ask them to create their organization (or invite them to an existing one).
```ts
const org = await auth.api.createOrganization({
body: {
name: 'Acme Inc',
slug: 'acme-inc',
},
headers: await headers(),
});
```
The user creating the org becomes `owner` automatically.
## Step 4: invite members
```ts
const invitation = await auth.api.inviteToOrganization({
body: {
email: 'jane@example.com',
organizationId: org.id,
role: 'member',
},
headers: await headers(),
});
// Send email with link: /accept-invitation?token=invitation.id
```
When the invitee opens the link, call:
```ts
await auth.api.acceptInvitation({
body: { invitationId: token },
headers: await headers(),
});
```
## Step 5: active organization in the session
A user can belong to multiple organizations. The session tracks which one is active:
```ts
await auth.api.setActiveOrganization({
body: { organizationId: org.id },
headers: await headers(),
});
```
And in any endpoint:
```ts
const session = await auth.api.getSession({ headers: await headers() });
const activeOrgId = session?.session.activeOrganizationId;
```
## Step 6: isolate data per organization
Here's the most important trick. **Every query against org resources must filter by
`organizationId`**.
Wrong:
```ts
const projects = await db.project.findMany();
```
Right:
```ts
const projects = await db.project.findMany({
where: { organizationId: activeOrgId },
});
```
It's easy to forget. A practice that helps: create a helper:
```ts
export async function getActiveOrgId() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.session.activeOrganizationId) {
throw new Error('No active organization');
}
return session.session.activeOrganizationId;
}
```
And use it in every query:
```ts
const orgId = await getActiveOrgId();
const projects = await db.project.findMany({ where: { organizationId: orgId } });
```
## Step 7: roles and permissions
```ts
const session = await auth.api.getSession({ headers: await headers() });
const member = await db.member.findFirst({
where: {
userId: session.user.id,
organizationId: activeOrgId,
},
});
if (member?.role !== 'owner' && member?.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
```
For apps with fine-grained permissions, you'll want a more serious system (Casbin, abilities). For
most cases, the plugin's 3 roles are enough.
## Common errors
**1. Forgetting to filter by `organizationId`**: the most dangerous bug. A user from org A sees data
from org B. Fix: centralized helper and cross-review in code review.
**2. Slug collisions**: if you allow custom slugs, validate uniqueness and format (kebab-case, no
special characters).
**3. Owner leaves and the org is orphaned**: define what happens when the last owner exits. The
sanest path: block the exit if it's the last owner, or force a transfer.
## Bottom line
Multi-tenancy with Better Auth organizations is ~200 lines of config and queries. The most delicate
part is disciplining the team to always filter by `organizationId`.
If you do it well from day 1, the SaaS scales without rewrites. If you do it wrong, you migrate with
production data later. And that's no longer pretty.
Enjoyed this article?
Subscribe for more tutorials and tips on building products with AI