Implement multi-tenancy with organizations, roles, and invitations in Next.js using the Better Auth plugin. Data isolation and common pitfalls.
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.
owner, admin, member)src/lib/auth/server.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,
}),
],
});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:
bunx prisma migrate dev --name add-organizationsWhen a user signs up, ask them to create their organization (or invite them to an existing one).
const org = await auth.api.createOrganization({
body: {
name: 'Acme Inc',
slug: 'acme-inc',
},
headers: await headers(),
});The user creating the org becomes owner automatically.
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.idWhen the invitee opens the link, call:
await auth.api.acceptInvitation({
body: { invitationId: token },
headers: await headers(),
});A user can belong to multiple organizations. The session tracks which one is active:
await auth.api.setActiveOrganization({
body: { organizationId: org.id },
headers: await headers(),
});And in any endpoint:
const session = await auth.api.getSession({ headers: await headers() });
const activeOrgId = session?.session.activeOrganizationId;Here's the most important trick. Every query against org resources must filter by
organizationId.
Wrong:
const projects = await db.project.findMany();Right:
const projects = await db.project.findMany({
where: { organizationId: activeOrgId },
});It's easy to forget. A practice that helps: create a helper:
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:
const orgId = await getActiveOrgId();
const projects = await db.project.findMany({ where: { organizationId: orgId } });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.
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.
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.
Subscribe for more tutorials and tips on building products with AI
The actual integration of Tailwind v4 + shadcn 2.3.0 in Next.js 16 App Router. OKLCH, FOUC-free dark mode, Geist with next/font, and the silent hsl(var()) bug that breaks your styles without warning.