Multi-tenant SaaS en Next.js App Router: aislamiento real sin complejidad innecesaria
El problema que nadie documenta bien
Construir una plataforma SaaS multi-tenant no es difícil en el prototipo. El problema aparece en producción, cuando tienes 50 tenants activos, un equipo de tres personas y una base de código donde tenantId aparece en 200 lugares distintos.
En un proyecto reciente, heredé una aplicación Next.js con Pages Router donde el tenant se resolvía así en cada página:
const tenantId = router.query.tenant as string;
const { data } = await fetchData(tenantId);Eso funciona. Hasta que no funciona. Hasta que alguien olvida pasar el tenantId, o lo pasa desde el cliente cuando debería venir del servidor, o un endpoint devuelve datos de otro tenant porque la validación estaba en el frontend.
Este artículo documenta la arquitectura que reemplazó ese sistema: cómo resolver el tenant una sola vez, propagarlo de forma segura a través de toda la aplicación, y mantener el código de negocio completamente agnóstico al contexto de tenant.
Contexto técnico
La aplicación es una plataforma B2B con las siguientes características: cada tenant tiene su propio subdominio (acme.app.com, globex.app.com), los datos están en una base de datos compartida con row-level isolation (PostgreSQL + RLS), el frontend es Next.js 14 con App Router, la autenticación usa JWT con tenantId embebido en el claim, y hay rutas públicas (landing, login) y rutas privadas (dashboard, settings).
El modelo de aislamiento es shared database, shared schema con RLS. Esto significa que el tenantId debe estar presente en cada query a nivel de base de datos, no solo en el frontend.
Errores comunes en implementaciones multi-tenant
1. Resolver el tenant en el cliente
El error más frecuente. Si el tenant se determina desde window.location.hostname o desde un parámetro de URL en el cliente, cualquier manipulación del DOM o de la URL puede comprometer el aislamiento.
2. Pasar tenantId como prop en cascada
El prop drilling de tenantId es una señal de que el contexto no está bien encapsulado. Cuando el tenant llega como prop a un componente de tercer nivel, el contrato implícito es frágil y cualquier refactor rompe el flujo.
3. Validar el tenant solo en el frontend
El frontend puede verificar que el usuario pertenece al tenant para mostrar o esconder UI, pero la validación real debe ocurrir en el servidor. Si el API no valida que el tenantId del JWT coincide con el recurso solicitado, tienes un IDOR esperando ser explotado.
4. Mezclar lógica de tenant con lógica de negocio
// ❌ Lógica de negocio contaminada con contexto de tenant
async function getProjects(tenantId: string, userId: string) {
const tenant = await db.tenant.findUnique({ where: { id: tenantId } });
if (!tenant.isActive) throw new Error('Tenant inactive');
return db.project.findMany({ where: { tenantId, userId } });
}Esta función hace demasiado. La validación del estado del tenant no es responsabilidad de getProjects. Cuando esta lógica se repite en 30 funciones, cualquier cambio en las reglas del tenant requiere tocar 30 archivos.
Arquitectura: resolución de tenant en el edge
La solución parte de un principio simple: el tenant se resuelve una sola vez, lo más cerca posible del request, y se propaga de forma implícita. El middleware de Next.js es el lugar correcto para esto.
Middleware como punto de entrada único
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { resolveTenant } from '@/lib/tenant/resolve';
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host') ?? '';
const tenant = await resolveTenant(hostname);
if (!tenant) {
return NextResponse.redirect(new URL('/not-found', request.url));
}
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenant.id);
response.headers.set('x-tenant-slug', tenant.slug);
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};El middleware resuelve el tenant desde el subdominio y lo inyecta como header. Esto ocurre antes de que cualquier Server Component o Route Handler se ejecute.
Función resolveTenant con deduplicación de queries
// lib/tenant/resolve.ts
import { cache } from 'react';
import { db } from '@/lib/db';
export const resolveTenant = cache(async (hostname: string) => {
const slug = hostname.split('.')[0];
return db.tenant.findUnique({
where: { slug },
select: { id: true, slug: true, isActive: true },
});
});Usar cache de React aquí es intencional: si resolveTenant se llama múltiples veces en el mismo request (desde el middleware y desde un Server Component), la query a base de datos ocurre una sola vez por request.
Propagación del contexto en App Router
Con el tenant en los headers, el siguiente paso es hacerlo accesible en toda la aplicación sin pasarlo como prop.
Server-side: leer desde headers
// lib/tenant/context.ts
import { headers } from 'next/headers';
export function getTenantContext() {
const headersList = headers();
const tenantId = headersList.get('x-tenant-id');
const tenantSlug = headersList.get('x-tenant-slug');
if (!tenantId) throw new Error('Tenant context not available');
return { tenantId, tenantSlug };
}Cualquier Server Component o Server Action puede llamar getTenantContext() sin recibir nada como prop:
// app/dashboard/page.tsx
import { getTenantContext } from '@/lib/tenant/context';
import { getProjects } from '@/lib/projects/queries';
export default async function DashboardPage() {
const { tenantId } = getTenantContext();
const projects = await getProjects(tenantId);
return <ProjectList projects={projects} />;
}Client-side: contexto React hidratado desde el servidor
Para Client Components que necesitan el tenant (para construir URLs o para llamadas fetch desde el cliente), se expone el contexto a través de un Provider que se hidrata desde el servidor:
// components/providers/TenantProvider.tsx
'use client';
import { createContext, useContext } from 'react';
type TenantContextValue = { tenantId: string; tenantSlug: string };
const TenantContext = createContext<TenantContextValue | null>(null);
export function TenantProvider({ children, value }: {
children: React.ReactNode;
value: TenantContextValue;
}) {
return <TenantContext.Provider value={value}>{children}</TenantContext.Provider>;
}
export function useTenant() {
const ctx = useContext(TenantContext);
if (!ctx) throw new Error('useTenant must be used within TenantProvider');
return ctx;
}// app/layout.tsx
import { getTenantContext } from '@/lib/tenant/context';
import { TenantProvider } from '@/components/providers/TenantProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const tenant = getTenantContext();
return (
<html><body>
<TenantProvider value={tenant}>{children}</TenantProvider>
</body></html>
);
}El layout es un Server Component que lee el contexto del servidor y lo pasa al Provider. Los Client Components consumen useTenant() sin saber nada del subdominio ni de los headers.
Aislamiento en la capa de datos
El frontend puede estar perfectamente encapsulado, pero si la capa de datos no valida el tenant, el aislamiento es cosmético. En producción, la implementación más robusta combina un wrapper de Prisma con PostgreSQL RLS, donde el tenantId se establece como variable de sesión antes de cada query:
-- Ejecutar antes de cada query en la conexión
SET app.current_tenant_id = 'tenant-uuid-here';
-- Policy en la tabla projects
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);Con RLS activo, incluso si un bug en la aplicación omite el filtro de tenant en una query, la base de datos rechaza el acceso. Es la última línea de defensa y la única que no depende de que el código de aplicación sea perfecto.
Tradeoffs y decisiones técnicas
Cada decisión arquitectónica tiene un costo. Estas son las que más impacto tuvieron:
Subdominio vs path-based routing
La decisión de usar subdominios tiene implicaciones que van más allá de la estética de la URL. Con subdominios, las cookies de sesión están naturalmente aisladas por dominio. Con paths, necesitas gestionar el scope de las cookies manualmente. Los subdominios también permiten configurar reglas de caché por tenant en el edge sin lógica adicional. El costo: requiere wildcard certificate (*.app.com), lo cual es estándar pero tiene un costo operacional en renovaciones y configuración de DNS.
cache() de React vs Redis para resolveTenant
cache() de React deduplica llamadas dentro del mismo request. Redis con TTL deduplica entre requests. Para resolveTenant, la información del tenant cambia raramente (nombre, slug, estado), pero si un tenant se desactiva, quieres que el cambio sea efectivo de inmediato. Con Redis y un TTL de 5 minutos, un tenant desactivado sigue siendo accesible durante ese tiempo. Con cache() de React, cada request consulta la base de datos una vez, lo cual es aceptable para la mayoría de los casos.
Proxy de Prisma vs Repository pattern
El Proxy de Prisma para inyectar tenantId automáticamente tiene menor overhead de abstracción para equipos pequeños. El Repository pattern escala mejor con equipos grandes porque hace explícito el contrato de cada operación de datos. La elección depende del tamaño del equipo y de cuánto quieres que el compilador te ayude a detectar errores.
Escalabilidad y mantenibilidad
Con 200 tenants activos, el middleware sigue siendo el único punto de resolución. No hay lógica de tenant dispersa en el código de negocio. Agregar un nuevo tenant es una operación de base de datos, no un cambio de código.
Feature flags por tenant
Esta arquitectura asume que todos los tenants comparten el mismo schema y la misma lógica de negocio. Si necesitas feature flags por tenant, se puede extender el contexto sin romper la arquitectura base:
// lib/tenant/features.ts
export async function getTenantFeatures(tenantId: string): Promise<Set<string>> {
const features = await db.tenantFeature.findMany({
where: { tenantId, enabled: true },
select: { featureKey: true },
});
return new Set(features.map(f => f.featureKey));
}Testing del aislamiento
Una suite de tests que vale la pena tener desde el día uno:
describe('Tenant isolation', () => {
it('should not return data from another tenant', async () => {
const tenantA = await createTestTenant();
const tenantB = await createTestTenant();
await createProject({ tenantId: tenantA.id, name: 'Project A' });
const projects = await getProjects(tenantB.id);
expect(projects).toHaveLength(0);
});
});Simple, pero este test ha detectado regresiones reales cuando alguien modificó la lógica de queries sin considerar el contexto de tenant. El valor no está en la complejidad del test, sino en que corre en cada PR.
Conclusión
El aislamiento multi-tenant no es un problema de framework, es un problema de diseño. Next.js App Router facilita la implementación porque el modelo de Server Components y el middleware encajan naturalmente con este patrón, pero la arquitectura funciona con cualquier framework que tenga un punto de entrada de request bien definido.
Lo que hace que esta solución sea mantenible no es la elegancia del código, sino la consistencia del principio: el tenant se resuelve una vez, en el borde, y el resto del sistema lo consume sin saber cómo llegó ahí.
Cuando el código de negocio no sabe que existe un sistema multi-tenant, el aislamiento es estructural, no accidental. Esa es la diferencia entre una arquitectura que escala y una que acumula deuda técnica silenciosa.