Enrutamiento de Subdominios Comodín en Next.js Middleware (SaaS Multi-Tenant)
Tu middleware se ejecuta en el momento en que una solicitud llega a tu app Next.js — antes de que cualquier página se renderice, antes de que cualquier ruta API se ejecute. Un tenant escribe customer1.tuapp.com en su navegador. Otro abre customer2.tuapp.com. Cada subdominio debe enrutar a dashboards aislados, bases de datos separadas, diferentes banderas de características — todo desde un mismo código. La mayoría de desarrolladores optan por enrutamiento basado en rutas (/tenant-slug/dashboard) y heredan un desastre de reescrituras de URL y verificaciones de autenticación dispersas en veinte archivos. Los subdomios comodín te permiten capturar *.tuapp.com en el edge, extraer el identificador del tenant en middleware e inyectar contexto antes de que React se monte. Pero tres decisiones determinarán si tu implementación escala a 10,000 tenants o se congela en 50.
| Patrón | Ejemplo | Aislamiento | SEO | Experiencia de 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 app dedicada |
| Dominio personalizado | app.tenant-a.com |
Alto | Autoridad de dominio completa | Se siente completamente personalizado |
¿Por qué optamos a menudo por subdomios? Bueno, es esa situación Ricitos de Oro — justo para el 95% de los casos. El tenant obtiene su propia URL de marca (acme.tuapp.com), ¿y la complejidad? Manejable. Además, no quedas atrapado cuando quieren actualizar a un dominio personalizado más adelante. Se siente lo suficientemente personal para el tenant y evita que tu stack tecnológico se convierta en una máquina de Rube Goldberg.
Pero aquí viene lo interesante: comienza con subdomios y ofrece esos dominios personalizados como una característica premium. Con middleware de Next.js, todo esto puede fluir a través de un pipeline ordenado. ¡Eso sí que es eficiencia!

Descripción General de la Arquitectura
Imagina cada solicitud ingresando a tu app como si estuviera en una cinta transportadora. ¿Lo primero que encuentra? Tu middleware de Next.js. Este código útil extrae el subdominio, averigua a qué tenant pertenece, y luego reescribe la ruta interna o marca esta solicitud con encabezados que tu app puede usar. ¡Muy fácil!
Solicitud: acme.tuapp.com/dashboard
↓
Middleware: Extrae hostname → resuelve tenant → inyecta encabezados
↓
Reescribe: /dashboard → /[tenant]/dashboard (reescritura interna)
↓
Página: Lee tenant de parámetros o encabezados, obtiene datos específicos del tenant
Lo que realmente ocurre aquí es un truco de magia — las reescrituras de middleware son invisibles para los usuarios. Su navegador aún muestra orgullosamente acme.tuapp.com/dashboard, mientras que detrás de escenas, Next.js realmente enruta a /acme/dashboard.
Estructura de Directorios
Aquí tienes 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
Configurando DNS Comodín
Antes de que codifiques una sola línea, necesitas lidiar con DNS.
Vercel
Este es directo: dirígete a la configuración de tu proyecto e añade un dominio comodín:
*.tuapp.com
tuapp.com
Y no te preocupes, Vercel hace el trabajo pesado con certificados SSL para ti. A partir de 2026, necesitarás al menos un plan Pro ($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: <tu-ip-servidor>
Proxy: Sí (nube naranja)
Y si estás en la banda de Vercel, cambia por 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í. Neutral es tu amigo.
Auto-alojado (Nginx)
server {
listen 80;
server_name *.tuapp.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 sano de lo que parece.
Construyendo 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 ser tratados como subdomios de tenant
const RESERVED_SUBDOMAINS = new Set([
'www',
'api',
'admin',
'app',
'mail',
'blog',
'docs',
'status',
]);
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'tuapp.com';
export const config = {
matcher: [
/*
* Coincide con todas las rutas excepto:
* - _next/static (archivos estáticos)
* - _next/image (optimización de imagen)
* - 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') || '';
// Elimina puerto para desarrollo local
const currentHost = hostname.replace(/:\d+$/, '');
// Verifica 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 destino
return NextResponse.next();
}
// Extrae 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 — redirige al sitio principal
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
// Reescribe a la ruta específica del tenant
const tenantUrl = new URL(`/${tenant}${url.pathname}`, req.url);
tenantUrl.search = url.search;
const response = NextResponse.rewrite(tenantUrl);
// Inyecta información del tenant como encabezados para consumo aguas abajo
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, almacena esto en caché 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 Matcher es Todo
¿Quieres velocidad? Ese regex config.matcher es tu mejor amigo. Sin él, cada solicitud arrastra middleware consigo — activos estáticos, imágenes, todo. Y eso es un viaje de ida a 200ms+ de latencia adicional. 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 un 40%! Sí, es bastante importante.
¿Por Qué Reescribir en Lugar de Redirigir?
Reescribir no interfiere con lo que los usuarios ven en su navegador — estamos hablando 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. Adhiérete a rewrite() para una experiencia sin drama.

Estrategias de Resolución de Tenant
El middleware anterior extrae tenants de subdomios como un profesional. ¿Pero existencia de tenant? Tienes que confirmar eso. Diferentes tácticas para diferentes amigos cuando se trata de estrategias.
Estrategia 1: Búsqueda en Base de Datos en Middleware
Aquí viene la trampa: Next.js middleware se ejecuta en Edge Runtime, lo que significa despedirse de muchas de esas cómodas APIs 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 Tienda KV
Esta es mi favorita. Almacena tus slugs en una tienda KV como Vercel KV, Upstash Redis, o Cloudflare KV. Las búsquedas en Edge en 1-5ms significan demora 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 compilación que mantienen las cosas apretadas 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 oferta:
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 clase que se ajuste a requisitos de cumplimiento como un guante? Una base de datos por tenant es el camino a seguir. El ramificado de Neon y los precios por base de datos de PlanetScale hacen esto factible, incluso para pequeñas fortunas en 2026.
Soporte de Dominio Personalizado
Aquí es donde se encuentran implementación y vanidad. Los tenants necesitan que sus dominios registrados con CNAME personalizado apunten a tu forma. ¿Y SSL? Cada enlace en el que se hace clic necesita brillar con ese HTTPS.
API de Dominios Personalizados de Vercel
La API de Vercel hace esto 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) {
// Almacena la asignación de 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 Platforms Starter Kit — es una implementación modelo.
Cloudflare para SaaS
Esto es ideal para los gestores de DNS personalizados sofisticados, ya que "SSL para SaaS" de Cloudflare incluye provisión de SSL y proxies también.
Rendimiento y Almacenamiento en Caché
Cuando se manejan solicitudes, cada nanosegundo cuenta. El truco es acelerar la resolución de tenants.
Almacena en Caché la Resolución de Tenant
Ahorrarás el día (y el 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 sin embargo, 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 almacenada en caché únicamente debido a la reescritura de /dashboard a /acme/dashboard. Sin configuraciones adicionales, solo disfruta de la gloria de la magia de Next.js.
Configuraciones de Implementación
Decisiones, decisiones. Comparemos opciones de implementación:
| Característica | Vercel Pro | Cloudflare Pages | Auto-alojado (Docker) |
|---|---|---|---|
| Dominios Comodín | ✅ | ✅ | ✅ (manual) |
| API de Dominios Personalizados | ✅ | ✅ | ❌ (manual) |
| Middleware de Edge | ✅ | ✅ | ❌ (solo Node) |
| SSL Automático | ✅ | ✅ | ⚠️ Let's Encrypt |
| Precios | $20/puesto/mes + uso | $5/mes + uso | $50-200/mes servidor |
| Máx. 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-alojadas te dan flexibilidad y asequibilidad.
Hemos navegado maravillas multi-tenant con cada método, así que si estás considerando tu próximo paso, nuestras capacidades de desarrollo Next.js podrían ser tu guía.
Consideraciones de Seguridad
Multi-tenancy es súper genial hasta que no lo es. Cada tenant espera que sus datos estén seguros — sin fugas, sin deslices.
Lista de Verificación de Aislamiento de Tenant
- Filtra por ID de Tenant: Nunca confíes en URLs solas. Las verificaciones de back-end importan también.
- PostgreSQL RLS: Es como tener un detalle de seguridad en tu base de datos 24/7.
- Sanitiza Subdomios: No te equivoques nunca — permite solo
[a-z0-9-]. Evita tomas de control de subdomios. - Limita Tasa por Tenant: Los encabezados dinámicos para el throttle de API pueden salvarte.
- Registra, Audita, Revisa: Cada operación de escritura debe decir "gotcha". La confianza infunde confianza.
// Valida 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 subdomios, necesitas CORS para que no lance 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
}
Pruebas de Middleware Multi-Tenant Localmente
Los dolores de cabeza abundan si el desarrollo local no soporta subdomios. Aquí está cómo sueñas de nuevo.
Opción 1: Edita `/etc/hosts`
# /etc/hosts
127.0.0.1 tuapp.local
127.0.0.1 acme.tuapp.local
127.0.0.1 globex.tuapp.local
Luego actualiza tu middleware para reconocer .tuapp.local durante desarrollo:
const ROOT_DOMAIN = process.env.NODE_ENV === 'development'
? 'tuapp.local:3000'
: 'tuapp.com';
Opción 2: Usa nip.io o sslip.io
Estos servicios sacan asignaciones IP mágicas:
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 Subdomios Personalizados
Usa ngrok (o similares) para túneles rápidos:
ngrok http 3000 --hostname=*.tu-dominio-dev.ngrok.io
Las pruebas no deberían ser más difíciles que escenarios del mundo real.
Escribiendo 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.tuapp.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('permite solicitudes de dominio raíz', async () => {
const req = new NextRequest('https://tuapp.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.tuapp.com/');
const res = await middleware(req);
expect(res.status).toBe(307); // Redirige
});
});
¿Pensando en el panorama general? Ya sea que sea la elección entre Next.js o Astro, considera subirse a nuestro viaje de insights de capa CMS headless.
Recuerda, explorar significa preguntar, adaptarse y crecer. Comunícate con nosotros para una sesión de lluvia de ideas o profundiza en nuestro precios si apuntas a asociación de primera categoría.