Multi-tenancy en Next.js con Better Auth Organizations
Implementa multi-tenancy con organizations, roles e invitaciones en Next.js usando el plugin de Better Auth. Aislamiento de datos y errores comunes.
Israel Palma
4 min de lectura
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.
## Qué resuelve el plugin
- Crear y gestionar organizations (workspaces, equipos, empresas)
- Roles por organization (`owner`, `admin`, `member`)
- Invitar usuarios por email
- Cambiar de organization activa en la sesión
- Aislar datos por organization en queries
## Paso 1: activar el 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,
}),
],
});
```
## Paso 2: schema de Prisma
```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)
}
```
Y migra:
```bash
bunx prisma migrate dev --name add-organizations
```
## Paso 3: crear organization
Cuando un usuario se registra, le pides crear su organization (o le invitas a una existente).
```ts
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.
## Paso 4: invitar miembros
```ts
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.id
```
Cuando el invitado abre el link, llamas:
```ts
await auth.api.acceptInvitation({
body: { invitationId: token },
headers: await headers(),
});
```
## Paso 5: organization activa en la sesión
Un usuario puede pertenecer a varias organizations. La sesión guarda cuál está activa:
```ts
await auth.api.setActiveOrganization({
body: { organizationId: org.id },
headers: await headers(),
});
```
Y en cualquier endpoint:
```ts
const session = await auth.api.getSession({ headers: await headers() });
const activeOrgId = session?.session.activeOrganizationId;
```
## Paso 6: aislar datos por organization
Aquí está el truco más importante. **Toda query a recursos de la org debe filtrar por
`organizationId`**.
Mal:
```ts
const projects = await db.project.findMany();
```
Bien:
```ts
const projects = await db.project.findMany({
where: { organizationId: activeOrgId },
});
```
Es fácil olvidarse. Una práctica que ayuda: crea un 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;
}
```
Y úsalo en todas las queries:
```ts
const orgId = await getActiveOrgId();
const projects = await db.project.findMany({ where: { organizationId: orgId } });
```
## Paso 7: roles y permisos
```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 });
}
```
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.
## Errores comunes
**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.
## Conclusión
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.
¿Te gustó este artículo?
Suscríbete para más tutoriales y tips sobre crear productos con IA