Middleware de Subdominio Comodín Next.js para SaaS Multi-Tenant
Por qué Multi-Tenancy Basada en Subdominio
Desglosemos esto. Generalmente tienes tres opciones cuando se trata de enrutamiento multi-tenant en aplicaciones SaaS:
| Patrón | Ejemplo | Aislamiento | SEO | Experiencia del Usuario |
|---|---|---|---|---|
| Basado en ruta | app.com/tenant-a/dashboard |
Bajo | Autoridad de dominio compartida | Se siente como una plataforma compartida |
| Basado en subdominio | tenant-a.app.com |
Medio | Autoridad de subdominio | Se siente como una aplicación dedicada |
| Dominio personalizado | app.tenant-a.com |
Alto | Autoridad de dominio completa | Se siente completamente personalizado |
¿Por qué a menudo aterrizamos en subdominio? Bueno, es esa situación de Ricitos de Oro—perfecta para el 95% de los casos. El tenant obtiene su propia URL marcada (acme.yourapp.com), ¿y la complejidad? Manejable. Además, no estás atrapado cuando quieran actualizar a un dominio personalizado más adelante. Se siente lo suficientemente personal para el tenant y mantiene tu stack tecnológico sin convertirse en una máquina de Rube Goldberg.
Pero aquí viene el truco: comienza con subdominio y ofrece esos dominios personalizados como característica premium. Con middleware Next.js, todo esto puede fluir a través de un único canal ordenado. ¿Habla de eficiencia, verdad?

Descripción General de la Arquitectura
Imagina cada solicitud entrando a tu aplicación como si estuviera en una cinta transportadora. ¿Lo primero que encuentra? Tu middleware Next.js. Este código confiable extrae el subdominio, descubre a qué tenant pertenece, y luego reescribe la ruta interna o marca esta solicitud con encabezados que tu aplicación puede usar. ¡Fácil!
Solicitud: acme.yourapp.com/dashboard
↓
Middleware: Extraer hostname → resolver tenant → inyectar encabezados
↓
Reescribir: /dashboard → /[tenant]/dashboard (reescritura interna)
↓
Página: Lee tenant de parámetros o encabezados, obtiene datos específicos del tenant
Lo que realmente sucede aquí es un truco de magia—las reescrituras de middleware son invisibles para los usuarios. Su navegador aún muestra con orgullo acme.yourapp.com/dashboard, mientras que detrás de escenas, Next.js realmente enruta a /acme/dashboard.
Estructura de Directorios
Aquí hay un vistazo a cómo podría verse tu proyecto:
├── middleware.ts
├── app/
│ ├── [tenant]/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── dashboard/
│ │ │ └── page.tsx
│ │ └── settings/
│ │ └── page.tsx
│ └── api/
│ └── tenant/
│ └── route.ts
├── lib/
│ ├── tenant.ts
│ └── middleware-utils.ts
Configurar DNS Comodín
Antes de codificar una sola línea, necesitas resolver DNS.
Vercel
Este es directo: dirígete a la configuración de tu proyecto, y añade un dominio comodín:
*.yourapp.com
yourapp.com
Y no te preocupes, Vercel hace el trabajo pesado con certificados SSL para ti. A partir de 2025, necesitarás al menos un plan Pro (eso es $20/mes por miembro de tu equipo) ya que los planes hobby no tienen suerte aquí.
Cloudflare
Cloudflare se lleva bien con el enrutamiento comodín. Configura un registro A así:
Tipo: A
Nombre: *
Contenido: <your-server-ip>
Proxy: Sí (nube naranja)
Y si estás en la pandilla de Vercel, cambia a un registro CNAME:
Tipo: CNAME
Nombre: *
Contenido: cname.vercel-dns.com
Proxy: Solo DNS (nube gris)
¿Por qué gris? Vercel maneja SSL, y el proxy de Cloudflare no juega bien allí. Lo neutral es tu amigo.
Auto-hospedado (Nginx)
server {
listen 80;
server_name *.yourapp.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Para SSL, necesitarás un certificado comodín de Let's Encrypt. Su desafío DNS-01 y el plugin Certbot hacen esto más sensato de lo que parece.
Construir el Middleware
Ok, ¿quieres código? Aquí está el middleware que ha sido probado en batalla. Te ahorraré la gloria de cada punto y coma pero confía en mí, es oro.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// Dominios que NO deben tratarse como subdominios de tenant
const RESERVED_SUBDOMAINS = new Set([
'www',
'api',
'admin',
'app',
'mail',
'blog',
'docs',
'status',
]);
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'yourapp.com';
export const config = {
matcher: [
/*
* Coincidir con todas las rutas excepto:
* - _next/static (archivos estáticos)
* - _next/image (optimización de imágenes)
* - favicon.ico
* - archivos de carpeta pública
*/
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)',
],
};
export default async function middleware(req: NextRequest) {
const url = req.nextUrl;
const hostname = req.headers.get('host') || '';
// Eliminar puerto para desarrollo local
const currentHost = hostname.replace(/:\d+$/, '');
// Verificar si este es el dominio raíz o www
if (
currentHost === ROOT_DOMAIN ||
currentHost === `www.${ROOT_DOMAIN}` ||
currentHost === 'localhost'
) {
// Este es el sitio de marketing / página de inicio
return NextResponse.next();
}
// Extraer subdominio
let tenant: string | null = null;
if (currentHost.endsWith(`.${ROOT_DOMAIN}`)) {
const subdomain = currentHost.replace(`.${ROOT_DOMAIN}`, '');
if (RESERVED_SUBDOMAINS.has(subdomain)) {
return NextResponse.next();
}
tenant = subdomain;
} else {
// Esto podría ser un dominio personalizado
tenant = await resolveCustomDomain(currentHost);
}
if (!tenant) {
// Dominio desconocido — redirigir al sitio principal
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
// Reescribir a ruta específica del tenant
const tenantUrl = new URL(`/${tenant}${url.pathname}`, req.url);
tenantUrl.search = url.search;
const response = NextResponse.rewrite(tenantUrl);
// Inyectar info del tenant como encabezados para consumo descendente
response.headers.set('x-tenant-slug', tenant);
response.headers.set('x-tenant-domain', currentHost);
return response;
}
async function resolveCustomDomain(domain: string): Promise<string | null> {
try {
// En producción, cachea esto agresivamente
const res = await fetch(
`${process.env.INTERNAL_API_URL}/api/domains/resolve?domain=${domain}`,
{
headers: { Authorization: `Bearer ${process.env.INTERNAL_API_KEY}` },
next: { revalidate: 300 },
}
);
if (!res.ok) return null;
const data = await res.json();
return data.tenantSlug || null;
} catch {
return null;
}
}
El Patrón del Matcher es Todo
¿Quieres velocidad? Ese regex config.matcher es tu mejor amigo. Sin él, cada solicitud arrastra middleware—activos estáticos, imágenes, todo. Y eso es un boleto de ida a 200ms+ de latencia extra. Nadie quiere eso. Mira esto: /((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*). ¡Esto solo redujo nuestra latencia P95 en 40%! Sí, es bastante importante.
¿Por Qué Reescribir en Lugar de Redirigir?
Reescribir no interfiere con lo que los usuarios ven en su navegador—hablamos de navegación suave. ¿Redirecciones? Esas son como pedirle a tus usuarios que confíen en que los llevarás al lugar correcto—otra URL en la caja. Quédate con rewrite() para una experiencia sin drama.

Estrategias de Resolución de Tenant
El middleware anterior extrae tenants de subdominio como un profesional. ¿Pero existencia del tenant? Tengo que confirmar eso. Los diferentes enfoques resultan en diferentes estrategias cuando se trata de estrategias.
Estrategia 1: Búsqueda de Base de Datos en Middleware
Aquí viene la trampa: el middleware de Next.js se ejecuta en Edge Runtime, lo que significa despedirse de muchas APIs cómodas de Node.js.
// Funciona con Prisma Accelerate o @prisma/client/edge
import { PrismaClient } from '@prisma/client/edge';
const prisma = new PrismaClient();
async function resolveTenant(slug: string) {
return prisma.tenant.findUnique({
where: { slug },
select: { id: true, slug: true, plan: true },
});
}
Estrategia 2: Búsqueda en Almacén KV
Esta es mi favorita. Almacena tus slugs en un almacén KV como Vercel KV, Upstash Redis, o Cloudflare KV. Las búsquedas de borde en 1-5ms significan retraso negligible.
import { kv } from '@vercel/kv';
async function resolveTenant(slug: string) {
const tenant = await kv.get(`tenant:${slug}`);
return tenant as TenantConfig | null;
}
Estrategia 3: Lista de Permitidos Estática
¿Operación pequeña? Las listas de permitidos estáticas pueden ser tu amigo—archivos JSON de tiempo de construcción que mantienen las cosas ajustadas y sin red.
import tenants from './tenants.json';
const tenantMap = new Map(tenants.map(t => [t.slug, t]));
function resolveTenant(slug: string) {
return tenantMap.get(slug) || null;
}
Reconstruye con ISR o webhooks para manejar nuevos tenants dinámicamente.
Diseño de Base de Datos para Multi-Tenancy
Aquí es donde las cosas se ponen serias. Dos caminos principales para elegir:
Base de Datos Compartida con ID de Tenant
Añade una columna tenantId como si estuviera en venta:
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
Y sí, PostgreSQL RLS es tu póliza de seguros:
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant')::uuid);
La vida se vuelve más fácil cuando estableces contexto:
await db.execute(sql`SELECT set_config('app.current_tenant', ${tenantId}, true)`);
Base de Datos por Tenant
¿Quieres aislamiento de primera categoría que se ajuste a los requisitos de cumplimiento como un guante? Una base de datos por tenant es el camino. El branching de Neon y los precios de por-base-de-datos de PlanetScale hacen esto factible, incluso para fortunas pequeñas en 2025.
Soporte de Dominio Personalizado
Aquí es donde el despliegue y la vanidad se encuentran. Los tenants necesitan que sus dominios personalizados registrados con CNAME apunten tu manera. ¿Y SSL? Cada enlace seleccionado necesita brillar con ese HTTPS.
API de Dominios Personalizados de Vercel
La API de Vercel hace esto aproximadamente tan indoloro como puede ser:
async function addCustomDomain(domain: string, tenantId: string) {
const response = await fetch(
`https://api.vercel.com/v10/projects/${process.env.VERCEL_PROJECT_ID}/domains`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: domain }),
}
);
if (response.ok) {
// Almacenar el mapeo dominio-a-tenant
await db.customDomain.create({
data: { domain, tenantId, verified: false },
});
}
return response.json();
}
Vercel maneja SSL una vez que DNS se alinea. Echa un vistazo a su Kit de Inicio de Plataformas—es una implementación modelo.
Cloudflare para SaaS
Esto es ideal para los gestores de DNS personalizados elegantes, ya que "SSL para SaaS" de Cloudflare añade aprovisionamiento SSL y proxies también.
Rendimiento y Almacenamiento en Caché
Cuando se manejan solicitudes, cada nanosegundo cuenta. El truco es acelerar la resolución del tenant.
Almacenar en Caché la Resolución de Tenant
Ahorrarás el día (y servidor) almacenando en caché las búsquedas de tenant:
import { LRUCache } from 'lru-cache';
const tenantCache = new LRUCache<string, TenantConfig>({
max: 10000,
ttl: 1000 * 60 * 5, // 5 minutos
});
async function resolveTenantCached(slug: string): Promise<TenantConfig | null> {
const cached = tenantCache.get(slug);
if (cached) return cached;
const tenant = await resolveTenantFromDB(slug);
if (tenant) {
tenantCache.set(slug, tenant);
}
return tenant;
}
Recuerda, los cachés en memoria no son para Edge Runtime. Ve con algo como Upstash Redis—es lo que hacen mejor.
ISR y Multi-Tenancy
ISR ama multi-tenancy porque es inteligente. Cada tenant obtiene una versión en caché única gracias a reescribir de /dashboard a /acme/dashboard. Sin configuraciones adicionales, solo disfruta de la magia de Next.js.
Configuraciones de Despliegue
Decisiones, decisiones. Comparemos opciones de despliegue:
| Característica | Vercel Pro | Cloudflare Pages | Auto-hospedado (Docker) |
|---|---|---|---|
| Dominios Comodín | ✅ | ✅ | ✅ (manual) |
| API de Dominios Personalizados | ✅ | ✅ | ❌ (manual) |
| Middleware de Borde | ✅ | ✅ | ❌ (solo Node) |
| SSL Automático | ✅ | ✅ | ⚠️ Let's Encrypt |
| Precios | $20/puesto/mes + uso | $5/mes + uso | $50-200/mes servidor |
| Máximo de Dominios Personalizados | 50+ | Ilimitado | Ilimitado |
Para un lanzamiento brillante y rápido, Vercel Pro es tu boleto. Pero cuando esos números suben—más usuarios, más solicitudes—Cloudflare Pages u opciones auto-hospedadas te dan flexibilidad y asequibilidad.
Hemos navegado por maravillas multi-tenant con cada método, así que si estás pensando en tu próximo paso, nuestras capacidades de desarrollo de Next.js podrían ser tu guía.
Consideraciones de Seguridad
La multi-tenancy es súper genial hasta que no lo es. Cada tenant espera que sus datos estén seguros—sin filtraciones, sin deslices.
Lista de Verificación de Aislamiento de Tenant
- Filtrar por ID de Tenant: Nunca confíes solo en las URLs. Los controles de back-end importan también.
- PostgreSQL RLS: Es como tener un detalle de seguridad en tu base de datos 24/7.
- Desinfectar Subdominios: Nunca te equivoques—permite solo
[a-z0-9-]. Evita la toma de control de subdominio. - Limitar Tasa por Tenant: Los encabezados dinámicos para estrangulación de API pueden salvarte.
- Registrar, Auditar, Revisar: Cada operación de escritura debe decir "entendido". La confianza infunde confianza.
// Validar slug de tenant en middleware
const VALID_SLUG = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
if (!VALID_SLUG.test(subdomain)) {
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
CORS y Multi-Tenancy
Tu API sirve muchos subdominios, necesitas CORS para no lanzar rabietas:
// app/api/[...route]/route.ts
export async function GET(req: NextRequest) {
const origin = req.headers.get('origin') || '';
const isValidOrigin =
origin.endsWith(`.${ROOT_DOMAIN}`) ||
await isCustomDomain(origin.replace('https://', ''));
const headers = new Headers();
if (isValidOrigin) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Access-Control-Allow-Credentials', 'true');
}
// ... resto del manejador
}
Prueba de Middleware Multi-Tenant Localmente
Los dolores de cabeza abundan si el desarrollo local no soporta subdominios. Aquí es cómo vuelves a soñar.
Opción 1: Editar `/etc/hosts`
# /etc/hosts
127.0.0.1 yourapp.local
127.0.0.1 acme.yourapp.local
127.0.0.1 globex.yourapp.local
Luego actualiza tu middleware para reconocer .yourapp.local durante desarrollo:
const ROOT_DOMAIN = process.env.NODE_ENV === 'development'
? 'yourapp.local:3000'
: 'yourapp.com';
Opción 2: Usar nip.io o sslip.io
Estos servicios extraen mapeando IPs mágicos:
acme.127.0.0.1.nip.io → 127.0.0.1
globex.127.0.0.1.nip.io → 127.0.0.1
Simple, no requiere editar hosts.
Opción 3: Túnel Local con Subdominios Personalizados
Usa ngrok (o similares) para túneles rápidos:
ngrok http 3000 --hostname=*.your-dev-domain.ngrok.io
Las pruebas no deberían ser más difíciles que los escenarios del mundo real.
Escribir Pruebas de Integración
import { describe, it, expect } from 'vitest';
import { middleware } from './middleware';
import { NextRequest } from 'next/server';
describe('middleware de tenant', () => {
it('reescribe solicitudes de subdominio a ruta de tenant', async () => {
const req = new NextRequest('https://acme.yourapp.com/dashboard');
const res = await middleware(req);
expect(res.headers.get('x-middleware-rewrite')).toContain('/acme/dashboard');
expect(res.headers.get('x-tenant-slug')).toBe('acme');
});
it('deja pasar solicitudes de dominio raíz', async () => {
const req = new NextRequest('https://yourapp.com/');
const res = await middleware(req);
expect(res.headers.get('x-middleware-rewrite')).toBeNull();
});
it('rechaza caracteres de subdominio inválidos', async () => {
const req = new NextRequest('https://acme--evil.yourapp.com/');
const res = await middleware(req);
expect(res.status).toBe(307); // Redirección
});
});
Pensando en la imagen más grande, ¿sea la opción entre Next.js o Astro, considera saltarte a nuestro viaje de insights de capa de CMS sin cabeza.
Recuerda, explorar significa preguntar, adaptarse y crecer. Comunícate con nosotros para una sesión de lluvia de ideas o profundiza en nuestro pricing si apuntas a una asociación de primera categoría.