¿Qué es el Middleware en Next.js y por qué debería importarte?
Si alguna vez has necesitado interceptar una solicitud antes de que llegue a tu aplicación, ya sabes lo frustrante que puede ser hacerlo página por página. Bueno, el middleware en Next.js resuelve exactamente eso: es una función que se ejecuta antes de que cualquier solicitud toque tus rutas. Piénsalo como un portero digital que inspecciona cada petición HTTP y decide qué hacer con ella — dejarla pasar, redirigirla, modificar sus cabeceras o bloquearla por completo.
Lo interesante es que, a diferencia de los controladores de ruta o los Server Components, el middleware opera en el Edge Runtime. Esto significa que se ejecuta en servidores distribuidos geográficamente cerca de tus usuarios, con una latencia mínima. Básicamente, toma decisiones rápidas antes de que el servidor principal se entere de nada.
En Next.js 15 con el App Router, el middleware se ha convertido en pieza clave. Ya sea que necesites proteger rutas, gestionar la internacionalización, implementar rate limiting o controlar accesos por roles, es tu primera línea de defensa. Y honestamente, una vez que empiezas a usarlo bien, no hay vuelta atrás.
Configuración básica del Middleware
Configurar el middleware es bastante sencillo. Se define en un archivo llamado middleware.ts (o middleware.js) ubicado en la raíz de tu proyecto, al mismo nivel que las carpetas app o pages. Un detalle importante: solo puede existir un único archivo de middleware por proyecto.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Tu lógica aquí
console.log('Solicitud interceptada:', request.nextUrl.pathname);
// Continuar con la solicitud normalmente
return NextResponse.next();
}
// Configuración del matcher para definir en qué rutas se ejecuta
export const config = {
matcher: [
// Excluir archivos estáticos y API internas
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};
Comprendiendo el objeto NextRequest
El objeto NextRequest extiende la API estándar de Request del navegador y te da acceso a información bastante útil:
- nextUrl: URL parseada con datos adicionales como
pathname,searchParamsylocale. - cookies: Acceso directo a las cookies con métodos como
get(),set()ydelete(). - headers: Las cabeceras HTTP de la solicitud entrante.
- geo: Información de geolocalización del usuario (disponible en plataformas como Vercel).
- ip: La dirección IP del cliente.
Respuestas disponibles en el Middleware
Desde el middleware puedes ejecutar cuatro acciones principales con NextResponse:
import { NextResponse } from 'next/server';
// 1. Continuar con la solicitud (opcionalmente modificando cabeceras)
NextResponse.next();
// 2. Redirigir a otra URL
NextResponse.redirect(new URL('/login', request.url));
// 3. Reescribir la URL (el usuario ve la URL original, pero el servidor sirve otra)
NextResponse.rewrite(new URL('/api/proxy', request.url));
// 4. Responder directamente (sin pasar por la ruta)
NextResponse.json({ error: 'No autorizado' }, { status: 401 });
Protección de rutas con autenticación
Este es probablemente el caso de uso más común (y el que más dolores de cabeza ahorra). En lugar de verificar la autenticación en cada página individualmente — algo que todos hemos hecho y luego lamentado — puedes centralizar toda esa lógica en el middleware.
Autenticación básica con tokens JWT
Veamos cómo implementar protección de rutas usando JSON Web Tokens. Para trabajar con JWT en el Edge Runtime, utilizaremos la biblioteca jose, que es ligera y totalmente compatible con este entorno:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || 'tu-clave-secreta'
);
// Rutas que requieren autenticación
const protectedRoutes = ['/dashboard', '/profile', '/settings', '/admin'];
// Rutas públicas (no requieren autenticación)
const publicRoutes = ['/login', '/register', '/forgot-password'];
async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload;
} catch (error) {
return null;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Verificar si la ruta es protegida
const isProtectedRoute = protectedRoutes.some(route =>
pathname.startsWith(route)
);
const isPublicRoute = publicRoutes.some(route =>
pathname.startsWith(route)
);
// Obtener el token de la cookie
const token = request.cookies.get('auth-token')?.value;
// Verificar el token
const user = token ? await verifyToken(token) : null;
// Si la ruta es protegida y no hay usuario autenticado, redirigir al login
if (isProtectedRoute && !user) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// Si el usuario ya está autenticado e intenta acceder a rutas públicas
if (isPublicRoute && user) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Agregar información del usuario a las cabeceras para los Server Components
const response = NextResponse.next();
if (user) {
response.headers.set('x-user-id', user.sub as string);
response.headers.set('x-user-role', user.role as string);
}
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|api/auth).*)',
],
};
Control de acceso basado en roles (RBAC)
En aplicaciones empresariales, no basta con saber si alguien está autenticado. También necesitas controlar qué puede hacer cada usuario según su rol. He visto proyectos donde esto se maneja de forma caótica en cada componente, y créeme, no es divertido de mantener. Mejor centralizarlo desde el principio:
// lib/rbac.ts
type Role = 'admin' | 'editor' | 'viewer';
interface RoutePermission {
path: string;
roles: Role[];
}
const routePermissions: RoutePermission[] = [
{ path: '/admin', roles: ['admin'] },
{ path: '/dashboard/analytics', roles: ['admin', 'editor'] },
{ path: '/dashboard/content', roles: ['admin', 'editor'] },
{ path: '/dashboard', roles: ['admin', 'editor', 'viewer'] },
];
export function hasAccess(pathname: string, userRole: Role): boolean {
const permission = routePermissions.find(rp =>
pathname.startsWith(rp.path)
);
// Si no hay permisos definidos, permitir acceso
if (!permission) return true;
return permission.roles.includes(userRole);
}
export function getRedirectForRole(userRole: Role): string {
switch (userRole) {
case 'admin':
return '/admin';
case 'editor':
return '/dashboard/content';
case 'viewer':
return '/dashboard';
default:
return '/';
}
}
// middleware.ts (sección RBAC)
import { hasAccess, getRedirectForRole } from './lib/rbac';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = request.cookies.get('auth-token')?.value;
const user = token ? await verifyToken(token) : null;
if (!user && pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (user) {
const userRole = user.role as Role;
if (!hasAccess(pathname, userRole)) {
// Redirigir al área apropiada según el rol del usuario
const redirectPath = getRedirectForRole(userRole);
return NextResponse.redirect(new URL(redirectPath, request.url));
}
}
return NextResponse.next();
}
Integración con Auth.js (NextAuth v5)
Auth.js (anteriormente NextAuth.js) sigue siendo la solución de autenticación más popular para Next.js, y con razón. La versión 5 se integra de forma nativa con el App Router y el middleware, lo que hace que la configuración sea bastante directa:
// auth.ts
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import GitHub from 'next-auth/providers/github';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Contraseña', type: 'password' },
},
async authorize(credentials) {
// Validar credenciales contra tu base de datos
const user = await validateUser(
credentials.email as string,
credentials.password as string
);
return user || null;
},
}),
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
if (isOnDashboard) {
if (isLoggedIn) return true;
return false; // Redirigir al login
}
return true;
},
},
});
// middleware.ts
import { auth } from './auth';
export default auth((req) => {
const { pathname } = req.nextUrl;
const isAuthenticated = !!req.auth;
// Proteger rutas del dashboard
if (pathname.startsWith('/dashboard') && !isAuthenticated) {
const loginUrl = new URL('/login', req.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return Response.redirect(loginUrl);
}
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
Compatibilidad con el Edge Runtime
Aquí hay un detalle que a muchos les pilla desprevenidos: el Edge Runtime no soporta todas las APIs de Node.js. Si tu adaptador de base de datos (como Prisma o Drizzle) necesita el runtime completo de Node.js, vas a tener que dividir tu configuración de autenticación en dos archivos. No es lo más elegante del mundo, pero funciona perfectamente:
// auth.config.ts - Configuración compatible con Edge
import type { NextAuthConfig } from 'next-auth';
export const authConfig: NextAuthConfig = {
pages: {
signIn: '/login',
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const protectedPaths = ['/dashboard', '/settings', '/admin'];
const isProtected = protectedPaths.some(path =>
nextUrl.pathname.startsWith(path)
);
if (isProtected && !isLoggedIn) {
return false;
}
return true;
},
},
providers: [], // Los providers se añaden en auth.ts
};
// auth.ts - Configuración completa con adaptadores de BD
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from './lib/prisma';
import GitHub from 'next-auth/providers/github';
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
],
});
// middleware.ts - Usa solo la configuración Edge-compatible
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export default NextAuth(authConfig).auth;
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
El patrón Data Access Layer: tu red de seguridad real
Vamos a hablar de algo que muchos desarrolladores pasan por alto. El middleware es una excelente primera línea de defensa, pero nunca debe ser tu única capa de seguridad. Next.js recomienda oficialmente implementar un Data Access Layer (DAL) — básicamente, una capa que verifica autenticación y autorización en cada punto de acceso a datos.
¿Y por qué no basta con el middleware? Porque solo puede verificar cookies y cabeceras. No tiene forma de garantizar que todas las funciones internas de tu aplicación estén protegidas, especialmente los Server Actions y las funciones de fetching que podrían llamarse desde múltiples lugares.
// lib/dal.ts
import 'server-only';
import { cookies } from 'next/headers';
import { cache } from 'react';
import { redirect } from 'next/navigation';
import { prisma } from './prisma';
// Función cacheada para obtener el usuario actual
export const getCurrentUser = cache(async () => {
const cookieStore = await cookies();
const sessionToken = cookieStore.get('session-token')?.value;
if (!sessionToken) {
return null;
}
const session = await prisma.session.findUnique({
where: { token: sessionToken },
include: { user: true },
});
if (!session || session.expiresAt < new Date()) {
return null;
}
return session.user;
});
// Función que exige autenticación
export async function requireAuth() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return user;
}
// Función que exige un rol específico
export async function requireRole(role: string) {
const user = await requireAuth();
if (user.role !== role) {
redirect('/unauthorized');
}
return user;
}
// lib/data/articles.ts
import 'server-only';
import { requireAuth, requireRole } from '../dal';
import { prisma } from '../prisma';
// DTO: Solo retornamos los campos necesarios
interface ArticleDTO {
id: string;
title: string;
excerpt: string;
publishedAt: Date;
}
export async function getArticles(): Promise<ArticleDTO[]> {
// Verificar autenticación ANTES de consultar la BD
const user = await requireAuth();
const articles = await prisma.article.findMany({
where: { authorId: user.id },
select: {
id: true,
title: true,
excerpt: true,
publishedAt: true,
},
orderBy: { publishedAt: 'desc' },
});
return articles;
}
export async function deleteArticle(id: string) {
// Solo administradores pueden eliminar artículos
const user = await requireRole('admin');
await prisma.article.delete({
where: { id, authorId: user.id },
});
}
La belleza de este patrón es que la seguridad queda integrada directamente en la capa de datos. Incluso si alguien encontrara una forma de eludir el middleware (y sí, pasa), el DAL bloqueará cualquier acceso no autorizado.
Rate Limiting en el Middleware
Proteger tu aplicación contra abusos y ataques de denegación de servicio no es opcional — es esencial. Y el middleware resulta ser el lugar perfecto para implementar rate limiting, porque intercepta las solicitudes antes de que consuman recursos del servidor.
Implementación con Upstash Redis
La solución más popular (y compatible con el Edge Runtime) es @upstash/ratelimit combinado con Upstash Redis. La configuración es más sencilla de lo que parece:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// Configurar límite: 10 solicitudes por ventana de 10 segundos
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '10 s'),
analytics: true,
prefix: '@upstash/ratelimit',
});
export async function middleware(request: NextRequest) {
// Solo aplicar rate limiting a rutas de API
if (request.nextUrl.pathname.startsWith('/api')) {
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? '127.0.0.1';
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: 'Demasiadas solicitudes. Intenta de nuevo más tarde.' },
{
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
},
}
);
}
// Agregar cabeceras de rate limit a la respuesta
const response = NextResponse.next();
response.headers.set('X-RateLimit-Limit', limit.toString());
response.headers.set('X-RateLimit-Remaining', remaining.toString());
return response;
}
return NextResponse.next();
}
Rate limiting sin dependencias externas
¿No quieres añadir Redis a tu stack? Lo entiendo. Puedes implementar un rate limiter básico en memoria. Eso sí, ten en cuenta que esta solución no funciona en entornos distribuidos con múltiples instancias. Para desarrollo o proyectos pequeños, sin embargo, cumple su función:
// lib/rate-limit.ts
const rateLimitMap = new Map<string, { count: number; lastReset: number }>();
export function rateLimit(
identifier: string,
limit: number = 10,
windowMs: number = 60000
): { success: boolean; remaining: number } {
const now = Date.now();
const record = rateLimitMap.get(identifier);
if (!record || now - record.lastReset > windowMs) {
rateLimitMap.set(identifier, { count: 1, lastReset: now });
return { success: true, remaining: limit - 1 };
}
if (record.count >= limit) {
return { success: false, remaining: 0 };
}
record.count++;
return { success: true, remaining: limit - record.count };
}
Geolocalización y redirecciones inteligentes
Cuando despliegas tu app en plataformas como Vercel, el middleware tiene acceso a datos de geolocalización del usuario. Esto abre la puerta a personalizar la experiencia según la ubicación del visitante, algo que tus usuarios van a agradecer mucho.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Mapeo de países a idiomas
const countryToLocale: Record<string, string> = {
US: 'en',
GB: 'en',
ES: 'es',
MX: 'es',
AR: 'es',
CO: 'es',
FR: 'fr',
DE: 'de',
BR: 'pt',
};
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// No redirigir si ya tiene un prefijo de idioma
const hasLocalePrefix = /^\/(en|es|fr|de|pt)(\/|$)/.test(pathname);
if (hasLocalePrefix) {
return NextResponse.next();
}
// Detectar idioma por geolocalización
const country = request.geo?.country || 'US';
const locale = countryToLocale[country] || 'en';
// Verificar si el usuario tiene una preferencia guardada
const preferredLocale = request.cookies.get('preferred-locale')?.value;
const targetLocale = preferredLocale || locale;
// Redirigir a la versión localizada
return NextResponse.redirect(
new URL(`/${targetLocale}${pathname}`, request.url)
);
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico|images).*)'],
};
Integración con next-intl
Para proyectos de internacionalización más completos, la biblioteca next-intl proporciona un middleware especializado que se encarga de todo: detección de idioma, redirecciones y reescritura de URLs. Así de simple queda:
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/', '/(es|en|fr|de)/:path*'],
};
// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'es', 'fr', 'de'],
defaultLocale: 'en',
localeDetection: true,
localePrefix: 'as-needed',
});
Composición de múltiples middlewares
Aquí es donde las cosas se ponen interesantes. En aplicaciones reales, vas a necesitar combinar varias funcionalidades en un solo middleware. Y como Next.js solo permite un archivo de middleware por proyecto, toca ser creativo con la composición.
Patrón de cadena de middlewares
Este patrón es probablemente mi favorito para organizar lógica compleja. La idea es simple: encadenar funciones de middleware que se ejecutan secuencialmente:
// lib/middleware-chain.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
type MiddlewareFunction = (
request: NextRequest,
response: NextResponse
) => Promise<NextResponse | Response | undefined>;
export function createMiddlewareChain(...middlewares: MiddlewareFunction[]) {
return async function middleware(request: NextRequest) {
let response = NextResponse.next();
for (const mw of middlewares) {
const result = await mw(request, response);
// Si el middleware retorna una redirección o respuesta directa, detener la cadena
if (result instanceof Response && result.status !== 200) {
return result;
}
if (result) {
response = result as NextResponse;
}
}
return response;
};
}
// middleware.ts
import { createMiddlewareChain } from './lib/middleware-chain';
import { authMiddleware } from './lib/middlewares/auth';
import { rateLimitMiddleware } from './lib/middlewares/rate-limit';
import { loggingMiddleware } from './lib/middlewares/logging';
import { securityHeadersMiddleware } from './lib/middlewares/security';
export default createMiddlewareChain(
loggingMiddleware,
securityHeadersMiddleware,
rateLimitMiddleware,
authMiddleware
);
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Cabeceras de seguridad
El middleware también es el lugar ideal para añadir cabeceras de seguridad HTTP a todas tus respuestas. Es una de esas cosas que llevan cinco minutos de configurar y te ahorran muchos problemas potenciales:
// lib/middlewares/security.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function securityHeadersMiddleware(
request: NextRequest,
response: NextResponse
) {
// Content Security Policy
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"
);
// Prevenir clickjacking
response.headers.set('X-Frame-Options', 'DENY');
// Prevenir MIME sniffing
response.headers.set('X-Content-Type-Options', 'nosniff');
// Referrer Policy
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions Policy
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
// Strict Transport Security
response.headers.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
return response;
}
Optimización del rendimiento del Middleware
Esto es algo que no puedes ignorar: el middleware se ejecuta en cada solicitud que coincida con tu matcher. Un middleware lento puede degradar toda tu aplicación. He visto esto en producción y no es bonito.
Prácticas recomendadas de rendimiento
- Usa matchers específicos: Evita ejecutar el middleware en rutas que no lo necesitan, como archivos estáticos o imágenes.
- Mantén la lógica ligera: Nada de consultas a bases de datos ni cálculos pesados. El middleware debe tomar decisiones rápidas basándose en cookies, cabeceras y URLs.
- Evita operaciones de I/O innecesarias: Cada llamada a una API externa suma latencia.
- Usa early returns: Sal del middleware lo antes posible cuando no necesites procesar la solicitud.
// middleware.ts - Optimizado para rendimiento
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Early return para archivos estáticos (el matcher debería filtrar estos,
// pero es una capa adicional de seguridad)
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/favicon') ||
pathname.includes('.')
) {
return NextResponse.next();
}
// Solo verificar autenticación en rutas protegidas
if (pathname.startsWith('/dashboard') || pathname.startsWith('/admin')) {
const token = request.cookies.get('session')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Verificación rápida del token sin llamadas externas
// (solo validar estructura y expiración)
try {
const payload = JSON.parse(
atob(token.split('.')[1])
);
if (payload.exp * 1000 < Date.now()) {
return NextResponse.redirect(new URL('/login', request.url));
}
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return NextResponse.next();
}
Configuración avanzada del Matcher
El matcher es tu herramienta principal para controlar dónde se ejecuta el middleware. Soporta expresiones regulares y múltiples patrones, así que aprovéchalo al máximo:
export const config = {
matcher: [
// Proteger todas las rutas del dashboard
'/dashboard/:path*',
// Proteger las rutas de API (excepto auth)
'/api/((?!auth|public).*)',
// Proteger rutas de administración
'/admin/:path*',
// Aplicar a la raíz para detección de idioma
'/',
],
};
Limitaciones del Edge Runtime y cómo superarlas
Antes de lanzarte a implementar todo en el middleware, es fundamental que conozcas las limitaciones del Edge Runtime. Más de un desarrollador se ha topado con errores misteriosos en producción por ignorar esto:
- Sin acceso al sistema de archivos: No puedes usar
fs,pathni otros módulos de Node.js que toquen el disco. - Sin módulos nativos de Node.js: Bibliotecas como
bcrypt,sharpo drivers nativos de bases de datos simplemente no van a funcionar. - Tamaño limitado: El código del middleware tiene un límite de tamaño en el Edge Runtime.
- Sin estado persistente: Las variables globales no persisten entre invocaciones en entornos serverless.
- No puedes leer el body de la solicitud: Solo tienes acceso a cabeceras, cookies y URL.
Estrategias para manejar las limitaciones
Cuando necesitas funcionalidades que el Edge Runtime no soporta, la solución más práctica es delegar a una API Route que se ejecute en el runtime completo de Node.js:
// middleware.ts
export async function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value;
if (token && request.nextUrl.pathname.startsWith('/dashboard')) {
// Delegar la verificación pesada a una API Route
// que se ejecuta en el Node.js Runtime completo
const verifyUrl = new URL('/api/verify-session', request.url);
const verifyResponse = await fetch(verifyUrl, {
headers: {
'Authorization': `Bearer ${token}`,
'X-Middleware-Verify': 'true',
},
});
if (!verifyResponse.ok) {
return NextResponse.redirect(new URL('/login', request.url));
}
const userData = await verifyResponse.json();
const response = NextResponse.next();
response.headers.set('x-user-data', JSON.stringify(userData));
return response;
}
return NextResponse.next();
}
Testing del Middleware
Probar el middleware es algo que muchos dejan para después (o directamente se saltan), pero es esencial para garantizar que tus reglas de seguridad funcionen como esperas. Aquí tienes cómo escribir tests unitarios con Vitest:
// __tests__/middleware.test.ts
import { describe, it, expect, vi } from 'vitest';
import { middleware } from '../middleware';
import { NextRequest } from 'next/server';
function createMockRequest(
url: string,
cookies: Record<string, string> = {}
): NextRequest {
const request = new NextRequest(new URL(url, 'http://localhost:3000'));
Object.entries(cookies).forEach(([name, value]) => {
request.cookies.set(name, value);
});
return request;
}
describe('Middleware', () => {
it('debe redirigir al login cuando no hay token en ruta protegida', async () => {
const request = createMockRequest('/dashboard');
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toContain('/login');
});
it('debe permitir acceso a rutas protegidas con token válido', async () => {
const validToken = createValidJWT({ sub: '123', role: 'admin' });
const request = createMockRequest('/dashboard', {
'auth-token': validToken,
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
it('debe redirigir usuarios autenticados que visitan /login', async () => {
const validToken = createValidJWT({ sub: '123', role: 'viewer' });
const request = createMockRequest('/login', {
'auth-token': validToken,
});
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get('location')).toContain('/dashboard');
});
it('debe bloquear acceso a /admin para usuarios sin rol admin', async () => {
const editorToken = createValidJWT({ sub: '456', role: 'editor' });
const request = createMockRequest('/admin', {
'auth-token': editorToken,
});
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get('location')).not.toContain('/admin');
});
it('debe permitir acceso a rutas públicas sin autenticación', async () => {
const request = createMockRequest('/about');
const response = await middleware(request);
expect(response.status).toBe(200);
});
});
Patrones avanzados y casos de uso reales
A/B Testing con Middleware
Una de las aplicaciones más interesantes del middleware es el A/B testing. La ventaja principal es que la decisión se toma antes de renderizar la página, así que no hay ningún parpadeo visible para el usuario:
// middleware.ts - A/B Testing
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Solo aplicar A/B testing en la página de inicio
if (pathname === '/') {
// Verificar si el usuario ya tiene una variante asignada
const existingVariant = request.cookies.get('ab-variant')?.value;
if (!existingVariant) {
// Asignar variante aleatoriamente (50/50)
const variant = Math.random() < 0.5 ? 'control' : 'experiment';
const response = NextResponse.rewrite(
new URL(`/home/${variant}`, request.url)
);
// Guardar la variante en una cookie para consistencia
response.cookies.set('ab-variant', variant, {
maxAge: 60 * 60 * 24 * 30, // 30 días
httpOnly: true,
});
return response;
}
// Usar la variante existente
return NextResponse.rewrite(
new URL(`/home/${existingVariant}`, request.url)
);
}
return NextResponse.next();
}
Feature Flags en el Edge
También puedes usar el middleware para evaluar feature flags antes de que la página se renderice. Esto te permite lanzar funcionalidades gradualmente sin necesidad de redesplegar:
// middleware.ts - Feature Flags
const FEATURE_FLAGS: Record<string, { enabled: boolean; percentage: number }> = {
'new-checkout': { enabled: true, percentage: 25 },
'redesigned-header': { enabled: true, percentage: 50 },
};
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const response = NextResponse.next();
// Evaluar feature flags y pasarlos como cabeceras
for (const [flag, config] of Object.entries(FEATURE_FLAGS)) {
if (!config.enabled) continue;
const userHash = hashString(
request.cookies.get('user-id')?.value || request.ip || 'anon'
);
const isEnabled = (userHash % 100) < config.percentage;
response.headers.set(`x-feature-${flag}`, isEnabled ? 'true' : 'false');
}
return response;
}
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return Math.abs(hash);
}
Mantenimiento programado
¿Necesitas poner tu app en modo mantenimiento? El middleware te permite hacerlo sin tocar una sola línea del código de tus rutas:
// middleware.ts - Modo de mantenimiento
export function middleware(request: NextRequest) {
const isMaintenanceMode = process.env.MAINTENANCE_MODE === 'true';
if (isMaintenanceMode) {
const { pathname } = request.nextUrl;
// Permitir acceso a la página de mantenimiento y archivos estáticos
if (
pathname === '/maintenance' ||
pathname.startsWith('/_next') ||
pathname.startsWith('/api/health')
) {
return NextResponse.next();
}
// Permitir acceso a administradores
const adminToken = request.cookies.get('admin-bypass')?.value;
if (adminToken === process.env.ADMIN_BYPASS_TOKEN) {
return NextResponse.next();
}
// Redirigir todo lo demás a la página de mantenimiento
return NextResponse.rewrite(new URL('/maintenance', request.url));
}
return NextResponse.next();
}
Errores comunes y cómo evitarlos
Estos son los errores que veo con más frecuencia cuando los desarrolladores trabajan con middleware en Next.js. Algunos los he cometido yo mismo, así que toma nota:
- Confiar exclusivamente en el middleware para la seguridad: Es tu primera línea de defensa, no la única. Siempre implementa verificaciones adicionales en el DAL y los Server Actions.
- Ejecutar el middleware en todas las rutas: Sin un matcher adecuado, se ejecutará hasta para archivos estáticos e imágenes. Un desperdicio de recursos que se nota en producción.
- Operaciones pesadas en el middleware: Consultas a bases de datos, llamadas a APIs externas o cálculos complejos van a ralentizar cada solicitud. Mantén todo ligero y rápido.
- No manejar errores correctamente: Si tu middleware lanza una excepción no capturada, la solicitud fallará por completo. Siempre usa bloques try-catch en la lógica crítica.
- Olvidar las limitaciones del Edge Runtime: Intentar usar módulos como
fs,crypto(el nativo, no la Web Crypto API) obcryptte dará errores en producción que no verás en desarrollo. - Bucles de redirección: Si rediriges
/loginal middleware que a su vez redirige a/login, acabarás con un bucle infinito. Siempre excluye las rutas de destino de tus redirecciones.
Conclusión: construyendo una arquitectura de seguridad robusta
El middleware en Next.js 15 con el App Router es una herramienta increíblemente versátil. Va mucho más allá de la simple protección de rutas: autenticación, RBAC, rate limiting, geolocalización, A/B testing, feature flags... las posibilidades son amplias y bien pensadas.
Pero no te olvides de la regla de oro de la seguridad web: la defensa en profundidad. El middleware es tu primera línea, pero necesita complementarse con:
- Un Data Access Layer que verifique autenticación y autorización en cada punto de acceso a datos.
- Validación en Server Actions para asegurar que las mutaciones de datos sean legítimas.
- Cabeceras de seguridad HTTP para proteger contra ataques como XSS y clickjacking.
- Rate limiting para prevenir abusos y ataques de denegación de servicio.
Con estos patrones bien implementados, tu aplicación Next.js estará lista para producción con la confianza de que tienes una arquitectura de seguridad sólida y un rendimiento óptimo. Así que, ¿qué esperas para empezar a implementarlo?