El middleware de Next.js se ejecuta antes de que una solicitud se complete, en el Edge Runtime, ideal para autenticación, redirecciones geográficas, pruebas A/B y limitación de velocidad.
¿Qué es el Middleware de Next.js?
El middleware es una función que se ejecuta antes de que la solicitud llegue a tu página.
// Request flow in Next.js
//
// Client Request
// │
// ▼
// ┌─────────────┐
// │ Middleware │ ← Runs here (Edge Runtime)
// └─────────────┘
// │
// ▼
// ┌─────────────┐
// │ Page/API │ ← Your application code
// └─────────────┘
// │
// ▼
// Client ResponseConfiguración básica
Crea un archivo middleware.ts en la raíz del proyecto.
// middleware.ts (project root)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Log every request
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
// Continue to the next handler
return NextResponse.next();
}
// Only run on specific paths
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};Configuración del Matcher
El matcher determina qué rutas activan el middleware.
// Match specific routes
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
};
// Match all routes except static files and API
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
// Multiple matchers with different patterns
export const config = {
matcher: [
'/dashboard/:path*', // All dashboard routes
'/admin/:path*', // All admin routes
'/api/protected/:path*', // Protected API routes
'/profile', // Exact match
],
};
// Conditional matching with has/missing (Next.js 13.1+)
export const config = {
matcher: [
{
source: '/api/:path*',
has: [
{ type: 'header', key: 'authorization' },
],
},
],
};Middleware de autenticación
Proteger rutas que requieren autenticación.
Verificación de JWT
Verificar tokens JWT directamente en el middleware.
// middleware.ts — JWT Authentication
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!
);
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip public paths
if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) {
return NextResponse.next();
}
// Get token from cookie or Authorization header
const token =
request.cookies.get('auth-token')?.value ||
request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
// Redirect to login with return URL
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
try {
// Verify JWT token
const { payload } = await jwtVerify(token, JWT_SECRET);
// Add user info to headers for downstream use
const response = NextResponse.next();
response.headers.set('x-user-id', payload.sub as string);
response.headers.set('x-user-role', payload.role as string);
return response;
} catch (error) {
// Token expired or invalid — redirect to login
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
const response = NextResponse.redirect(loginUrl);
response.cookies.delete('auth-token');
return response;
}
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*', '/api/protected/:path*'],
};Control de acceso basado en roles
Control de acceso según roles de usuario.
// Role-based access control in middleware
const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/admin': ['admin'],
'/dashboard': ['admin', 'editor', 'viewer'],
'/editor': ['admin', 'editor'],
'/api/admin': ['admin'],
};
function hasPermission(pathname: string, role: string): boolean {
for (const [route, roles] of Object.entries(ROUTE_PERMISSIONS)) {
if (pathname.startsWith(route)) {
return roles.includes(role);
}
}
return true; // No restriction for unmatched routes
}
export async function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
if (!token) return NextResponse.redirect(new URL('/login', request.url));
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
const role = payload.role as string;
const { pathname } = request.nextUrl;
if (!hasPermission(pathname, role)) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}Patrones de redirección
Ideal para redirecciones condicionales.
Redirecciones geográficas
Redirigir usuarios según su geolocalización.
// Geo-based redirect (Vercel Edge)
export function middleware(request: NextRequest) {
const country = request.geo?.country || 'US';
const city = request.geo?.city || 'Unknown';
// Redirect EU users to GDPR-compliant page
const EU_COUNTRIES = [
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI',
'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU',
'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
];
if (EU_COUNTRIES.includes(country) &&
request.nextUrl.pathname === '/') {
return NextResponse.redirect(new URL('/eu', request.url));
}
// Add geo headers for downstream use
const response = NextResponse.next();
response.headers.set('x-country', country);
response.headers.set('x-city', city);
return response;
}Detección de idioma
Detectar idioma preferido y redirigir.
// Locale detection middleware
const SUPPORTED_LOCALES = ['en', 'fr', 'de', 'es', 'ja', 'ko', 'zh'];
const DEFAULT_LOCALE = 'en';
function getPreferredLocale(request: NextRequest): string {
// Check cookie first (user preference)
const cookieLocale = request.cookies.get('locale')?.value;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
return cookieLocale;
}
// Parse Accept-Language header
const acceptLang = request.headers.get('accept-language') || '';
const preferred = acceptLang
.split(',')
.map(lang => lang.split(';')[0].trim().substring(0, 2))
.find(lang => SUPPORTED_LOCALES.includes(lang));
return preferred || DEFAULT_LOCALE;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if locale is already in the path
const hasLocale = SUPPORTED_LOCALES.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (hasLocale) return NextResponse.next();
// Redirect to locale-prefixed path
const locale = getPreferredLocale(request);
const url = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.redirect(url);
}Limitación de velocidad
Implementar limitación en el borde.
// Simple rate limiting with in-memory store
// Note: This works per-edge-node; use Redis for distributed rate limiting
const rateLimit = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT = 100; // requests
const RATE_WINDOW = 60 * 1000; // per minute
function isRateLimited(ip: string): boolean {
const now = Date.now();
const record = rateLimit.get(ip);
if (!record || now > record.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + RATE_WINDOW });
return false;
}
record.count++;
return record.count > RATE_LIMIT;
}
export function middleware(request: NextRequest) {
// Only rate-limit API routes
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ||
request.ip ||
'127.0.0.1';
if (isRateLimited(ip)) {
return new NextResponse(
JSON.stringify({ error: 'Too many requests' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': '60',
},
}
);
}
return NextResponse.next();
}Ventana deslizante
Algoritmo de ventana deslizante.
// Rate limiting with Upstash Redis (production-ready)
// npm install @upstash/ratelimit @upstash/redis
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
analytics: true,
prefix: 'api-ratelimit',
});
export async function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
const ip = request.ip ?? '127.0.0.1';
const { success, limit, reset, remaining } = await ratelimit.limit(ip);
const response = success
? NextResponse.next()
: new NextResponse(JSON.stringify({ error: 'Rate limit exceeded' }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
});
// Always set rate limit headers
response.headers.set('X-RateLimit-Limit', limit.toString());
response.headers.set('X-RateLimit-Remaining', remaining.toString());
response.headers.set('X-RateLimit-Reset', reset.toString());
return response;
}Encabezados y seguridad
Lugar ideal para encabezados de seguridad.
Encabezados de seguridad
// Security headers middleware
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Prevent XSS attacks
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
);
// Prevent clickjacking
response.headers.set('X-Frame-Options', 'DENY');
// Prevent MIME type sniffing
response.headers.set('X-Content-Type-Options', 'nosniff');
// Enable HSTS
response.headers.set(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
// Referrer policy
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions policy
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
return response;
}Encabezados CORS
Manejar CORS a nivel de middleware.
// CORS middleware for API routes
const ALLOWED_ORIGINS = [
'https://myapp.com',
'https://staging.myapp.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean);
export function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
const origin = request.headers.get('origin') || '';
const isAllowed = ALLOWED_ORIGINS.includes(origin);
// Handle preflight OPTIONS request
if (request.method === 'OPTIONS') {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
const response = NextResponse.next();
if (isAllowed) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
}
return response;
}Pruebas A/B
Pruebas A/B del lado del servidor sin parpadeo.
// A/B Testing middleware
export function middleware(request: NextRequest) {
// Only A/B test the homepage
if (request.nextUrl.pathname !== '/') {
return NextResponse.next();
}
// Check if user already has a variant assigned
const variant = request.cookies.get('ab-variant')?.value;
if (variant) {
// Rewrite to the variant page without changing URL
return NextResponse.rewrite(
new URL(`/variants/${variant}`, request.url)
);
}
// Assign a random variant (50/50 split)
const newVariant = Math.random() < 0.5 ? 'control' : 'experiment';
const response = NextResponse.rewrite(
new URL(`/variants/${newVariant}`, request.url)
);
// Persist variant in cookie for 30 days
response.cookies.set('ab-variant', newVariant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: 'lax',
});
return response;
}Reescritura de URL
Reescribir sin cambiar la URL del navegador.
// Multi-tenant rewriting
// app.mysite.com → /tenants/app
// docs.mysite.com → /tenants/docs
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const subdomain = hostname.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'mysite') {
return NextResponse.rewrite(
new URL(`/tenants/${subdomain}${request.nextUrl.pathname}`, request.url)
);
}
return NextResponse.next();
}
// Proxy API requests to external service
// /api/external/* → https://api.external-service.com/*
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/external/')) {
const path = request.nextUrl.pathname.replace('/api/external', '');
return NextResponse.rewrite(
new URL(`https://api.external-service.com${path}`)
);
}
return NextResponse.next();
}Encadenamiento de middleware
Componer múltiples funciones middleware.
// Composing multiple middleware functions
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
type MiddlewareFn = (
request: NextRequest,
response: NextResponse
) => NextResponse | Promise<NextResponse>;
function chain(
functions: MiddlewareFn[],
request: NextRequest
): Promise<NextResponse> {
return functions.reduce(
async (promise, fn) => {
const response = await promise;
return fn(request, response);
},
Promise.resolve(NextResponse.next())
);
}
// Individual middleware functions
const withLogging: MiddlewareFn = (request, response) => {
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
return response;
};
const withSecurityHeaders: MiddlewareFn = (request, response) => {
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
return response;
};
const withAuth: MiddlewareFn = (request, response) => {
const token = request.cookies.get('auth-token')?.value;
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return response;
};
// Compose all middleware
export function middleware(request: NextRequest) {
return chain([withLogging, withSecurityHeaders, withAuth], request);
}Limitaciones del Edge Runtime
El Edge Runtime tiene algunas limitaciones.
- Sin acceso al sistema de archivos
- Sin módulos nativos Node.js
- Solo Web APIs
- Límite de bundle 1MB
- Sin conexiones DB
- Tiempo de ejecución limitado
// What you CAN use in Edge Runtime:
// ✅ fetch, Request, Response, Headers, URL
// ✅ TextEncoder, TextDecoder
// ✅ crypto.subtle (Web Crypto API)
// ✅ structuredClone
// ✅ atob, btoa
// ✅ URLPattern
// ✅ EdgeDB, Upstash, PlanetScale drivers
// What you CANNOT use:
// ❌ fs (file system)
// ❌ path, os, child_process
// ❌ pg, mysql2, mongoose (traditional DB drivers)
// ❌ bcrypt (use bcryptjs or Web Crypto instead)
// ❌ Large npm packages that exceed 1MB bundleDepuración
Técnicas de depuración para middleware.
// Debugging middleware
export function middleware(request: NextRequest) {
// Log request details
console.log({
method: request.method,
url: request.url,
pathname: request.nextUrl.pathname,
cookies: Object.fromEntries(request.cookies.getAll().map(c => [c.name, c.value])),
headers: Object.fromEntries(request.headers),
geo: request.geo,
ip: request.ip,
});
// Return debug info in response headers (dev only)
const response = NextResponse.next();
if (process.env.NODE_ENV === 'development') {
response.headers.set('x-middleware-pathname', request.nextUrl.pathname);
response.headers.set('x-middleware-matched', 'true');
}
return response;
}Mejores prácticas
- Mantener el middleware ligero.
- Excluir archivos estáticos.
- Evitar llamadas a DB.
- Configurar encabezados de caché.
- Cookies para A/B.
- Implementar manejo de errores.
- Probar diferentes escenarios.
- Usar variables de entorno.
Conclusión
El middleware de Next.js es una herramienta poderosa en el borde. Domine estos patrones para aplicaciones robustas.
FAQ
¿El middleware se ejecuta en cada solicitud?
Por defecto sí, use el matcher para limitar.
¿Se puede usar una base de datos?
No directamente. Use fetch a una ruta API.
¿Diferencia entre redirect y rewrite?
Redirect cambia la URL del navegador, rewrite no.
¿Cómo manejar errores?
Envolver en try-catch y devolver una respuesta apropiada.