Módulo 13: Proxy y configuración

Objetivos del módulo

  • Saber por qué orden pasa una petición antes de llegar a tu página, y en qué punto actúa cada herramienta: next.config, proxy.ts y la propia ruta
  • Escribir un proxy.ts que redirige, reescribe, añade cabeceras, maneja cookies y responde directamente
  • Entender qué ha cambiado de middleware.ts (Next.js 15) a proxy.ts (Next.js 16): nombre, función exportada, runtime y mensajes de error
  • Controlar dónde se ejecuta el proxy con matcher, incluidas las exclusiones y las condiciones has y missing, y probarlo sin arrancar el servidor
  • Configurar en next.config.ts redirecciones, reescrituras y cabeceras sin escribir código de petición
  • Usar variables de entorno con criterio: ficheros .env*, orden de carga, NEXT_PUBLIC_, qué se congela al compilar y qué se lee al arrancar
  • Validar la configuración al arrancar para que un .env incompleto falle pronto y con un mensaje claro
  • Reconocer los errores más frecuentes de este tema y saber arreglarlos

🔄 Cambia en Next.js 16.0. En Next.js 15 el fichero se llama middleware.ts, exporta una función middleware y se ejecuta por defecto en el runtime Edge. Si usas Next.js 15: todo lo que hace proxy.ts en este módulo funciona igual en middleware.ts cambiando el nombre del fichero y de la función; si usas APIs de Node.js (node:crypto, process.version…), añade runtime: 'nodejs' a su config (estable desde 15.5). El apartado 3 lo compara paso a paso. Los apartados 6 y 7 (next.config y variables de entorno) funcionan igual en las dos versiones, salvo lo marcado en 6.4 y 7.6.

💡 Consejo: Hasta ahora cada URL llegaba directa a su page.tsx o a su route.ts. En una aplicación real hay trabajo que hacer antes: llevar al login a quien no ha entrado, mantener vivas las URL antiguas, añadir cabeceras de seguridad a todo el sitio o esconder la dirección de una API. Este módulo cubre esa capa intermedia y la configuración que la rodea. Todo el código se ha compilado y ejecutado con Next.js 16.4 y la API EMPRESA; las comparaciones con Next.js 15 se han probado con 15.5.


1. Antes de la página: el viaje de una petición

Cuando el navegador pide /admin/informes, Next.js no busca directamente app/admin/informes/page.tsx. Antes pasa la petición por una serie de filtros, en un orden fijo. Algunos los configuras en next.config.ts (sin código, solo datos) y uno lo programas tú en proxy.ts. El orden, según la documentación oficial y comprobado con el laboratorio de este módulo, es este:

  1. headers() de next.config: apunta las cabeceras que llevará la respuesta.
  2. redirects() de next.config: si casa, responde con una redirección y el viaje termina.
  3. proxy.ts, si su matcher casa con la URL.
  4. rewrites() del grupo beforeFiles.
  5. Rutas de fichero: public/, _next/static/ y las páginas estáticas de app/.
  6. rewrites() del grupo afterFiles (es el grupo por defecto).
  7. Rutas dinámicas (app/productos/[id]).
  8. rewrites() del grupo fallback.

Diagrama vertical con los ocho pasos que recorre una petición en Next.js: headers, redirects, proxy, rewrites beforeFiles, rutas de fichero, rewrites afterFiles, rutas dinámicas y rewrites fallback, con ejemplos de la aplicación EMPRESA

Figura 1. El orden en que Next.js atiende una petición. Las redirecciones cortan el viaje; las reescrituras lo desvían sin que el navegador se entere.

Un ejemplo real del laboratorio lo deja claro. La aplicación tiene una redirección en next.config.ts de /admin/:ruta* a /gestion/:ruta*, y un proxy que manda al login a quien entra en /gestion sin cookie de sesión. Con curl -L, que sigue las redirecciones, se ve el recorrido completo:

curl -s -L -D - -o /dev/null http://localhost:3001/admin/informes
HTTP/1.1 307 Temporary Redirect
location: /gestion/informes
HTTP/1.1 307 Temporary Redirect
location: /login?desde=%2Fgestion%2Finformes
HTTP/1.1 200 OK

La primera respuesta la da redirects() (paso 2). El navegador vuelve a pedir /gestion/informes, y esta vez quien responde es el proxy (paso 3). La tercera petición, /login, ya llega a su página.

1.1. Tres herramientas, tres momentos

Herramienta Dónde Qué sabe de la petición Úsala para
redirects, rewrites, headers en next.config.ts Configuración Ruta, query, cabeceras y cookies (con has/missing) Reglas fijas: URL antiguas, cabeceras de seguridad, ocultar una API
proxy.ts Código que se ejecuta en cada petición que casa Todo: puedes leer, calcular, decidir Reglas que dependen de lógica: sesión, pruebas A/B, idioma, mantenimiento
page.tsx, route.ts, Server Actions La propia ruta Todo, y además los datos La comprobación definitiva de permisos y todo lo demás

📘 Concepto: La regla práctica es usar la herramienta más sencilla que resuelva el problema. Si una redirección no depende de nada más que de la URL, va en next.config.ts: no consume CPU en cada petición y se lee de un vistazo. Si necesitas lógica, va en el proxy. Y si necesitas los datos de la base de datos o del usuario completo, va en la ruta.


2. proxy.ts: código antes de la ruta 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 esta función se llama middleware: fichero middleware.ts y función export function middleware(), con el runtime Edge por defecto. Si usas Next.js 15: escribe el mismo código en middleware.ts con la función llamada middleware; la API (NextRequest, NextResponse, matcher) es la misma.

2.1. Qué es y dónde va

proxy.ts es un fichero especial que va en la raíz del proyecto, al lado de la carpeta app/ (o dentro de src/ si usas esa carpeta). No va dentro de app/. Exporta una función que Next.js llama con cada petición que casa con su matcher, antes de renderizar nada.

mi-proyecto/
├── app/
│   ├── layout.tsx
│   └── page.tsx
├── proxy.ts          ← aquí, al lado de app/
├── next.config.ts
└── package.json

El proxy más sencillo posible añade una cabecera a todas las respuestas:

// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const respuesta = NextResponse.next()
  respuesta.headers.set('x-ruta-pedida', request.nextUrl.pathname)
  return respuesta
}

Tres reglas sobre la exportación:

  1. La función se llama proxy (exportación con nombre) o es la exportación por defecto (export default function). Si la llamas de otra forma, el build falla (lo verás en el apartado 3.1).
  2. Solo puede haber una función de proxy por proyecto y un solo fichero. No hay un proxy por carpeta, como ocurre con los layout.tsx.
  3. Junto a la función puedes exportar un objeto config con el matcher. Si no lo exportas, el proxy se ejecuta en todas las peticiones, incluidos los ficheros de _next/static, las imágenes y lo que hay en public/.

📘 Concepto: ¿Por qué “proxy”? Un proxy es una pieza que se pone delante de un servidor y decide qué hacer con cada petición antes de que llegue: dejarla pasar, desviarla o contestar ella misma. Es exactamente lo que hace este fichero. Next.js lo llamaba middleware, pero ese nombre confundía con el middleware de Express, que es una cadena de funciones dentro de la aplicación. El equipo de Next.js explica el cambio en la documentación de proxy.js: el nombre nuevo deja claro que es una frontera de red delante de la aplicación, y que debe usarse como último recurso, cuando next.config no basta.

2.2. Lo que puede devolver

La función puede devolver cinco cosas, y cada una tiene un efecto distinto:

Devuelve Efecto Ejemplo
Nada (undefined) La petición sigue su camino sin cambios Un proxy que solo registra algo
NextResponse.next() Sigue su camino, pero puedes añadir cabeceras a la petición y a la respuesta, o cookies Identificador de petición
NextResponse.redirect(url) El navegador recibe un 307 y pide otra URL Login
NextResponse.rewrite(url) Next sirve otra ruta, pero la barra de direcciones no cambia Prueba A/B
Un Response propio (Response.json(...)) Responde el proxy; la ruta no llega a ejecutarse 401 en una API

La función puede ser async si necesitas await dentro. Recibe dos parámetros: request (un NextRequest) y event (un NextFetchEvent con el método waitUntil, para trabajo en segundo plano). Si prefieres no escribir los tipos, existe el tipo NextProxy:

// proxy.ts
import type { NextProxy } from 'next/server'

export const proxy: NextProxy = (request) => {
  console.log(request.method, request.nextUrl.pathname)
}

2.3. El runtime: Node.js, siempre

El código del proxy se ejecuta en el servidor, pero ¿en qué entorno? Next.js tiene dos runtimes:

  • Node.js: el normal. Tienes todo Node: node:crypto, node:fs, process.version, cualquier paquete de npm.
  • Edge: un entorno reducido, basado en las APIs web estándar (fetch, Request, crypto.subtle…), pensado para ejecutarse en servidores repartidos por el mundo (la “red de borde” de una CDN). Arranca muy rápido, pero no tiene las APIs de Node.

En Next.js 16, el proxy siempre usa Node.js y no se puede cambiar. Para comprobarlo, el proxy del laboratorio pone en cada respuesta una cabecera x-runtime que detecta el entorno (en Edge existe la variable global EdgeRuntime):

respuesta.headers.set('x-runtime', 'EdgeRuntime' in globalThis ? 'edge' : `node ${process.version}`)
x-runtime: node v24.19.0

Por eso en proxy.ts puedes importar node:crypto sin más, como hace el ejemplo completo del apartado 5. En Next.js 15 eso no se podía hacer con la configuración por defecto: es la diferencia más importante entre las dos versiones, y la detalla el apartado siguiente.


3. De middleware.ts (Next.js 15) a proxy.ts (Next.js 16) 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 el fichero es middleware.ts, la función middleware y el runtime por defecto es Edge. Si usas Next.js 15: renombra fichero y función en todos los ejemplos de este módulo, y añade runtime: 'nodejs' al config si el código usa APIs de Node.js; sin eso, el build pasa con un aviso, pero la ruta responde con un error 500.

La guía oficial de actualización resume el cambio en una frase: “renombra el fichero y la función”. Es verdad para el caso sencillo, pero hay detalles que la frase no cuenta, y algunos solo se descubren al compilar. Para conocerlos de verdad, he compilado y ejecutado el mismo middleware mínimo en un proyecto de Next.js 15.5 y en otro de Next.js 16.4, cambiando una cosa cada vez. El código de prueba es siempre este, con distintos config:

import { NextResponse } from 'next/server'

const rt = () => ('EdgeRuntime' in globalThis ? 'edge' : 'nodejs')

export function middleware() {
  const r = NextResponse.next()
  r.headers.set('x-runtime', rt())
  r.headers.set('x-via', 'middleware')
  return r
}

export const config = { matcher: '/gestion/:ruta*' }

3.1. Lo que hace cada versión

Prueba Next.js 15.5 Next.js 16.4
middleware.ts sin runtime Funciona. x-runtime: edge Funciona, con aviso de obsoleto en cada build. x-runtime: edge
middleware.ts + import 'node:crypto', sin runtime El build pasa con un aviso; /gestion responde 500 Mismo aviso al compilar (Edge sigue siendo el valor por defecto)
middleware.ts + process.version, sin runtime   Error de build: A Node.js API is used (process.version ...) which is not supported in the Edge Runtime
middleware.ts con runtime: 'nodejs' Funciona. x-runtime: nodejs, y node:crypto va bien (aunque Turbopack sigue mostrando el aviso de Edge al compilar) Funciona. x-runtime: nodejs
middleware.ts con runtime: 'edge' (escrito) Error de build: Use runtime 'experimental-edge' instead El mismo error
middleware.ts con runtime: 'experimental-edge'   Funciona, con dos avisos: experimental edge runtime y “The Edge Runtime is deprecated”
proxy.ts con export function proxy Se ignora sin aviso: el build no muestra la línea ƒ Middleware y las respuestas no llevan las cabeceras Funciona. x-runtime: nodejs sin configurar nada
proxy.ts con runtime: 'edge'   Error de build: Proxy does not support Edge runtime
proxy.ts con runtime: 'nodejs'   Error de build: Route segment config is not allowed in Proxy file
proxy.ts que exporta middleware   Error de build: Proxy is missing expected function export name
proxy.ts y middleware.ts a la vez   Error de build: Both middleware file "./middleware.ts" and proxy file "./proxy.ts" are detected
Línea en la salida de next build ƒ Middleware 39.2 kB ƒ Proxy (Middleware)
location de NextResponse.redirect(new URL('/login', request.url)) Absoluta: http://localhost:<puerto>/login Relativa: /login

Tres columnas que comparan middleware.ts en Next.js 15, middleware.ts en Next.js 16 y proxy.ts en Next.js 16: nombre de la función, runtime por defecto, qué pasa con runtime nodejs o edge, convivencia de ficheros y estado de cada convención

Figura 2. Las tres situaciones que te puedes encontrar. proxy.ts es la única sin sorpresas de runtime.

Cuatro conclusiones que conviene recordar:

  1. El cambio de nombre es también un cambio de runtime. Un middleware.ts sin runtime, en 15 y en 16, se ejecuta en Edge. El mismo código renombrado a proxy.ts se ejecuta en Node.js. Casi siempre es una mejora (tienes todo Node y los paquetes de npm), pero si tu código dependía de algo propio de Edge, compruébalo.
  2. En Next.js 15, un módulo de Node en Edge no rompe el build. Importar node:crypto en el middleware solo produce un aviso al compilar (“A Node.js module is loaded (‘node:crypto’ at line 2) which is not supported in the Edge Runtime”); el error llega en producción, cuando alguien visita la ruta: el servidor registra Failed to load external module node:crypto: TypeError: Native module not found: node:crypto y responde con un 500. Lee los avisos del build. (Usar process.version, en cambio, sí rompe el build en las dos versiones.)
  3. No se puede escribir runtime: 'edge' en el config del middleware, ni en 15 ni en 16. Es el valor por defecto, pero escrito explícitamente el build falla y pide 'experimental-edge'. En 16, además, Next avisa de que el runtime Edge está obsoleto en general: la documentación de la opción runtime de los segmentos lo marca como deprecated y recomienda quitarlo.
  4. Los errores de migración en 16 se ven al compilar. Olvidar renombrar la función o dejar los dos ficheros no pasa desapercibido: el build se detiene con un mensaje claro. El error contrario es silencioso: un proxy.ts en un proyecto de Next.js 15 es un fichero cualquiera, y Next.js 15 no lo ejecuta ni avisa. Si copias código de este curso a un proyecto en 15, renombra.

3.2. ¿Y si mi proyecto necesita Edge?

La guía oficial de Next.js 16 lo dice expresamente: el runtime Edge no está disponible en proxy; si quieres seguir usándolo, mantén middleware.ts, y el equipo de Next anunció instrucciones adicionales en una versión menor posterior. En la práctica, con Next.js 16.4:

  • middleware.ts sigue funcionando y sigue usando Edge por defecto, pero cada next build muestra el aviso de obsoleto.
  • Escribir el runtime exige 'experimental-edge', que añade el aviso de que Edge está obsoleto.

Así que solo tiene sentido quedarse en middleware.ts si despliegas en una plataforma que ejecuta el middleware en su red de borde y dependes de ello. Para todo lo demás (y para todo este curso), proxy.ts.

3.3. Cómo migrar

Next.js trae un codemod que renombra el fichero y la función:

npx @next/codemod@canary middleware-to-proxy .
// middleware.ts -> proxy.ts
- export function middleware() {
+ export function proxy() {

Después, revisa a mano:

  1. Quita runtime del config si lo tenía, también si era 'nodejs'. Aunque coincida con lo que usa el proxy, el build falla con Route segment config is not allowed in Proxy file at "./proxy.ts". Proxy always runs on Node.js runtime.
  2. Si alguna opción de next.config tenía middleware en el nombre, cámbiala: skipMiddlewareUrlNormalize pasa a skipProxyUrlNormalize (apartado 6.4).
  3. Compila (next build) y lee los avisos: si aparece “The ‘middleware’ file convention is deprecated”, aún queda un middleware.ts en algún sitio.
  4. Comprueba que el código sigue funcionando en Node.js: lo normal es que sí, y que puedas simplificar cosas que en Edge necesitaban rodeos (por ejemplo, usar node:crypto en lugar de crypto.subtle).

💡 Consejo: Si mantienes un proyecto en Next.js 15 y quieres que la migración futura sea sencilla, añade ya runtime: 'nodejs' a tu middleware.ts (estable desde 15.5). Así el código se ejecuta en el mismo entorno que tendrá proxy.ts, y el día que actualices cambias dos nombres y borras esa línea.


4. matcher: dónde se ejecuta el proxy

Sin matcher, el proxy se ejecuta para cada petición: la página, pero también cada fichero JavaScript de _next/static, cada imagen y el favicon.ico. Eso tiene dos problemas: gastas CPU en peticiones que no te interesan, y un proxy que redirige al login puede acabar bloqueando el CSS de la propia página de login.

El matcher se declara en el objeto config exportado:

export const config = {
  matcher: '/gestion/:ruta*',
}

4.1. La sintaxis de los patrones

Los patrones usan la sintaxis de path-to-regexp, la misma que verás en redirects y rewrites:

Patrón Significa
/gestion Exactamente /gestion
/gestion/:ruta Un tramo después de /gestion
/gestion/:ruta* Cero o más tramos (*)
/gestion/:ruta+ Uno o más tramos (+)
/gestion/:ruta? Cero o un tramo (?)
/productos/:id(\\d+) Un tramo que cumple la expresión regular \d+
/((?!api\|_next).*) Expresión regular completa: todo menos lo que empieza por api o _next

En lugar de suponer qué casa con qué, compruébalo. Next.js trae una utilidad experimental para pruebas que responde exactamente eso. Este script del laboratorio la usa con varias URL:

// comprobar-matcher.mjs (se ejecuta con: node comprobar-matcher.mjs)
import { AsyncLocalStorage } from 'node:async_hooks'
globalThis.AsyncLocalStorage = AsyncLocalStorage // fuera de Next hay que proporcionarlo
const { unstable_doesMiddlewareMatch } = await import('next/experimental/testing/server.js')

const casos = {
  "'/gestion'": { matcher: '/gestion' },
  "'/gestion/:ruta'": { matcher: '/gestion/:ruta' },
  "'/gestion/:ruta*'": { matcher: '/gestion/:ruta*' },
  "'/gestion/:ruta+'": { matcher: '/gestion/:ruta+' },
  "'/productos/:id(\\\\d+)'": { matcher: '/productos/:id(\\d+)' },
  'negativo': { matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:png|svg|jpg|ico)$).*)'] },
  'has cookie': { matcher: [{ source: '/tienda', missing: [{ type: 'cookie', key: 'grupo-ab' }] }] },
}
const urls = ['/', '/gestion', '/gestion/informes', '/gestion/informes/2026', '/productos/7',
  '/productos/abc', '/api/interna/estado', '/_next/static/chunks/a.js', '/logo.png', '/tienda']

for (const [nombre, config] of Object.entries(casos)) {
  const si = urls.filter((url) => unstable_doesMiddlewareMatch({ config, url }))
  console.log(nombre.padEnd(26), '->', si.join('  '))
}
console.log('tienda con cookie grupo-ab:', unstable_doesMiddlewareMatch({
  config: casos['has cookie'], url: '/tienda', cookies: { 'grupo-ab': 'a' },
}))

Salida real:

'/gestion'                 -> /gestion
'/gestion/:ruta'           -> /gestion/informes
'/gestion/:ruta*'          -> /gestion  /gestion/informes  /gestion/informes/2026
'/gestion/:ruta+'          -> /gestion/informes  /gestion/informes/2026
'/productos/:id(\\d+)'     -> /productos/7
negativo                   -> /  /gestion  /gestion/informes  /gestion/informes/2026  /productos/7  /productos/abc  /tienda
has cookie                 -> /tienda
tienda con cookie grupo-ab: false

Fíjate en tres resultados:

  • '/gestion' casa solo con /gestion, no con /gestion/informes. Si quieres proteger una sección entera, necesitas '/gestion/:ruta*'. La documentación de Next.js 16.4 dice en un punto que /about casa también con /about/team; la prueba (y el servidor real, apartado 3) dicen que no. Escribe siempre el *.
  • :ruta exige exactamente un tramo, así que no casa con /gestion ni con /gestion/informes/2026.
  • El patrón negativo deja fuera la API, los estáticos y las imágenes, y casa con todo lo demás.

⚠️ Importante: La documentación de Next.js 16.4 llama a esta utilidad unstable_doesProxyMatch, pero en la versión instalada (16.4.3) ese nombre no existe: se sigue exportando como unstable_doesMiddlewareMatch. Además, fuera de Next hay que crear el AsyncLocalStorage global antes de importarla, o falla con “AsyncLocalStorage accessed in runtime where it is not available”. Es una API unstable_: puede cambiar en cualquier versión menor. En el módulo 21 la usarás dentro de Vitest.

4.2. Excluir en lugar de incluir: el patrón negativo

Lo habitual en un proyecto real es lo contrario: ejecutar el proxy en todas las páginas menos en los recursos estáticos. Para eso se usa una búsqueda negativa ((?!...)) dentro de la expresión regular:

export const config = {
  matcher: [
    // Todo menos los estáticos de Next, las imágenes optimizadas, el favicon y las imágenes de public/
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|svg|jpg|ico)$).*)',
  ],
}

Léelo de dentro afuera: (?!X) significa “en esta posición no empieza X”, y .* es “cualquier cosa”. Las barras invertidas van dobles porque es una cadena de JavaScript: \\. en el código es \. en la expresión regular, un punto literal.

⚠️ Importante: Hay una petición que el patrón negativo no puede excluir del todo: las Server Functions (Server Actions). No son rutas propias: viajan como un POST a la página donde se usan. Si tu matcher excluye /tienda, tampoco se ejecutará el proxy para las acciones que se invoquen desde /tienda. La documentación lo avisa expresamente: comprueba siempre los permisos dentro de cada Server Action, nunca solo en el proxy. Lo verás a fondo en el módulo 17.

4.3. Condiciones: has y missing

El matcher también acepta objetos con source y condiciones sobre la petición. has exige que algo esté presente; missing, que falte. Cada condición tiene un type (header, cookie, query o host), una key y, opcionalmente, un value:

export const config = {
  matcher: [
    {
      source: '/tienda',
      missing: [{ type: 'cookie', key: 'grupo-ab' }], // solo si aún no tiene grupo
    },
  ],
}

Con esta configuración, el proxy solo se ejecuta la primera vez: en cuanto el navegador tiene la cookie, Next ni siquiera llama a tu función (la salida anterior lo muestra: tienda con cookie grupo-ab: false). Es más eficiente que comprobar la cookie dentro de la función, porque te ahorras la llamada.

Un uso muy común es saltarse las precargas. Cuando un <Link> entra en pantalla, Next precarga la página con una petición que lleva la cabecera next-router-prefetch. Si tu proxy hace algo costoso (registrar visitas, por ejemplo), no quieres contarlas:

export const config = {
  matcher: [
    {
      source: '/((?!_next/static|_next/image|favicon.ico).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}

4.4. El matcher tiene que ser constante

Next lee el matcher al compilar, analizando el código fuente sin ejecutarlo. Por eso tiene que ser un valor literal: nada de variables, funciones ni plantillas.

// ❌ Next no puede evaluarlo al compilar: se ignora
const PRIVADAS = ['/gestion/:ruta*', '/pedidos/:ruta*']
export const config = { matcher: PRIVADAS }

// ✅ Literal
export const config = { matcher: ['/gestion/:ruta*', '/pedidos/:ruta*'] }

Si necesitas que la lista dependa de algo, pon un matcher amplio y decide dentro de la función, como hace el ejemplo del apartado 5 con PREFIJOS_PRIVADOS.


5. Caso práctico EMPRESA: un proxy completo

El laboratorio de este módulo es una versión reducida de la aplicación EMPRESA con cuatro necesidades:

  1. La zona de gestión (/gestion/...) solo es para quien ha iniciado sesión. Sin sesión, al login, y después de entrar, de vuelta a donde iba.
  2. La tienda está probando dos diseños de portada. Cada visitante ve siempre el mismo, pero la URL es /tienda para todos.
  3. Una API interna (/api/interna/...) solo responde a quien manda una clave en la cabecera Authorization.
  4. Cada petición lleva un identificador único, visible en la página y en la respuesta, para poder seguirla en los registros.

Y una quinta que añadirás en el ejercicio 2: un modo mantenimiento que se activa con una variable de entorno.

5.1. El fichero proxy.ts

// proxy.ts (en la raíz del proyecto, al lado de app/)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { randomUUID } from 'node:crypto'

const PREFIJOS_PRIVADOS = ['/gestion']

export function proxy(request: NextRequest) {
  const { pathname, search } = request.nextUrl

  // 1. API interna: responde directamente con 401 si falta la clave
  if (pathname.startsWith('/api/interna')) {
    const esperada = `Bearer ${process.env.CLAVE_API_INTERNA}`
    if (request.headers.get('authorization') !== esperada) {
      return Response.json({ error: 'No autorizado' }, { status: 401 })
    }
  }

  // 2. Zona de gestión: sin cookie de sesión, al login recordando el destino
  const esPrivada = PREFIJOS_PRIVADOS.some((p) => pathname.startsWith(p))
  if (esPrivada && !request.cookies.has('sesion')) {
    const login = new URL('/login', request.url)
    login.searchParams.set('desde', pathname + search)
    return NextResponse.redirect(login)
  }

  // 3. Prueba A/B de la tienda: la URL no cambia, el contenido sí
  if (pathname === '/tienda') {
    const actual = request.cookies.get('grupo-ab')?.value
    const grupo = actual === 'a' || actual === 'b' ? actual : Math.random() < 0.5 ? 'a' : 'b'
    const respuesta = NextResponse.rewrite(new URL(`/variantes/tienda-${grupo}`, request.url))
    if (actual !== grupo) {
      respuesta.cookies.set('grupo-ab', grupo, { path: '/', maxAge: 60 * 60 * 24 * 30 })
    }
    return respuesta
  }

  // 4. Resto: identificador de petición hacia dentro (request) y hacia fuera (response)
  const id = randomUUID()
  const cabeceras = new Headers(request.headers)
  cabeceras.set('x-peticion-id', id)
  const respuesta = NextResponse.next({ request: { headers: cabeceras } })
  respuesta.headers.set('x-peticion-id', id)
  respuesta.headers.set('x-runtime', 'EdgeRuntime' in globalThis ? 'edge' : `node ${process.version}`)
  return respuesta
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|svg|jpg|ico)$).*)'],
}

El orden de los bloques importa: el primero que devuelve algo termina la función. Los apartados siguientes explican cada bloque con la salida real que produce.

5.2. Redirigir al login (y volver)

request.nextUrl es una versión ampliada de URL con la ruta (pathname) y la query (search) ya separadas. El proxy construye la URL del login con new URL('/login', request.url): el segundo argumento aporta el protocolo y el dominio, porque NextResponse.redirect exige una URL absoluta. El destino original viaja en ?desde=, codificado por searchParams.set:

curl -s -D - -o /dev/null "http://localhost:3001/gestion/informes?orden=fecha"
HTTP/1.1 307 Temporary Redirect
X-Frame-Options: DENY
location: /login?desde=%2Fgestion%2Finformes%3Forden%3Dfecha

NextResponse.redirect responde con 307 por defecto: redirección temporal que conserva el método. Es lo correcto aquí: no quieres que el navegador ni los buscadores recuerden que /gestion “es” /login. Si necesitas otro código, pásalo como segundo argumento: NextResponse.redirect(url, 303). La cabecera X-Frame-Options viene de headers() en next.config.ts: como headers va antes que el proxy, también se aplica a las respuestas del proxy.

La página de login lee desde, lo pasa al formulario en un campo oculto y muestra un aviso:

// app/login/page.tsx
import { Suspense } from 'react'
import { iniciarSesion } from './acciones'

async function Formulario({ searchParams }: { searchParams: PageProps<'/login'>['searchParams'] }) {
  const { desde } = await searchParams
  const destino = typeof desde === 'string' ? desde : '/gestion'
  return (
    <form action={iniciarSesion} className="space-y-3 bg-white border rounded p-4">
      {typeof desde === 'string' && (
        <p className="text-sm bg-amber-100 border border-amber-300 rounded p-2">
          Para entrar en <code>{desde}</code> necesitas iniciar sesión.
        </p>
      )}
      <input type="hidden" name="desde" value={destino} />
      <label className="block">
        Usuario <input name="usuario" defaultValue="ana" className="border rounded px-2 py-1 ml-2" />
      </label>
      <button className="bg-indigo-700 text-white rounded px-3 py-1">Entrar</button>
    </form>
  )
}

export default function PaginaLogin({ searchParams }: PageProps<'/login'>) {
  return (
    <section className="space-y-4">
      <h1 className="text-2xl font-bold">Iniciar sesión</h1>
      <Suspense fallback={<p>Cargando…</p>}>
        <Formulario searchParams={searchParams} />
      </Suspense>
    </section>
  )
}

El <Suspense> es necesario porque, con Cache Components, searchParams es un dato de la petición y no puede leerse fuera de un límite de streaming (lo viste en el módulo 04).

Página de inicio de sesión de EMPRESA con un aviso amarillo que dice: Para entrar en /gestion/informes?orden=fecha necesitas iniciar sesión, un campo Usuario con el valor ana y el botón Entrar

Figura 3. El navegador pidió /gestion/informes?orden=fecha sin sesión; el proxy lo llevó al login con el destino original en la URL.

La Server Action pone la cookie y vuelve al destino. Fíjate en destinoSeguro: sin ella, cualquiera podría enviarte un enlace a /login?desde=https://sitio-malo.example y, tras iniciar sesión, acabarías en su web creyendo que sigues en la tuya. Es la vulnerabilidad llamada redirección abierta (open redirect):

'use server'
// app/login/acciones.ts
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

// Solo destinos internos: evita la "redirección abierta" (?desde=https://malo.example)
function destinoSeguro(valor: FormDataEntryValue | null): string {
  if (typeof valor !== 'string') return '/gestion'
  if (!valor.startsWith('/') || valor.startsWith('//')) return '/gestion'
  return valor
}

export async function iniciarSesion(datos: FormData) {
  const usuario = String(datos.get('usuario') ?? '').trim() || 'anonimo'
  // Cookie de ejemplo: en el módulo 17 será un JWT firmado con jose
  ;(await cookies()).set('sesion', usuario, { httpOnly: true, sameSite: 'lax', path: '/' })
  redirect(destinoSeguro(datos.get('desde')))
}

export async function cerrarSesion() {
  ;(await cookies()).delete('sesion')
  redirect('/')
}

La comprobación de // no es un capricho: //sitio-malo.example empieza por /, pero el navegador la interpreta como una URL absoluta con el mismo protocolo.

⚠️ Importante: La cookie sesion de este ejemplo contiene solo el nombre de usuario, sin firmar: cualquiera puede crearla a mano en las herramientas del navegador. Sirve para entender el flujo del proxy, nada más. En el módulo 17 la sustituirás por un JWT firmado con jose, y el proxy se limitará a una comprobación optimista (¿hay cookie y su firma es válida?), mientras que la comprobación de verdad irá en la capa de acceso a datos.

He comprobado el flujo completo con un script de Playwright que pide /gestion?pestana=equipo, pulsa Entrar y espera a volver:

Tras pedir /gestion: http://127.0.0.1:5513/login?desde=%2Fgestion%3Fpestana%3Dequipo
Tras el login: http://127.0.0.1:5513/gestion?pestana=equipo

La query original (?pestana=equipo) sobrevive al viaje de ida y vuelta.

5.3. Pasar información a la página: cabeceras de petición

El bloque 4 del proxy genera un identificador con randomUUID() de node:crypto y lo pone en dos sitios distintos:

const cabeceras = new Headers(request.headers)
cabeceras.set('x-peticion-id', id)
const respuesta = NextResponse.next({ request: { headers: cabeceras } }) // hacia la página
respuesta.headers.set('x-peticion-id', id)                              // hacia el navegador
  • NextResponse.next({ request: { headers } }) cambia la petición que recibirá la página. La página la lee con headers() de next/headers.
  • respuesta.headers.set(...) cambia la respuesta que recibirá el navegador.

Son direcciones opuestas y se confunden con mucha facilidad. Si escribes NextResponse.next({ headers: cabeceras }) (sin request:), estás mandando al navegador todas las cabeceras de la petición, cookies incluidas. La documentación lo marca en negrita, y con razón.

La página de gestión lee el identificador:

// app/gestion/page.tsx
import { Suspense } from 'react'
import { cookies, headers } from 'next/headers'
import { cerrarSesion } from '../login/acciones'

async function DatosPeticion() {
  const usuario = (await cookies()).get('sesion')?.value
  const id = (await headers()).get('x-peticion-id')
  return (
    <dl className="grid grid-cols-[10rem_1fr] gap-1 bg-white border rounded p-4 text-sm">
      <dt className="font-semibold">Usuario</dt><dd>{usuario}</dd>
      <dt className="font-semibold">x-peticion-id</dt><dd><code>{id}</code></dd>
    </dl>
  )
}

export default function PaginaGestion() {
  return (
    <section className="space-y-4">
      <h1 className="text-2xl font-bold">Zona de gestión</h1>
      <Suspense fallback={<p>Cargando…</p>}>
        <DatosPeticion />
      </Suspense>
      <form action={cerrarSesion}>
        <button className="border rounded px-3 py-1">Cerrar sesión</button>
      </form>
    </section>
  )
}

Página Zona de gestión con una tabla que muestra Usuario ana y x-peticion-id con un UUID, y un botón Cerrar sesión

Figura 4. Tras el login, la página recibe el identificador que generó el proxy para esa petición.

Y la respuesta lleva el mismo valor en sus cabeceras:

curl -s -D - -o /dev/null -b "sesion=ana" http://localhost:3001/gestion
HTTP/1.1 200 OK
X-Frame-Options: DENY
x-peticion-id: 8ed36e86-4379-4d23-913f-bf7394e47cd3
x-runtime: node v24.19.0

💡 Consejo: Usa cabeceras de petición para datos pequeños que el proxy ya ha calculado: un identificador, el idioma elegido, el grupo de una prueba. No metas ahí objetos grandes: los servidores web rechazan cabeceras demasiado grandes con un error 431. Y nunca te fíes de una cabecera x-... que pueda venir del navegador: el proxy debe sobrescribirla siempre con set, como en el ejemplo, nunca solo leerla.

5.4. Reescribir: la prueba A/B

Una prueba A/B enseña dos versiones de una página a dos grupos de usuarios para ver cuál funciona mejor. El requisito clave es que cada usuario vea siempre la misma versión, y que la URL sea la misma para todos.

El bloque 3 del proxy lo resuelve con una reescritura. Las dos variantes son páginas normales en app/variantes/tienda-a/page.tsx y app/variantes/tienda-b/page.tsx. Cuando alguien pide /tienda, el proxy elige grupo (o lee el que ya tenía en la cookie), y Next sirve la variante sin que cambie la barra de direcciones:

curl -s -D - http://localhost:3001/tienda
HTTP/1.1 200 OK
set-cookie: grupo-ab=a; Path=/; Expires=Wed, 02 Dec 2026 11:52:11 GMT; Max-Age=2592000
x-middleware-rewrite: http://localhost:3001/variantes/tienda-a
...
Variante A: catálogo en lista.

Con la cookie del grupo B, la misma URL devuelve la otra variante, y ya no se envía set-cookie porque el valor no ha cambiado:

curl -s -b "grupo-ab=b" http://localhost:3001/tienda | grep -o "Variante [AB]"
Variante B

Página Tienda EMPRESA con un recuadro naranja: Variante B, catálogo en tarjetas con ofertas destacadas

Figura 5. La URL es /tienda, pero el contenido es el de /variantes/tienda-b: una reescritura no cambia la barra de direcciones.

⚠️ Importante: Fíjate en la cabecera x-middleware-rewrite de la respuesta: Next la deja a la vista y revela la ruta interna. Además, /variantes/tienda-a es una página normal, así que también responde si alguien la pide directamente. Una reescritura no es un mecanismo de seguridad: si una ruta no debe verse, protégela en la propia ruta. (Observa también que la cabecera aún se llama middleware: Next.js 16 no ha renombrado todo lo interno.)

Dos diagramas de secuencia lado a lado. Izquierda, redirección: el navegador pide /catalogo/3, Next responde 308 con location /productos/3, el navegador pide /productos/3 y recibe 200. Derecha, reescritura: el navegador pide /tienda, Next sirve por dentro /variantes/tienda-b y responde 200

Figura 6. Una redirección son dos viajes y cambia la URL; una reescritura es un viaje y la URL se queda igual.

5.5. Responder desde el proxy

El bloque 1 no deja pasar la petición: devuelve un Response propio. La ruta app/api/interna/estado/route.ts ni siquiera se ejecuta si falta la clave:

// app/api/interna/estado/route.ts
export async function GET(request: Request) {
  return Response.json({
    estado: 'ok',
    peticion: request.headers.get('x-peticion-id'),
  })
}
curl -s -i http://localhost:3001/api/interna/estado
curl -s http://localhost:3001/api/interna/estado -H "Authorization: Bearer clave-local-123456"
HTTP/1.1 401 Unauthorized
{"error":"No autorizado"}
{"estado":"ok","peticion":"b409cda5-4dcd-469f-b8a6-f0d0e5dd3c04"}

Con la clave correcta, la petición sigue hasta el bloque 4, recibe su identificador y llega al Route Handler, que lo lee de la cabecera de la petición.

El proxy lee process.env.CLAVE_API_INTERNA cada vez que se ejecuta, no al compilar. Para comprobarlo, arranqué el servidor ya compilado con otra clave en la shell (CLAVE_API_INTERNA=clave-de-la-shell-999 npx next start): la clave del .env.local pasó a recibir {"error":"No autorizado"} y la de la shell abrió la puerta. Así puedes cambiar un secreto sin recompilar.

💡 Consejo: Responder desde el proxy es útil para cortar pronto lo que no tiene sentido procesar. Pero si la regla es propia de un único Route Handler, ponla en el propio handler: es más fácil de leer y de probar. El módulo 12 explica cómo.

5.6. Cookies en el proxy

request.cookies y respuesta.cookies tienen métodos parecidos a los de un Map:

En la petición (request.cookies) En la respuesta (respuesta.cookies)
get(nombre) → { name, value } o undefined set(nombre, valor, opciones)
getAll() get(nombre), getAll()
has(nombre) delete(nombre)

Lo que cambias en request.cookies solo afecta a la copia que tiene el proxy; para que el navegador guarde una cookie, hay que ponerla en la respuesta, como hace la prueba A/B con respuesta.cookies.set('grupo-ab', ...).

5.7. Qué no hacer en el proxy

El proxy se ejecuta antes de cada petición que casa, así que cada milisegundo que tarde se suma a todas ellas. Algunas reglas:

  • Nada de consultas lentas. Leer una cookie o verificar la firma de un JWT es barato. Consultar la base de datos en cada petición no lo es. Si necesitas datos del usuario, que los cargue la página.
  • No es la única barrera de seguridad. Un cambio de matcher o mover una Server Action de ruta puede dejar algo sin proteger sin que nadie se dé cuenta. Comprueba los permisos también en la ruta y en cada acción.
  • No compartas estado en variables globales. La documentación avisa de que el proxy puede ejecutarse aparte del código que renderiza (incluso desplegado en otra máquina). Para pasar información a la página, usa cabeceras, cookies o la URL.
  • Una sola función. Si el fichero crece, divide la lógica en funciones de un módulo aparte (app/_lib/proxy/sesion.ts, app/_lib/proxy/ab.ts) y llámalas desde proxy.

6. next.config.ts: redirecciones, reescrituras y cabeceras

Muchas reglas no necesitan código. Si solo dependen de la URL (y, como mucho, de una cabecera, una cookie o un parámetro), van en next.config.ts. Next las compila en tablas de rutas y las aplica sin ejecutar nada tuyo. Este es el next.config.ts del laboratorio:

// next.config.ts
import type { NextConfig } from 'next'
import './app/_lib/entorno' // valida las variables al compilar y al arrancar (apartado 7.7)

const API = process.env.API_URL ?? 'http://localhost:3000/api'
const LEGADO = process.env.SITIO_LEGADO ?? 'http://localhost:3000'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  poweredByHeader: false,
  env: {
    VERSION_APP: '1.3.0',
  },

  async redirects() {
    return [
      // Catálogo antiguo: /catalogo/3 -> /productos/3 (solo si es un número)
      { source: '/catalogo/:id(\\d+)', destination: '/productos/:id', permanent: true },
      // Antiguo panel de administración: temporal mientras se migra
      { source: '/admin/:ruta*', destination: '/gestion/:ruta*', permanent: false },
      // Forzar la variante clásica con ?ver=clasica
      {
        source: '/tienda',
        has: [{ type: 'query', key: 'ver', value: 'clasica' }],
        destination: '/variantes/tienda-a',
        permanent: false,
      },
    ]
  },

  async rewrites() {
    return {
      beforeFiles: [],
      afterFiles: [
        // El navegador habla con /api-empresa/...; Next reenvía a la API EMPRESA
        { source: '/api-empresa/:ruta*', destination: `${API}/:ruta*` },
      ],
      fallback: [
        // Lo que no existe en app/ lo sirve el sitio antiguo
        { source: '/:ruta*', destination: `${LEGADO}/:ruta*` },
      ],
    }
  },

  async headers() {
    return [
      {
        source: '/:ruta*',
        headers: [
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'X-Frame-Options', value: 'DENY' },
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
        ],
      },
      {
        source: '/api-empresa/:ruta*',
        headers: [{ key: 'Cache-Control', value: 'no-store' }],
      },
    ]
  },
}

export default nextConfig

(El proyecto del laboratorio conserva además la regla turbopack.rules para Tailwind que genera create-next-app; aquí se omite para centrarse en lo nuevo.)

Las tres funciones son async y devuelven arrays de reglas. Se ejecutan al compilar (next build) y al arrancar next dev; si cambias next.config.ts, hay que reiniciar el servidor de desarrollo.

6.1. redirects

Cada regla tiene source (el patrón, con la misma sintaxis que el matcher), destination (puede usar los parámetros de source) y permanent:

permanent Código Significa Cuándo
true 308 Permanent Redirect “Esta URL se ha movido para siempre” URL antiguas que no volverán; los buscadores actualizan su índice
false 307 Temporary Redirect “Por ahora, ve allí” Migraciones en curso, campañas, mantenimiento

Next usa 307 y 308 en lugar de los clásicos 302 y 301 porque estos últimos permiten al navegador cambiar un POST por un GET, y los nuevos no. Si necesitas un código concreto (por ejemplo, 301 para un cliente antiguo), usa statusCode en lugar de permanent.

Las pruebas con el laboratorio:

curl -s -D - -o /dev/null http://localhost:3001/catalogo/3
curl -s -D - -o /dev/null http://localhost:3001/catalogo/abc
curl -s -D - -o /dev/null "http://localhost:3001/tienda?ver=clasica"
HTTP/1.1 308 Permanent Redirect
location: /productos/3

HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8

HTTP/1.1 307 Temporary Redirect
location: /variantes/tienda-a?ver=clasica
  • /catalogo/3 casa con :id(\\d+) y se redirige. /catalogo/abc no casa con la expresión regular, así que la regla no se aplica (el 404 en JSON viene del fallback del apartado 6.2, que manda lo desconocido al “sitio antiguo”).
  • La redirección con has solo se aplica si la query tiene ver=clasica. Y la query original se conserva en el destino: Next la copia automáticamente.

💡 Consejo: Las redirecciones permanentes se quedan en la caché del navegador. Si te equivocas con un 308 durante el desarrollo, el navegador seguirá redirigiendo aunque cambies la regla: abre una ventana privada o borra la caché. Usa permanent: false mientras pruebas y pasa a true cuando estés seguro.

6.2. rewrites

Una reescritura sirve el contenido de destination manteniendo la URL de source. Puedes devolver un array (todas van al grupo afterFiles) o un objeto con los tres grupos que viste en la figura 1:

Grupo Se comprueba Uso típico
beforeFiles Después del proxy y antes de mirar public/ y las páginas Sustituir una página existente
afterFiles Después de las páginas estáticas y antes de las dinámicas Lo normal: rutas que no existen en app/
fallback Después de todo, si nada ha respondido Migración progresiva: lo no migrado lo sirve la web antigua

El destino puede ser otra web. Entonces Next actúa como proxy inverso: hace la petición al servidor de destino y devuelve su respuesta. La regla afterFiles del laboratorio usa eso para esconder la dirección de la API EMPRESA. El navegador llama a /api-empresa/productos/2, en el mismo dominio que la aplicación, y Next reenvía a http://localhost:3000/api/productos/2:

curl -s http://localhost:3001/api-empresa/productos/2
{"productoId":2,"nombre":"Portátil Gaming 16","descripcion":"Portátil de 16 pulgadas con gráfica dedicada","categoriaId":2,"precio":1499,"stock":10,"fechaAlta":"2024-03-15","descatalogado":false,"categoria":"Portátiles"}

Ventajas: el código del cliente no necesita conocer la dirección real de la API, y como la petición es al mismo origen, no hay problemas de CORS (repasa CORS en el módulo 12).

⚠️ Importante: He comprobado que, cuando una reescritura va a otra web, la respuesta que llega al navegador es la del servidor de destino: no lleva las cabeceras de headers() (ni X-Frame-Options ni el Cache-Control: no-store de la regla específica) ni las que añade el proxy. Si necesitas controlar las cabeceras de esas respuestas, usa un Route Handler que haga el fetch y construya la respuesta (módulo 12). Las reescrituras a rutas internas sí reciben todas las cabeceras.

La regla fallback dirige lo desconocido al sitio antiguo, que en el laboratorio es el propio servidor de la API (variable SITIO_LEGADO). Así, una ruta que no existe en app/, como /api/estadisticas, la contesta el sistema antiguo, y una que no existe en ningún sitio devuelve el 404 del sistema antiguo:

curl -s -i http://localhost:3001/api/estadisticas | head -1
curl -s http://localhost:3001/no-existe
HTTP/1.1 200 OK
{"status":404,"mensaje":"Ruta no encontrada: /no-existe"}

Es la forma recomendada de migrar una web grande a Next.js poco a poco: lo que ya está en app/ lo sirve Next, y lo demás sigue funcionando como antes.

6.3. headers

headers() añade cabeceras a las respuestas cuyas rutas casen con source. Si varias reglas casan, se aplican todas. La primera regla del laboratorio añade tres cabeceras de seguridad a todo el sitio:

Cabecera Qué evita
X-Content-Type-Options: nosniff Que el navegador “adivine” el tipo de un fichero y ejecute como script algo que no lo era
X-Frame-Options: DENY Que otra web meta la tuya en un <iframe> para engañar al usuario (clickjacking)
Referrer-Policy: strict-origin-when-cross-origin Que la URL completa (con su query) se envíe a otros dominios al seguir un enlace
curl -s -D - -o /dev/null http://localhost:3001/variantes/tienda-a | grep -iE "^x-|cache"
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
x-peticion-id: f26ce9f5-6a50-44d9-8afb-c35eefe1b914
x-runtime: node v24.19.0
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
Cache-Control: s-maxage=31536000

Las dos primeras vienen de headers(), las dos siguientes del proxy y el resto las pone Next (la página es estática y se sirve desde la caché). La política de seguridad de contenido (CSP) con nonce, que necesita el proxy porque cambia en cada petición, se ve en el módulo 22.

⚠️ Importante: Ten cuidado con Cache-Control en headers(). Para los recursos inmutables de _next/static, Next pone public, max-age=31536000, immutable y la documentación dice expresamente que no se puede cambiar. Para las páginas, Next calcula la cabecera a partir de la caché de cada ruta (lo viste en el módulo 09, y en la salida anterior: s-maxage=31536000); contrólala con cacheLife, no con headers().

6.4. Otras opciones relacionadas

Opción Qué hace
poweredByHeader: false Quita la cabecera X-Powered-By: Next.js (no conviene anunciar qué usas). En el laboratorio, la respuesta ya no la lleva
trailingSlash: true Las URL terminan en /, y Next redirige las que no
basePath: '/tienda' Toda la aplicación cuelga de un prefijo, por ejemplo cuando compartes dominio con otra web
skipTrailingSlashRedirect Desactiva la redirección automática de la barra final, para gestionarla tú en el proxy
skipProxyUrlNormalize 🔄 16.0 El proxy recibe la URL original sin normalizar. En Next.js 15 se llamaba skipMiddlewareUrlNormalize; en 16 ese nombre sigue aceptándose, marcado como obsoleto en los tipos
experimental.proxyClientMaxBodySize 🔄 16.0 Tamaño máximo del cuerpo de las peticiones cuando hay proxy (10 MB por defecto). En Next.js 15 era experimental.middlewareClientMaxBodySize

6.5. ¿next.config, proxy o la ruta?

Necesitas… Usa
Que /blog-antiguo/:slug vaya a /blog/:slug redirects
Esconder la URL de una API externa rewrites
Cabeceras de seguridad fijas para todo el sitio headers
Redirigir según una cookie que solo hay que mirar si existe redirects con has/missing
Redirigir según el valor de una sesión, un idioma calculado o un número aleatorio proxy.ts
Generar un valor distinto en cada petición (identificador, nonce) proxy.ts
Saber si el usuario puede ver este pedido La ruta (página, handler o acción)

7. Variables de entorno

La aplicación EMPRESA necesita datos que cambian según dónde se ejecuta: la dirección de la API, una clave interna, el nombre de la tienda. No deben ir escritos en el código. Las variables de entorno son el mecanismo estándar para eso: pares NOMBRE=valor que el sistema operativo (o el servicio donde despliegas) pasa a tu proceso, y que en Node.js lees con process.env.NOMBRE.

7.1. Ficheros .env y orden de carga

Next.js carga automáticamente las variables de unos ficheros con nombre fijo en la raíz del proyecto (no en src/). El laboratorio tiene cuatro:

# .env: valores por defecto para todos los entornos (se puede subir al repositorio)
API_URL=http://127.0.0.1:3413/api
API_PRODUCTOS=$API_URL/productos
SALUDO="Hola desde .env"
NEXT_PUBLIC_NOMBRE_TIENDA="Tienda EMPRESA"
SITIO_LEGADO=http://127.0.0.1:3413
# .env.local: secretos de tu máquina (nunca al repositorio)
CLAVE_API_INTERNA=clave-local-123456
# .env.development
NEXT_PUBLIC_ENTORNO=desarrollo
# .env.production
NEXT_PUBLIC_ENTORNO=produccion

(En el curso la API está en http://localhost:3000/api; el laboratorio usa otro puerto.) next dev usa NODE_ENV=development; next build y next start, NODE_ENV=production. Al arrancar, Next dice qué ficheros ha cargado:

▲ Next.js 16.4.3 (Turbopack)
- Environments: .env.local, .env.production, .env

Si una variable está en varios sitios, gana la primera de esta lista:

  1. process.env: lo que ya trae el proceso (variables de la shell, del sistema o del servicio de despliegue)
  2. .env.$(NODE_ENV).local (por ejemplo, .env.production.local)
  3. .env.local (no se carga cuando NODE_ENV es test)
  4. .env.$(NODE_ENV)
  5. .env

Lo he comprobado con una variable que está en .env y también en la shell. API_URL=no-es-url npx next build hace que la validación del apartado 7.7 se queje de API_URL, aunque .env tenga una URL válida: gana la shell.

⚠️ Importante: create-next-app añade .env* al .gitignore, así que por defecto ningún fichero .env se sube al repositorio. Si quieres compartir valores por defecto que no son secretos (.env, .env.development), quita esa regla y deja solo .env*.local. Los secretos van siempre en .env.local o en el panel del servicio donde despliegas. Una práctica habitual es subir un .env.example con los nombres de las variables y valores ficticios.

Dos detalles del formato:

  • Expansión: API_PRODUCTOS=$API_URL/productos se convierte en http://127.0.0.1:3413/api/productos (lo verás en la figura 7). Si necesitas un $ literal, escápalo: \$.
  • Comillas: son opcionales, pero necesarias si el valor tiene espacios o #.

7.2. En el servidor: todas, y en el momento justo

En el código de servidor (Server Components, Route Handlers, Server Actions, proxy, next.config.ts) tienes todas las variables. Pero hay una pregunta que se olvida a menudo: ¿cuándo se leen?

Con Cache Components, una página sin datos de petición se prerenderiza al compilar. Si en esa parte estática lees process.env.SALUDO, se lee durante next build. Si la lees después de await connection() (o de cualquier otra API de petición), se lee en cada petición, con el valor que tenga el servidor en marcha.

La página /entorno del laboratorio muestra las dos cosas:

// app/entorno/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import VariablesCliente from './variables-cliente'

function Fila({ nombre, valor }: { nombre: string; valor: string | undefined }) {
  return (
    <tr className="border-t">
      <td className="pr-4 py-1 font-mono text-xs">{nombre}</td>
      <td className="py-1">{valor ?? <em className="text-slate-400">undefined</em>}</td>
    </tr>
  )
}

// Se ejecuta al prerenderizar (en next build): el valor queda congelado en el HTML
function EnElBuild() {
  return (
    <table className="text-sm">
      <tbody>
        <Fila nombre="SALUDO" valor={process.env.SALUDO} />
        <Fila nombre="NEXT_PUBLIC_ENTORNO" valor={process.env.NEXT_PUBLIC_ENTORNO} />
        <Fila nombre="NODE_ENV" valor={process.env.NODE_ENV} />
      </tbody>
    </table>
  )
}

// connection() lo saca del prerender: se lee en cada petición
async function EnCadaPeticion() {
  await connection()
  return (
    <table className="text-sm">
      <tbody>
        <Fila nombre="SALUDO" valor={process.env.SALUDO} />
        <Fila nombre="API_URL" valor={process.env.API_URL} />
        <Fila nombre="API_PRODUCTOS" valor={process.env.API_PRODUCTOS} />
        <Fila nombre="CLAVE_API_INTERNA" valor={process.env.CLAVE_API_INTERNA ? '(definida, no se muestra)' : undefined} />
      </tbody>
    </table>
  )
}

export default function PaginaEntorno() {
  return (
    <section className="space-y-4">
      <h1 className="text-2xl font-bold">Variables de entorno</h1>
      <div className="bg-white border rounded p-4">
        <h2 className="font-semibold mb-2">Servidor, leídas en el prerender</h2>
        <EnElBuild />
      </div>
      <div className="bg-white border rounded p-4">
        <h2 className="font-semibold mb-2">Servidor, leídas en cada petición</h2>
        <Suspense fallback={<p>Cargando…</p>}>
          <EnCadaPeticion />
        </Suspense>
      </div>
      <div className="bg-white border rounded p-4">
        <h2 className="font-semibold mb-2">Cliente (navegador)</h2>
        <VariablesCliente />
      </div>
    </section>
  )
}

Para ver la diferencia, compilé con un valor de SALUDO en la shell y arranqué con otro:

SALUDO="Hola desde la shell del build" npx next build
SALUDO="Hola desde la shell del start" npx next start -p 3001

El HTML que devuelve el servidor (curl) muestra los dos valores:

 SALUDO  Hola desde la shell del build      ← parte prerenderizada
 NEXT_PUBLIC_ENTORNO  produccion
 NODE_ENV  production
 SALUDO  Hola desde la shell del start      ← tras connection()
 API_URL  http://127.0.0.1:3413/api
 API_PRODUCTOS  http://127.0.0.1:3413/api/productos
 CLAVE_API_INTERNA  (definida, no se muestra)

Y aquí viene la sorpresa. En el navegador, tras hidratar, la parte “prerenderizada” también muestra “Hola desde la shell del start” (figura 7). La razón: la página es ◐ (Partial Prerender), y al reanudarla en cada petición Next genera de nuevo la carga RSC completa, también la del armazón estático, con el valor del proceso en marcha. El HTML inicial dice una cosa y, medio segundo después, la página dice otra.

⚠️ Importante: La lección práctica: no leas variables de entorno de servidor en la parte estática de una página si su valor puede cambiar entre el build y el arranque. Léelas después de connection() (o dentro de un Route Handler o una acción), o asume que es una constante de compilación y no la cambies al arrancar. Esto importa mucho al desplegar con Docker, donde lo normal es compilar una imagen una vez y arrancarla con variables distintas en cada entorno (módulo 23).

7.3. En el navegador: solo NEXT_PUBLIC_, y congeladas

El código de cliente se ejecuta en el navegador, donde no existe process.env de verdad. Para que una variable llegue allí, su nombre tiene que empezar por NEXT_PUBLIC_. Lo que hace Next es sustituir el texto process.env.NEXT_PUBLIC_NOMBRE_TIENDA por su valor al compilar, como si lo hubieras escrito tú a mano.

El componente cliente de /entorno prueba varias formas de leerlas:

'use client'
// app/entorno/variables-cliente.tsx
import { useEffect, useState } from 'react'

export default function VariablesCliente() {
  // Solo se muestran tras hidratar, para ver lo que de verdad hay en el navegador
  const [filas, setFilas] = useState<[string, string | undefined][]>([])

  useEffect(() => {
    const clave = 'NEXT_PUBLIC_NOMBRE_TIENDA'
    // Clave que solo se conoce al ejecutar: el compilador no puede sustituirla
    const claveEnEjecucion = ['NEXT_PUBLIC', 'NOMBRE', 'TIENDA'].join('_')
    setFilas([
      ['NEXT_PUBLIC_NOMBRE_TIENDA', process.env.NEXT_PUBLIC_NOMBRE_TIENDA],
      ['NEXT_PUBLIC_ENTORNO', process.env.NEXT_PUBLIC_ENTORNO],
      ['VERSION_APP (next.config env)', process.env.VERSION_APP],
      ['SALUDO (sin prefijo)', process.env.SALUDO],
      ['process.env[clave] (constante)', process.env[clave]],
      ['process.env[claveEnEjecucion]', process.env[claveEnEjecucion]],
      ['const { ... } = process.env', (() => { const { NEXT_PUBLIC_ENTORNO } = process.env; return NEXT_PUBLIC_ENTORNO })()],
    ])
  }, [])

  return (
    <table className="text-sm">
      <tbody>
        {filas.map(([nombre, valor]) => (
          <tr key={nombre} className="border-t">
            <td className="pr-4 py-1 font-mono text-xs">{nombre}</td>
            <td className="py-1">{valor ?? <em className="text-slate-400">undefined</em>}</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

Página Variables de entorno con tres tablas. Servidor en el prerender: SALUDO Hola desde la shell del start, NEXT_PUBLIC_ENTORNO produccion, NODE_ENV production. Servidor en cada petición: SALUDO, API_URL, API_PRODUCTOS expandida y CLAVE_API_INTERNA definida. Cliente: NEXT_PUBLIC_NOMBRE_TIENDA Tienda EMPRESA, NEXT_PUBLIC_ENTORNO produccion, VERSION_APP 1.3.0, SALUDO undefined, process.env[clave] constante Tienda EMPRESA, process.env[claveEnEjecucion] undefined, desestructuración undefined

Figura 7. Lo que ve cada parte de la aplicación. En el cliente, las variables sin prefijo y los accesos que el compilador no puede resolver valen undefined.

Buscando en el JavaScript generado (.next/static/chunks/) se ve exactamente qué ha hecho el compilador:

["NEXT_PUBLIC_NOMBRE_TIENDA","Tienda EMPRESA"],["NEXT_PUBLIC_ENTORNO","produccion"],
["VERSION_APP (next.config env)","1.3.0"],["SALUDO (sin prefijo)",s.default.env.SALUDO],
["process.env[clave] (constante)","Tienda EMPRESA"],
["process.env[claveEnEjecucion]",t.default.env.NEXT_PUBLIC_NOMBRE_TIENDA],
["const { ... } = process.env",(()=>{let{NEXT_PUBLIC_ENTORNO:e}=t.default.env;return e})()]
  • Las tres primeras son texto literal dentro del JavaScript. Cualquiera puede leerlo en las herramientas del navegador. Por eso nunca se pone un secreto en una variable NEXT_PUBLIC_.
  • SALUDO no tiene prefijo: queda como acceso a un process.env vacío y vale undefined. No hay error ni aviso: simplemente no está.
  • process.env[clave] con const clave = '...' sí se sustituye: Turbopack es capaz de ver que la clave es una constante. Pero no cuentes con ello.
  • La clave calculada y la desestructuración no se sustituyen y valen undefined en el navegador. (Curiosamente, el minificador acaba escribiendo env.NEXT_PUBLIC_NOMBRE_TIENDA, pero lo hace después de la sustitución, cuando ya es tarde.) Escribe siempre process.env.NEXT_PUBLIC_LO_QUE_SEA completo.

Diagrama con las fuentes de variables por orden de prioridad, la fase next build donde NEXT_PUBLIC se escribe en el JavaScript y las páginas prerenderizadas guardan su valor, la fase next start donde el código de servidor lee el valor del arranque, y dos cajas: el servidor ve todas las variables, el navegador solo NEXT_PUBLIC con acceso literal

Figura 8. NEXT_PUBLIC_ se resuelve al compilar; el resto, en el servidor, cuando se ejecuta el código que la lee.

📘 Concepto: Que se sustituyan al compilar tiene una consecuencia: las variables NEXT_PUBLIC_ quedan congeladas en el build. Si compilas una vez y despliegas la misma compilación en pruebas y en producción, las dos tendrán el mismo valor, el del momento de compilar. Si necesitas un valor público distinto en cada despliegue sin recompilar, léelo en el servidor (Server Component tras connection()) y pásalo como prop al componente cliente.

7.4. La opción env de next.config

next.config.ts acepta un objeto env cuyas claves se sustituyen igual que las NEXT_PUBLIC_, en servidor y en cliente, aunque no lleven el prefijo. En el laboratorio, VERSION_APP: '1.3.0' aparece en el navegador (figura 7). Es una opción antigua; hoy se prefiere NEXT_PUBLIC_ porque el nombre ya avisa de que el valor será público.

7.5. Variables que pone Next

  • NODE_ENV: development, production o test. No la definas en tus .env: Next la fija según la orden.
  • NEXT_RUNTIME: nodejs o edge, según el runtime del código que se está ejecutando. La usarás en instrumentation.ts en el módulo 14.

7.6. Ya no existen: serverRuntimeConfig y publicRuntimeConfig 🔄 16.0

🔄 Cambia en Next.js 16.0. Las opciones serverRuntimeConfig y publicRuntimeConfig de next.config se han eliminado. Si usas Next.js 15: aún existen en sus tipos, pero no las uses en código nuevo: lee process.env en el servidor y usa NEXT_PUBLIC_ para el cliente, como en este módulo, y tu código funcionará igual en las dos versiones.

Si heredas un proyecto que las usa, la guía de actualización a la versión 16 propone el mismo cambio: valores de servidor con process.env en Server Components (tras connection() si deben leerse al arrancar) y valores de cliente con NEXT_PUBLIC_.

7.7. Validar las variables al arrancar

¿Qué pasa si alguien despliega sin CLAVE_API_INTERNA? Con el código anterior, nada… hasta que llega la primera petición a la API interna y el proxy compara con "Bearer undefined". Es mucho mejor fallar al compilar y al arrancar, con un mensaje claro. Zod (que ya usaste en el módulo 11) lo resuelve en pocas líneas:

// app/_lib/entorno.ts
// Valida las variables de entorno una sola vez. Si falta algo, falla al arrancar, no en mitad de una petición.
import { z } from 'zod'

const esquema = z.object({
  API_URL: z.url(),
  CLAVE_API_INTERNA: z.string().min(12, 'Debe tener al menos 12 caracteres'),
  NEXT_PUBLIC_NOMBRE_TIENDA: z.string().min(1),
  MODO_MANTENIMIENTO: z.enum(['0', '1']).default('0'),
})

const resultado = esquema.safeParse(process.env)

if (!resultado.success) {
  console.error('Variables de entorno no válidas:')
  for (const problema of resultado.error.issues) {
    console.error(`  - ${problema.path.join('.')}: ${problema.message}`)
  }
  throw new Error('Configuración incorrecta: revisa tus ficheros .env')
}

export const entorno = resultado.data

Importándolo desde next.config.ts (import './app/_lib/entorno'), se ejecuta en cuanto Next carga la configuración, que es justo después de leer los .env. Con valores incorrectos:

CLAVE_API_INTERNA=corta API_URL=no-es-url npx next build
▲ Next.js 16.4.3 (Turbopack)
- Environments: .env.local, .env.production, .env
Variables de entorno no válidas:
  - API_URL: Invalid URL
  - CLAVE_API_INTERNA: Debe tener al menos 12 caracteres
⨯ Failed to load next.config.ts, see more info here https://nextjs.org/docs/messages/next-config-error

> Build error occurred
Error: Configuración incorrecta: revisa tus ficheros .env

En el código de servidor puedes importar entorno en lugar de usar process.env directamente, y TypeScript sabrá que entorno.API_URL es un string (no string | undefined). No lo importes desde un componente cliente: en el navegador process.env no tiene las variables de servidor y la validación fallaría. Para impedirlo del todo, puedes crear un segundo módulo para el servidor que empiece con import 'server-only' y reexporte entorno (no se puede poner en el propio entorno.ts, porque next.config.ts no se ejecuta en el entorno de React Server Components y server-only lanzaría un error al cargarlo).


8. Errores frecuentes

Síntoma Causa Solución
El proxy no hace nada El fichero está dentro de app/, o en la raíz cuando usas src/ Ponlo al lado de app/
Proxy is missing expected function export name El fichero se llama proxy.ts pero la función sigue llamándose middleware Renombra la función a proxy o usa export default
Both middleware file "./middleware.ts" and proxy file "./proxy.ts" are detected Has creado proxy.ts sin borrar middleware.ts Deja solo uno
Proxy does not support Edge runtime o Route segment config is not allowed in Proxy file runtime (sea 'edge' o 'nodejs') en el config de proxy.ts Quita runtime; el proxy es siempre Node.js
Use runtime 'experimental-edge' instead runtime: 'edge' escrito en middleware.ts (15 o 16) Quita la línea (Edge ya es el valor por defecto) o usa 'nodejs'
La ruta responde 500 con Native module not found: node:crypto middleware.ts en Edge (por defecto) que importa un módulo de Node runtime: 'nodejs' en 15, o migra a proxy.ts en 16
A Node.js API is used (...) which is not supported in the Edge Runtime middleware.ts (15, o 16 sin runtime) usa APIs de Node Añade runtime: 'nodejs' o migra a proxy.ts
El CSS o las imágenes no cargan en la página de login El proxy redirige también las peticiones de _next/static Excluye los estáticos en el matcher
matcher: '/gestion' no protege /gestion/informes Sin modificador solo casa la ruta exacta '/gestion/:ruta*'
El matcher no se aplica Usa una variable o una plantilla Escríbelo literal
Las cookies o cabeceras de la petición aparecen en la respuesta NextResponse.next({ headers }) en lugar de { request: { headers } } Usa request: para la petición
Una variable vale undefined en un componente cliente Le falta el prefijo NEXT_PUBLIC_, o se lee con clave calculada o desestructurando Prefijo y acceso literal process.env.NEXT_PUBLIC_X
Una variable pública no cambia al cambiar el .env en producción Se sustituyó al compilar Recompila, o léela en el servidor
Una redirección vieja sigue aplicándose tras cambiar la regla El navegador guardó el 308 en caché Ventana privada; usa permanent: false mientras pruebas
Las reglas de next.config no cambian en desarrollo next.config.ts solo se lee al arrancar Reinicia next dev
NextResponse.rewrite(url, { status: 503 }) responde 200 En una reescritura el código lo pone la ruta de destino Responde desde el proxy con new Response(..., { status: 503 })
Bucle de redirecciones (ERR_TOO_MANY_REDIRECTS) El proxy redirige a /login, y /login también casa con la condición Excluye el destino de la condición de redirección

9. Ejercicios

Ejercicio 1: Redirecciones de la web antigua

La web antigua de EMPRESA tenía estas URL, que siguen enlazadas desde otras webs y buscadores:

  • /empleado.php?id=105 → ahora es /gestion/empleados/105
  • /departamentos-lista → ahora es /gestion/departamentos, para siempre
  • /ofertas → durante la campaña de invierno, va a /tienda; después volverá a existir

Escribe las reglas de redirects y di qué código HTTP devuelve cada una.

Ver solución
async redirects() {
  return [
    {
      source: '/empleado.php',
      has: [{ type: 'query', key: 'id', value: '(?<id>\\d+)' }],
      destination: '/gestion/empleados/:id',
      permanent: true,
    },
    { source: '/departamentos-lista', destination: '/gestion/departamentos', permanent: true },
    { source: '/ofertas', destination: '/tienda', permanent: false },
  ]
},
  • La primera usa has con un grupo con nombre ((?<id>\\d+)) en el value: lo que casa se puede usar en destination como :id. Devuelve 308.
  • La segunda es un 308 porque el cambio es definitivo.
  • La tercera es un 307: la URL /ofertas volverá a tener contenido propio, así que no conviene que los buscadores la olviden.

Salida real:

GET /empleado.php?id=105   -> 308  location: /gestion/empleados/105?id=105
GET /empleado.php?id=abc   -> no casa: sigue su camino (404)
GET /departamentos-lista   -> 308  location: /gestion/departamentos
GET /ofertas               -> 307  location: /tienda

Recuerda que Next conserva la query original, así que el destino de la primera queda como /gestion/empleados/105?id=105. Es inofensivo; si te molesta, el proxy puede hacer la redirección construyendo la URL a mano.

Ejercicio 2: Modo mantenimiento

Haz que, cuando la variable de entorno MODO_MANTENIMIENTO valga 1, cualquier petición reciba una página de mantenimiento con el código 503 (servicio no disponible), sin cambiar la URL. El 503 importa: le dice a los buscadores que el corte es temporal y que no borren tus páginas del índice. Debe poder activarse sin recompilar: basta con reiniciar el servidor con la variable puesta.

Ver solución

La primera idea suele ser crear app/mantenimiento/page.tsx y reescribir hacia ella pasando el código de estado:

// Primer intento: NO devuelve 503
if (process.env.MODO_MANTENIMIENTO === '1' && pathname !== '/mantenimiento') {
  return NextResponse.rewrite(new URL('/mantenimiento', request.url), { status: 503 })
}

Compila (el tipo de rewrite acepta status), y la página de mantenimiento aparece, pero al probarlo la respuesta es HTTP/1.1 200 OK: en una reescritura, el código lo pone la ruta de destino y el status se ignora. Para controlar el código, el proxy tiene que responder él mismo:

// proxy.ts
const PAGINA_MANTENIMIENTO = `<!doctype html>
<html lang="es"><head><meta charset="utf-8"><title>EMPRESA: mantenimiento</title></head>
<body style="font-family: system-ui; padding: 2rem">
  <h1>Estamos en mantenimiento</h1><p>Volvemos en unos minutos.</p>
</body></html>`

export function proxy(request: NextRequest) {
  // 0. Modo mantenimiento: se lee en cada petición, no al compilar
  if (process.env.MODO_MANTENIMIENTO === '1') {
    return new Response(PAGINA_MANTENIMIENTO, {
      status: 503,
      headers: { 'content-type': 'text/html; charset=utf-8', 'retry-after': '600' },
    })
  }
  // ... resto de bloques
}

Con el servidor compilado una sola vez, arrancando con y sin la variable:

MODO_MANTENIMIENTO=1 npx next start -p 3001
curl -s -D - http://localhost:3001/tienda
HTTP/1.1 503 Service Unavailable
retry-after: 600
...
  <h1>Estamos en mantenimiento</h1><p>Volvemos en unos minutos.</p>

La API interna también responde 503, porque el bloque va antes que todos los demás. Sin la variable, /tienda vuelve a dar 200.

La cabecera Retry-After indica cuántos segundos esperar antes de reintentar. La página es HTML sencillo, sin React ni Tailwind, a propósito: durante un mantenimiento conviene depender de lo menos posible.

¿Por qué en el proxy y no en next.config.ts? Porque redirects y rewrites se calculan al compilar: una condición sobre process.env allí quedaría fija en el build. El proxy, en cambio, se ejecuta en cada petición y lee el valor del proceso en marcha.

Ejercicio 3: ¿Qué casa con qué?

Sin ejecutar nada, indica si el proxy se ejecuta en cada URL con cada matcher. Luego compruébalo con el script del apartado 4.1.

URL '/pedidos' '/pedidos/:id' '/pedidos/:id*' '/pedidos/:id(\\d+)'
/pedidos        
/pedidos/5001        
/pedidos/5001/lineas        
/pedidos/nuevo        
Ver solución
URL '/pedidos' '/pedidos/:id' '/pedidos/:id*' '/pedidos/:id(\\d+)'
/pedidos Sí No Sí No
/pedidos/5001 No Sí Sí Sí
/pedidos/5001/lineas No No Sí No
/pedidos/nuevo No Sí Sí No

Es exactamente lo que responde unstable_doesMiddlewareMatch con esas cuatro URL:

/pedidos               Si | No | Si | No
/pedidos/5001          No | Si | Si | Si
/pedidos/5001/lineas   No | No | Si | No
/pedidos/nuevo         No | Si | Si | No

La trampa típica es la primera columna: sin modificador, el patrón casa solo con la ruta exacta.

Ejercicio 4: Migrar un middleware.ts de Next.js 15

Este fichero funciona en un proyecto de Next.js 15.5. Usa node:crypto para comprobar una firma sencilla, y por eso declara el runtime de Node.js. Migra el proyecto a Next.js 16 y explica cada cambio.

// middleware.ts (Next.js 15)
import { NextResponse, type NextRequest } from 'next/server'
import { createHash } from 'node:crypto'

export function middleware(request: NextRequest) {
  const sesion = request.cookies.get('sesion')?.value
  const firma = request.cookies.get('firma')?.value
  const esperada = sesion && createHash('sha256').update(sesion + process.env.SECRETO).digest('hex')
  if (!sesion || firma !== esperada) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

export const config = {
  matcher: ['/gestion/:ruta*'],
  runtime: 'nodejs',
}
Ver solución
// proxy.ts (Next.js 16)
import { NextResponse, type NextRequest } from 'next/server'
import { createHash } from 'node:crypto'

export function proxy(request: NextRequest) {
  const sesion = request.cookies.get('sesion')?.value
  const firma = request.cookies.get('firma')?.value
  const esperada = sesion && createHash('sha256').update(sesion + process.env.SECRETO).digest('hex')
  if (!sesion || firma !== esperada) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

export const config = {
  matcher: ['/gestion/:ruta*'],
}

Tres cambios:

  1. El fichero pasa de middleware.ts a proxy.ts. Hay que borrar el antiguo: con los dos, el build falla.
  2. La función pasa de middleware a proxy. Si solo renombras el fichero, el build falla con Proxy is missing expected function export name.
  3. Desaparece runtime: 'nodejs'. Parece inofensivo, porque el proxy ya es Node.js, pero en proxy.ts cualquier runtime rompe el build: Route segment config is not allowed in Proxy file at "./proxy.ts". Proxy always runs on Node.js runtime.

El codemod oficial hace los dos primeros cambios: npx @next/codemod@canary middleware-to-proxy .. El tercero debes revisarlo tú.

¿Y si el original no tuviera runtime? Entonces en Next.js 15 se ejecutaba en Edge, y el import 'node:crypto' habría compilado con un aviso pero respondido 500 al visitar /gestion (apartado 3.1). Al migrar a proxy.ts pasaría a funcionar, porque el proxy es Node.js. Si un proyecto necesita Edge, la guía oficial recomienda de momento mantener middleware.ts, que en Next.js 16 sigue funcionando como obsoleto (apartado 3.2).

Ejercicio 5: Cabeceras de seguridad por zonas

Añade a headers() estas reglas:

  • Todo el sitio: Permissions-Policy: camera=(), microphone=(), geolocation=().
  • Solo /gestion/...: X-Robots-Tag: noindex, para que los buscadores no indexen la zona privada aunque encuentren un enlace.

Comprueba con curl que /gestion/informes (con cookie) lleva las dos y que / solo la primera.

Ver solución
async headers() {
  return [
    {
      source: '/:ruta*',
      headers: [{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }],
    },
    {
      source: '/gestion/:ruta*',
      headers: [{ key: 'X-Robots-Tag', value: 'noindex' }],
    },
  ]
},
curl -s -D - -o /dev/null -b "sesion=ana" http://localhost:3001/gestion/informes
curl -s -D - -o /dev/null http://localhost:3001/
curl -s -D - -o /dev/null http://localhost:3001/gestion
HTTP/1.1 200 OK
Permissions-Policy: camera=(), microphone=(), geolocation=()
X-Robots-Tag: noindex

HTTP/1.1 200 OK
Permissions-Policy: camera=(), microphone=(), geolocation=()

HTTP/1.1 307 Temporary Redirect
Permissions-Policy: camera=(), microphone=(), geolocation=()
X-Robots-Tag: noindex
location: /login?desde=%2Fgestion

Si varias reglas casan, Next aplica todas. Por eso /gestion/informes recibe las dos cabeceras. Observa que también las recibe la redirección al login si no hay sesión: headers va antes que el proxy.

Ejercicio 6: ¿Qué valor tendrá?

Con estos ficheros, y ejecutando next build y luego next start sin variables en la shell:

# .env
API_URL=http://api.empresa.example
NEXT_PUBLIC_TITULO=EMPRESA
# .env.local
API_URL=http://localhost:3000/api
# .env.production
NEXT_PUBLIC_TITULO=EMPRESA (producción)
# .env.development
API_URL=http://127.0.0.1:9999

¿Qué valen API_URL en un Route Handler y NEXT_PUBLIC_TITULO en un componente cliente? ¿Y si arrancas con API_URL=http://otra npx next start?

Ver solución

next build y next start usan NODE_ENV=production, así que .env.development no se carga. El orden es: shell, .env.production.local, .env.local, .env.production, .env.

  • API_URL en el Route Handler: http://localhost:3000/api. Está en .env.local y en .env, y gana .env.local.
  • NEXT_PUBLIC_TITULO en el cliente: EMPRESA (producción). Gana .env.production sobre .env, y el valor se escribe en el JavaScript al compilar.
  • Con API_URL=http://otra en la shell al arrancar, el Route Handler ve http://otra: la shell gana a todos los ficheros y el handler lee el valor al ejecutarse. Si en cambio hubieras puesto NEXT_PUBLIC_TITULO=otro al arrancar, el cliente seguiría mostrando EMPRESA (producción): quedó fijado en el build.

Ejercicio 7: Ocultar la API EMPRESA al navegador

Un componente cliente de la tienda pide los productos a http://localhost:3000/api/productos directamente, y en producción la API estará en otro dominio. Haz que el componente llame a /api-empresa/productos y que Next reenvíe la petición, de forma que la dirección real de la API solo esté en una variable de servidor.

Ver solución

En next.config.ts:

const API = process.env.API_URL ?? 'http://localhost:3000/api'

// dentro de nextConfig
async rewrites() {
  return [{ source: '/api-empresa/:ruta*', destination: `${API}/:ruta*` }]
},

Y en el componente cliente:

const respuesta = await fetch('/api-empresa/productos')

API_URL no lleva NEXT_PUBLIC_, así que nunca llega al navegador. La regla se evalúa al compilar: si cambias API_URL, hay que recompilar. Lo comprobé arrancando con API_URL=http://127.0.0.1:1/api (un puerto donde no hay nada): /api-empresa/productos/1 siguió respondiendo 200 desde la API del build, mientras que la página /entorno, que lee API_URL tras connection(), ya mostraba la dirección nueva. Además, recuerda lo comprobado en el apartado 6.2: la respuesta llega tal y como la da la API, sin las cabeceras de headers(). Si necesitas leer la variable al arrancar o controlar la respuesta, usa un Route Handler en app/api-empresa/[...ruta]/route.ts que haga el fetch.

Ejercicio 8: El bucle infinito

Este proxy deja la aplicación inservible: el navegador muestra ERR_TOO_MANY_REDIRECTS en cualquier página. Encuentra el fallo y corrígelo de dos formas distintas.

export function proxy(request: NextRequest) {
  if (!request.cookies.has('sesion')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
Ver solución

El matcher incluye /login. Sin sesión, /login redirige a /login, que redirige a /login… El navegador corta tras unas veinte redirecciones.

Forma 1, en la función:

const PUBLICAS = ['/login', '/tienda', '/']

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl
  const esPublica = PUBLICAS.some((p) => pathname === p || pathname.startsWith(`${p}/`))
  if (!esPublica && !request.cookies.has('sesion')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

Cuidado con '/': pathname.startsWith('/') es verdad para todo, por eso la comparación es pathname === p o startsWith(p + '/'), y con p = '/' la segunda condición busca //, que no se da.

Forma 2, en el matcher, excluyendo el login:

export const config = {
  matcher: ['/((?!login|_next/static|_next/image|favicon.ico).*)'],
}

Comprobado con unstable_doesMiddlewareMatch: con ese matcher, /login ya no activa el proxy, y /gestion, / y /tienda sí. Pero ojo: (?!login...) excluye todo lo que empieza por login, así que /loginx o /login-ayuda también quedan fuera (la prueba da false para /loginx). Si te importa, escribe login(?:/|$).

La forma 1 es más fácil de leer y de ampliar; la forma 2 ahorra la llamada al proxy. Lo más claro suele ser al revés: en lugar de “todo menos lo público”, proteger solo lo privado ('/gestion/:ruta*'), como en el caso práctico.

Ejercicio 9 (propuesto): Idioma por cabecera

Haz que el proxy, cuando alguien pide / sin la cookie idioma, lea la cabecera Accept-Language y redirija a /es o a /en (español por defecto). Pon la cookie para que la siguiente visita no tenga que calcularlo. En el módulo 19 harás esto mismo con next-intl.

Ejercicio 10 (propuesto): Registro de visitas con waitUntil

Usa el segundo parámetro del proxy (event: NextFetchEvent) para enviar, sin retrasar la respuesta, un POST con la ruta y la hora a un Route Handler app/api/visitas/route.ts que las guarde en memoria. Excluye del matcher las precargas (next-router-prefetch) y comprueba que el número de visitas registradas coincide con las páginas que has abierto, no con los enlaces que has visto.


Resumen

Concepto API Idea clave
Orden de la petición headers → redirects → proxy → rewrites → rutas Una redirección corta el viaje; una reescritura lo desvía
Proxy 🔄 16.0 proxy.ts con export function proxy Antes middleware.ts; un solo fichero, al lado de app/
Runtime del proxy Siempre Node.js Cualquier runtime en su config rompe el build; en 15 el middleware era Edge por defecto
middleware.ts en 16 Obsoleto, sigue funcionando Edge por defecto; no puede convivir con proxy.ts
Dónde se ejecuta config.matcher Literal; '/x' es solo /x, usa '/x/:ruta*'
Condiciones has, missing header, cookie, query, host
Probar el matcher unstable_doesMiddlewareMatch Experimental; la documentación lo llama unstable_doesProxyMatch
Redirigir NextResponse.redirect(url) 307 por defecto; URL absoluta
Reescribir NextResponse.rewrite(url) La URL no cambia; deja ver x-middleware-rewrite
Cabeceras hacia la página NextResponse.next({ request: { headers } }) Sin request: van al navegador
Responder Response.json(..., { status }) La ruta no llega a ejecutarse
Cookies request.cookies, respuesta.cookies Para guardarlas, en la respuesta
Redirecciones fijas redirects() permanent: 308 o 307; conserva la query
Reescrituras fijas rewrites() beforeFiles, afterFiles, fallback; a otra web, sin tus cabeceras
Cabeceras fijas headers() Se aplican también a las redirecciones
Ficheros .env .env, .env.local, .env.[modo] Gana la shell; secretos en .env.local
Variables públicas NEXT_PUBLIC_ Texto en el JavaScript; congeladas al compilar
Lectura en servidor process.env En la parte prerenderizada, al compilar; tras connection(), al ejecutar
Validación Zod en next.config.ts Falla al compilar y al arrancar, con mensaje claro