Middleware en Next.js 15: Guía Completa de Autenticación, Protección de Rutas y Patrones Avanzados

Domina el middleware de Next.js 15 con App Router: autenticación JWT, control de acceso por roles, rate limiting, geolocalización, A/B testing y el patrón Data Access Layer con ejemplos prácticos listos para producción.

¿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, searchParams y locale.
  • cookies: Acceso directo a las cookies con métodos como get(), set() y delete().
  • 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

  1. Usa matchers específicos: Evita ejecutar el middleware en rutas que no lo necesitan, como archivos estáticos o imágenes.
  2. 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.
  3. Evita operaciones de I/O innecesarias: Cada llamada a una API externa suma latencia.
  4. 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, path ni otros módulos de Node.js que toquen el disco.
  • Sin módulos nativos de Node.js: Bibliotecas como bcrypt, sharp o 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Olvidar las limitaciones del Edge Runtime: Intentar usar módulos como fs, crypto (el nativo, no la Web Crypto API) o bcrypt te dará errores en producción que no verás en desarrollo.
  6. Bucles de redirección: Si rediriges /login al 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?

Sobre el Autor Editorial Team

Our team of expert writers and editors.