Implementa multi-tenancy con organizations, roles e invitaciones en Next.js usando el plugin de Better Auth. Aislamiento de datos y errores comunes.
El multi-tenancy es uno de los problemas más mal resueltos en SaaS. Lo planteas mal al principio y arrastras deuda técnica durante años. En 2026, Better Auth tiene un plugin de organizations que resuelve la mayoría de casos sin reinventar la rueda.
Esta guía explica cómo implementar multi-tenancy con organizations, roles e invitaciones en una app Next.js. Asume que ya tienes Better Auth funcionando con 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)
}Y migra:
bunx prisma migrate dev --name add-organizationsCuando un usuario se registra, le pides crear su organization (o le invitas a una existente).
const org = await auth.api.createOrganization({
body: {
name: 'Acme Inc',
slug: 'acme-inc',
},
headers: await headers(),
});El usuario que crea la org queda como owner automáticamente.
const invitation = await auth.api.inviteToOrganization({
body: {
email: 'jane@example.com',
organizationId: org.id,
role: 'member',
},
headers: await headers(),
});
// Envía email con el link: /accept-invitation?token=invitation.idCuando el invitado abre el link, llamas:
await auth.api.acceptInvitation({
body: { invitationId: token },
headers: await headers(),
});Un usuario puede pertenecer a varias organizations. La sesión guarda cuál está activa:
await auth.api.setActiveOrganization({
body: { organizationId: org.id },
headers: await headers(),
});Y en cualquier endpoint:
const session = await auth.api.getSession({ headers: await headers() });
const activeOrgId = session?.session.activeOrganizationId;Aquí está el truco más importante. Toda query a recursos de la org debe filtrar por
organizationId.
Mal:
const projects = await db.project.findMany();Bien:
const projects = await db.project.findMany({
where: { organizationId: activeOrgId },
});Es fácil olvidarse. Una práctica que ayuda: crea un 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;
}Y úsalo en todas las queries:
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 });
}Para apps con permisos finos, conviene un sistema más serio (Casbin, abilities). Para la mayoría de casos, los 3 roles del plugin sobran.
1. Olvidar filtrar por organizationId: el bug más peligroso. Un usuario de la org A ve datos
de la org B. Solución: helper centralizado y revisión cruzada en code review.
2. Slug colisiones: si permites slugs personalizados, valida unicidad y formato (kebab-case, sin caracteres especiales).
3. Owner se va y la org queda huérfana: define qué pasa cuando el último owner sale. Lo más sano: bloquear la salida si es el último owner, o forzar transferencia.
Multi-tenancy con Better Auth organizations son ~200 líneas de configuración y queries. La parte más
delicada es disciplinar al equipo a filtrar siempre por organizationId.
Si lo haces bien desde el día 1, el SaaS escala sin reescrituras. Si lo haces mal, te toca migrar con datos en producción. Y eso ya no es bonito.
Suscríbete para más tutoriales y tips sobre crear productos con IA
La integración real de Tailwind v4 + shadcn 2.3.0 en Next.js 16 App Router. OKLCH, dark mode sin FOUC, Geist con next/font y el bug silencioso de hsl(var()) que rompe tus estilos sin avisar.