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.tsy la propia ruta - Escribir un
proxy.tsque redirige, reescribe, añade cabeceras, maneja cookies y responde directamente - Entender qué ha cambiado de
middleware.ts(Next.js 15) aproxy.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 condicioneshasymissing, y probarlo sin arrancar el servidor - Configurar en
next.config.tsredirecciones, 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
.envincompleto 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ónmiddlewarey se ejecuta por defecto en el runtime Edge. Si usas Next.js 15: todo lo que haceproxy.tsen este módulo funciona igual enmiddleware.tscambiando el nombre del fichero y de la función; si usas APIs de Node.js (node:crypto,process.version…), añaderuntime: 'nodejs'a suconfig(estable desde 15.5). El apartado 3 lo compara paso a paso. Los apartados 6 y 7 (next.configy 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.tsxo a suroute.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:
headers()denext.config: apunta las cabeceras que llevará la respuesta.redirects()denext.config: si casa, responde con una redirección y el viaje termina.proxy.ts, si sumatchercasa con la URL.rewrites()del grupobeforeFiles.- Rutas de fichero:
public/,_next/static/y las páginas estáticas deapp/. rewrites()del grupoafterFiles(es el grupo por defecto).- Rutas dinámicas (
app/productos/[id]). rewrites()del grupofallback.
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.tsy funciónexport function middleware(), con el runtime Edge por defecto. Si usas Next.js 15: escribe el mismo código enmiddleware.tscon la función llamadamiddleware; 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:
- 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). - 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. - Junto a la función puedes exportar un objeto
configcon elmatcher. 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 enpublic/.
📘 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, cuandonext.configno 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ónmiddlewarey 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ñaderuntime: 'nodejs'alconfigsi 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 |
Figura 2. Las tres situaciones que te puedes encontrar. proxy.ts es la única sin sorpresas de runtime.
Cuatro conclusiones que conviene recordar:
- El cambio de nombre es también un cambio de runtime. Un
middleware.tssinruntime, en 15 y en 16, se ejecuta en Edge. El mismo código renombrado aproxy.tsse 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. - En Next.js 15, un módulo de Node en Edge no rompe el build. Importar
node:cryptoen 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 registraFailed to load external module node:crypto: TypeError: Native module not found: node:cryptoy responde con un 500. Lee los avisos del build. (Usarprocess.version, en cambio, sí rompe el build en las dos versiones.) - No se puede escribir
runtime: 'edge'en elconfigdel 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ónruntimede los segmentos lo marca como deprecated y recomienda quitarlo. - 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.tsen 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.tssigue funcionando y sigue usando Edge por defecto, pero cadanext buildmuestra 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:
- Quita
runtimedelconfigsi lo tenía, también si era'nodejs'. Aunque coincida con lo que usa el proxy, el build falla conRoute segment config is not allowed in Proxy file at "./proxy.ts". Proxy always runs on Node.js runtime. - Si alguna opción de
next.configtenía middleware en el nombre, cámbiala:skipMiddlewareUrlNormalizepasa askipProxyUrlNormalize(apartado 6.4). - Compila (
next build) y lee los avisos: si aparece “The ‘middleware’ file convention is deprecated”, aún queda unmiddleware.tsen algún sitio. - 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:cryptoen lugar decrypto.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 tumiddleware.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/aboutcasa también con/about/team; la prueba (y el servidor real, apartado 3) dicen que no. Escribe siempre el*.:rutaexige exactamente un tramo, así que no casa con/gestionni 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 comounstable_doesMiddlewareMatch. Además, fuera de Next hay que crear elAsyncLocalStorageglobal antes de importarla, o falla con “AsyncLocalStorage accessed in runtime where it is not available”. Es una APIunstable_: 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
POSTa la página donde se usan. Si tumatcherexcluye/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:
- 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. - La tienda está probando dos diseños de portada. Cada visitante ve siempre el mismo, pero la URL es
/tiendapara todos. - Una API interna (
/api/interna/...) solo responde a quien manda una clave en la cabeceraAuthorization. - 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).

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
sesionde 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 conjose, 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 conheaders()denext/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>
)
}

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 conset, 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

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-rewritede la respuesta: Next la deja a la vista y revela la ruta interna. Además,/variantes/tienda-aes 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.)
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
matchero 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 desdeproxy.
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/3casa con:id(\\d+)y se redirige./catalogo/abcno casa con la expresión regular, así que la regla no se aplica (el 404 en JSON viene delfallbackdel apartado 6.2, que manda lo desconocido al “sitio antiguo”).- La redirección con
hassolo se aplica si la query tienever=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: falsemientras pruebas y pasa atruecuando 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()(niX-Frame-Optionsni elCache-Control: no-storede 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 elfetchy 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-Controlenheaders(). Para los recursos inmutables de_next/static, Next ponepublic, max-age=31536000, immutabley 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 concacheLife, no conheaders().
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:
process.env: lo que ya trae el proceso (variables de la shell, del sistema o del servicio de despliegue).env.$(NODE_ENV).local(por ejemplo,.env.production.local).env.local(no se carga cuandoNODE_ENVestest).env.$(NODE_ENV).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-appañade.env*al.gitignore, así que por defecto ningún fichero.envse 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.localo en el panel del servicio donde despliegas. Una práctica habitual es subir un.env.examplecon los nombres de las variables y valores ficticios.
Dos detalles del formato:
- Expansión:
API_PRODUCTOS=$API_URL/productosse convierte enhttp://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](images/13-variables-entorno.png)
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_. SALUDOno tiene prefijo: queda como acceso a unprocess.envvacío y valeundefined. No hay error ni aviso: simplemente no está.process.env[clave]conconst 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
undefineden el navegador. (Curiosamente, el minificador acaba escribiendoenv.NEXT_PUBLIC_NOMBRE_TIENDA, pero lo hace después de la sustitución, cuando ya es tarde.) Escribe siempreprocess.env.NEXT_PUBLIC_LO_QUE_SEAcompleto.
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 trasconnection()) 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,productionotest. No la definas en tus.env: Next la fija según la orden.NEXT_RUNTIME:nodejsoedge, según el runtime del código que se está ejecutando. La usarás eninstrumentation.tsen el módulo 14.
7.6. Ya no existen: serverRuntimeConfig y publicRuntimeConfig 🔄 16.0
🔄 Cambia en Next.js 16.0. Las opciones
serverRuntimeConfigypublicRuntimeConfigdenext.configse han eliminado. Si usas Next.js 15: aún existen en sus tipos, pero no las uses en código nuevo: leeprocess.enven el servidor y usaNEXT_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
hascon un grupo con nombre ((?<id>\\d+)) en elvalue: lo que casa se puede usar endestinationcomo:id. Devuelve 308. - La segunda es un 308 porque el cambio es definitivo.
- La tercera es un 307: la URL
/ofertasvolverá 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:
- El fichero pasa de
middleware.tsaproxy.ts. Hay que borrar el antiguo: con los dos, el build falla. - La función pasa de
middlewareaproxy. Si solo renombras el fichero, el build falla conProxy is missing expected function export name. - Desaparece
runtime: 'nodejs'. Parece inofensivo, porque el proxy ya es Node.js, pero enproxy.tscualquierruntimerompe 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_URLen el Route Handler:http://localhost:3000/api. Está en.env.localy en.env, y gana.env.local.NEXT_PUBLIC_TITULOen el cliente:EMPRESA (producción). Gana.env.productionsobre.env, y el valor se escribe en el JavaScript al compilar.- Con
API_URL=http://otraen la shell al arrancar, el Route Handler vehttp://otra: la shell gana a todos los ficheros y el handler lee el valor al ejecutarse. Si en cambio hubieras puestoNEXT_PUBLIC_TITULO=otroal arrancar, el cliente seguiría mostrandoEMPRESA (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 |