Módulo 23: Despliegue
Objetivos del módulo
- Entender qué produce
next build, qué necesita para funcionar y qué cambia entre desarrollo y producción - Conocer las cuatro formas de poner en producción una aplicación Next: servidor Node con
next start, contenedor conoutput: 'standalone', exportación estática conoutput: 'export'y plataformas con adaptador - Escribir un
Dockerfilede varias etapas para Next 16, con usuario sin privilegios, comprobación de salud y una imagen cuatro veces más pequeña que la versión ingenua - Distinguir las variables de entorno que se congelan en el build de las que se leen al ejecutar, para usar una sola imagen en todos los entornos
- Ejecutar varias réplicas detrás de Nginx sin que cada una tenga su propia verdad: caché compartida con
cacheHandlers, clave de las Server Actions ydeploymentId - Generar un sitio estático, servirlo con Nginx en un contenedor de 63 MB y saber exactamente qué funciones se pierden
- Saber qué ofrece Vercel, qué es la Adapter API estable desde Next.js 16.2, cómo se escribe un adaptador y dónde encaja OpenNext
- Configurar bien la caché HTTP y una CDN delante de Next
- Reconocer los errores de despliegue más habituales por su mensaje real
💡 Consejo: Todo lo que ves en este módulo se ha construido y ejecutado con Next.js 16.4 y Docker 29, en contenedores Linux de verdad, y las alternativas marcadas con Next.js 15.5. Instala siempre el último parche de la 16.4: los parches corrigen fallos de seguridad, pero no cambian las APIs. Las salidas que aparecen (tamaños de imagen, tiempos de arranque, cabeceras HTTP, mensajes de error) son reales. Nada de este módulo se ha publicado en servicios externos: Vercel y OpenNext se explican, pero no se despliegan.
1. Qué significa desplegar una aplicación Next
Durante el curso has trabajado casi siempre con next dev. Es un servidor pensado para ti, no para tus usuarios: compila cada ruta la primera vez que la pides, vigila los ficheros, muestra el panel de errores y no aplica casi ninguna optimización. Desplegar es llevar la aplicación a una máquina donde la usarán otras personas, y eso se hace en dos pasos que conviene no mezclar:
- Construir (
next build). Se hace una vez por versión del código. Compila TypeScript, empaqueta el JavaScript del navegador y del servidor, y prerenderiza todo lo que se puede calcular de antemano: páginas○estáticas, armazones◐de Partial Prerendering, resultados degenerateStaticParams. El resultado queda en la carpeta.next/(o enout/con exportación estática). - Ejecutar. Se hace en cada máquina y cada vez que arranca. Un proceso de Node lee
.next/, sirve lo prerenderizado y calcula en cada petición lo que es dinámico: los huecos de<Suspense>, las Server Actions, los Route Handlers dinámicos, elproxy.
Esta separación es la clave de casi todo el módulo. Lo que se decide en el paso 1 (qué páginas son estáticas, qué valor tenía una variable de entorno, qué versión de Node compiló los módulos nativos) queda grabado en el resultado. Lo que se decide en el paso 2 (puerto, URL de la base de datos, secretos) puede cambiar sin volver a construir. Si confundes los dos momentos, tendrás los errores típicos de despliegue: «en mi máquina funciona», «he cambiado la variable y no hace nada», «el contenedor no encuentra la API».
📘 Concepto: En el módulo 02 viste los modelos de renderizado y en el módulo 09 la caché. Desplegar es el momento en que esas decisiones se convierten en infraestructura: una página
○es un fichero que puede servir cualquier CDN; una página◐necesita un servidor que sepa completar los huecos; una Server Action necesita un servidor que recibaPOST.
1.1. Las cuatro opciones
Figura 1. Las cuatro formas de desplegar. Solo la exportación estática renuncia al servidor, y con él a buena parte de Next.
La documentación oficial resume así qué funciones tiene cada opción:
| Opción | Qué necesitas | Funciones de Next | Ejemplos |
|---|---|---|---|
Servidor Node (next start) | Una máquina con Node 20.9+ | Todas | VPS, servidor de la empresa, PaaS con Node |
Contenedor (output: 'standalone') | Docker o un orquestador | Todas | Kubernetes, Google Cloud Run, Fly.io, Render |
Exportación estática (output: 'export') | Cualquier servidor web | Limitadas | Nginx, Apache, GitHub Pages, un bucket S3 |
| Adaptador de plataforma | La plataforma y su adaptador | Depende del adaptador | Vercel, Bun; Netlify, Cloudflare, AWS con sus integraciones |
La documentación lo dice con una frase que merece la pena recordar: para ejecutar Next, tu plataforma necesita un servidor Node.js, y nada más. Un único proceso next start hace funcionar correctamente todas las funciones: Server Components, ISR, Partial Prerendering, Cache Components, Server Actions, proxy y after(). Lo demás (CDN, varias réplicas, caché compartida) sirve para ir más rápido o para escalar, no para que funcione.
1.2. El laboratorio de este módulo
Para que las pruebas sean reales, el módulo usa dos proyectos pequeños de la aplicación EMPRESA:
app-docker: la parte de servidor. Una portada que muestra variables de entorno, una página/productosque lee la API EMPRESA, una página/marcacon un dato en caché ('use cache') y dos Route Handlers:/api/saludpara las comprobaciones de salud y/api/revalidarpara invalidar la caché.app-estatica: el catálogo público de la tienda, generado entero en el build a partir de la API, con una página por producto.
Los datos salen de la API EMPRESA (recursos/empresa-api.zip, http://localhost:3000/api). En la parte privada de la aplicación usarías la base de datos SQLite del módulo 16; en la sección 5.6 verás qué cambia al meter una base de datos de fichero en un contenedor.
2. next build y next start: el servidor Node
La forma más sencilla de desplegar es la que ya conoces: los scripts que crea create-next-app.
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
}
}
En la máquina de producción:
npm ci # instala exactamente lo que dice package-lock.json
npm run build # genera .next/
npm run start -- -p 3001 # sirve la aplicación en el puerto 3001
npm ci es preferible a npm install en un servidor: no modifica package-lock.json, borra node_modules antes de instalar y falla si el lock no cuadra con package.json. Así sabes que producción tiene exactamente las versiones que probaste.
⚠️ Importante:
next buildprerenderiza páginas, y para eso ejecuta tu código. Si una página○lee la API EMPRESA o la base de datos, esa API tiene que estar accesible durante el build, no solo al ejecutar. Es una de las sorpresas más habituales en integración continua y en Docker: el build falla porque en la máquina que construye no hay base de datos. Lo verás resuelto en la sección 4.
2.1. Opciones de next start
| Opción | Para qué |
|---|---|
-p 3001 o la variable PORT | Puerto (3000 por defecto) |
-H 127.0.0.1 | Interfaz en la que escucha. Con 127.0.0.1 solo se puede llegar desde la misma máquina, lo correcto si delante hay un proxy inverso |
--keepAliveTimeout 70000 | Tiempo que se mantienen abiertas las conexiones. Si delante hay un balanceador, debe ser mayor que el suyo para que no corte conexiones que el balanceador cree vivas |
--inspect | Conecta el depurador de Node al servidor de producción (desde 16.2) |
next build y next start trabajan en modo producción por sí mismos: no hace falta que definas NODE_ENV. Lo que no debes hacer es forzar NODE_ENV=development en producción, porque muchas bibliotecas (React incluido) activan entonces comprobaciones lentas pensadas para desarrollo.
2.2. Mantener el proceso vivo
next start es un proceso normal: si la máquina se reinicia o el proceso falla, la aplicación desaparece. En un servidor propio necesitas un gestor de procesos que lo arranque al iniciar el sistema y lo reinicie si cae. Las opciones habituales son systemd en Linux (un fichero .service con ExecStart=/usr/bin/npm run start y Restart=always) o PM2 (pm2 start npm --name empresa -- start). En contenedores, ese papel lo hace Docker con restart: unless-stopped o el orquestador.
Al parar el servidor, envía SIGTERM (o SIGINT) y espera. Next termina las peticiones en curso y ejecuta los after() pendientes antes de salir. La documentación recomienda dejar entre 10 y 30 segundos para ese drenaje. docker stop envía SIGTERM y espera 10 segundos antes de matar el proceso; si tus after() hacen trabajo largo, súbelo con docker stop -t 30 o stop_grace_period en Compose.
2.3. Un proxy inverso delante
La documentación recomienda no exponer el servidor de Next directamente a Internet, sino poner delante un proxy inverso como Nginx. El proxy se encarga del certificado TLS, de rechazar peticiones mal formadas, de los ataques de conexión lenta, de limitar el tamaño de los cuerpos y la tasa de peticiones. Así el proceso de Node dedica su tiempo a renderizar.
Hay un detalle que rompe aplicaciones App Router sin dar ningún error: el buffering. Por defecto, Nginx acumula la respuesta del servidor antes de enviarla al navegador. Con páginas que hacen streaming (todas las que tienen <Suspense>, que en este curso son casi todas), el usuario deja de ver el armazón al instante y recibe la página de golpe cuando termina el último hueco. Funciona, pero pierdes la ventaja del streaming que viste en el módulo 08. Hay dos formas de evitarlo:
# En la configuración de Nginx, para todo lo que va a Next
proxy_buffering off;
// O desde Next, con una cabecera que Nginx entiende
async headers() {
return [{ source: '/:path*{/}?', headers: [{ key: 'X-Accel-Buffering', value: 'no' }] }]
}
Lo mismo vale para cualquier pieza intermedia: balanceadores, CDN, compresión. Si alguna acumula, el streaming desaparece.
3. output: 'standalone': lo justo para ejecutar
El problema de next start es que necesita todo node_modules en la máquina de producción: también TypeScript, ESLint, Tailwind, las herramientas de pruebas y cualquier dependencia que solo se use al compilar. Para un contenedor es un desperdicio enorme.
Next resuelve esto con el rastreo de ficheros (output file tracing). Durante el build, una herramienta llamada @vercel/nft analiza cada import, require y lectura de ficheros de cada página para saber qué ficheros necesita de verdad. Con esa información, la opción output: 'standalone' crea una carpeta autosuficiente:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfig
Después de next build, aparece .next/standalone/ con:
server.js: un servidor mínimo que sustituye anext start. Se arranca connode server.js.package.jsony.next/server/: el código compilado del servidor y lo prerenderizado.- Un
node_modulesrecortado: solo los paquetes que el rastreo ha encontrado.
Esto es lo que contiene node_modules dentro del contenedor del laboratorio:
56.5M /app/node_modules
@img @next @swc client-only detect-libc next react react-dom semver sharp styled-jsx
/app/node_modules/@img:
colour sharp-libvips-linux-x64 sharp-libvips-linuxmusl-x64 sharp-linux-x64 sharp-linuxmusl-x64
Ni TypeScript, ni Tailwind, ni ESLint. Fíjate en que sí está sharp, la biblioteca nativa que usa next/image para optimizar imágenes, con sus binarios para Linux. Sin ella la optimización de imágenes no funcionaría en el servidor.
⚠️ Importante:
standaloneno copia dos carpetas:public/y.next/static/(el JavaScript y el CSS del navegador). La documentación lo justifica porque lo ideal es servirlas desde una CDN. Si no las copias tú,server.jsarranca y el HTML llega, pero cada.jsy.cssda 404 (en el laboratorio: la portada respondió 200 y/_next/static/chunks/1x6obpmlu1_c2.js, 404). Verás la página sin estilos y sin interactividad. Se copian así:cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
3.1. server.js lleva la configuración dentro
Si abres el server.js generado, verás que la configuración de next.config.ts está escrita dentro, ya evaluada:
const currentPort = parseInt(process.env.PORT, 10) || 3000
const hostname = process.env.HOSTNAME || '0.0.0.0'
let keepAliveTimeout = parseInt(process.env.KEEP_ALIVE_TIMEOUT, 10)
const nextConfig = {"env":{},"webpack":null,"typescript":{"ignoreBuildErrors":false},...
Tiene dos consecuencias importantes:
- El puerto, la interfaz y el keep-alive se eligen al arrancar, con las variables
PORT,HOSTNAMEyKEEP_ALIVE_TIMEOUT. Por eso en un contenedor se usaHOSTNAME=0.0.0.0: si escuchara solo en127.0.0.1, nada de fuera del contenedor podría conectarse. - Todo lo que pongas en
next.config.tsse evalúa al construir. Si escribesdeploymentId: process.env.DEPLOYMENT_ID, el valor que cuenta es el que había durantenext build, no el del contenedor.
3.2. Monorepos y la raíz del rastreo
Al probar standalone en el laboratorio, fuera de Docker, apareció algo inesperado. La carpeta generada no tenía server.js en la raíz, sino aquí:
.next/standalone/
├── node_modules/
├── package.json
└── empresa/
└── app-docker/
├── .next/
├── cache-handlers/
├── package.json
└── server.js
El motivo: en el laboratorio, los node_modules están en una carpeta superior al proyecto, compartidos entre varios. Next toma como raíz del rastreo la carpeta común a todo lo que necesita, y reproduce la estructura desde ahí. Es exactamente lo que pasa en un monorepo (por ejemplo, apps/web y packages/ui con un node_modules en la raíz). Tiene tres arreglos:
- Arrancar con la ruta real:
node .next/standalone/apps/web/server.js. - Fijar la raíz a mano con
outputFileTracingRoot: path.join(__dirname, '../../')ennext.config.ts, para que sea predecible. - Construir dentro de Docker con el proyecto en
/appy su propionode_modules, como en la sección 4. Allí la raíz es el proyecto yserver.jsqueda arriba.
Al mismo tiempo, Next avisó de otra cosa que verás en máquinas de alumnos con proyectos dentro de la carpeta personal:
⚠ Warning: Next.js ignored package-lock.json in C:\Users\alumno because it would include your home directory (C:\Users\alumno).
To use this directory, set `turbopack.root` in your Next.js config.
Turbopack busca hacia arriba el último fichero de bloqueo para decidir la raíz del proyecto. Si encuentra un package-lock.json olvidado en tu carpeta personal (de algún npm install hecho por error ahí), lo ignora y avisa. La solución es borrar ese fichero suelto, o fijar turbopack.root si de verdad quieres esa raíz.
3.3. Cuando el rastreo se equivoca
El análisis es estático: ve import x from 'y' y fs.readFile(path.join(process.cwd(), 'datos.json')) con rutas fijas, pero no adivina ficheros que se cargan con nombres calculados en tiempo de ejecución. Para esos casos hay dos opciones en next.config.ts, cuyas claves son rutas de la aplicación y cuyos valores son patrones de ficheros desde la raíz del proyecto:
const nextConfig: NextConfig = {
output: 'standalone',
outputFileTracingIncludes: {
// incluir los mensajes de traducción en todas las rutas
'/*': ['./messages/**/*.json'],
},
outputFileTracingExcludes: {
// no meter en la ruta de informes unos ficheros enormes que no usa
'/api/informes': ['./datos-historicos/**/*'],
},
}
En el laboratorio, el manejador de caché de la sección 6 (cache-handlers/disco.js), que se referencia con require.resolve en next.config.ts, sí se copió solo a la carpeta standalone, como muestra el árbol de arriba.
4. Docker con output: 'standalone'
Un contenedor empaqueta la aplicación con su sistema operativo mínimo, su versión de Node y sus dependencias. La misma imagen funciona igual en tu portátil, en el servidor de pruebas y en producción. Docker es además la forma estándar de llegar a casi cualquier proveedor: Kubernetes, Google Cloud Run, Azure Container Apps, AWS ECS, Fly.io, Render o un VPS con Docker.
💡 Consejo: La documentación recomienda no usar Docker para desarrollar en Windows y macOS: el sistema de ficheros compartido con el contenedor es lento y la recarga en caliente sufre. Desarrolla con
npm run devy usa Docker para construir y probar lo que irá a producción.
4.1. El Dockerfile de varias etapas
Figura 2. Cada etapa parte de la anterior o copia de ella. Solo runner acaba en la imagen final: el código fuente, las devDependencies y las cachés se quedan en las etapas intermedias.
# syntax=docker/dockerfile:1
# Imagen de producción de EMPRESA con output: 'standalone'
# 1) Base común: Node 24 sobre Alpine (Next 16 exige Node 20.9 o superior)
FROM node:24-alpine AS base
# 2) Dependencias: solo cambia si cambian package.json o package-lock.json
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 3) Build: compila la aplicación con todas las dependencias (también las de desarrollo)
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Las NEXT_PUBLIC_* se escriben en el JavaScript del navegador: hacen falta AQUÍ
ARG NEXT_PUBLIC_ENTORNO
ENV NEXT_PUBLIC_ENTORNO=$NEXT_PUBLIC_ENTORNO
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# 4) Ejecución: solo la carpeta standalone, los estáticos y public
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs \
&& mkdir /cache && chown nextjs:nodejs /cache
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/salud || exit 1
CMD ["node", "server.js"]
Vamos por partes, porque cada línea tiene un motivo:
node:24-alpine. Alpine es una distribución Linux mínima; la imagen de Node sobre Alpine ocupa 172 MB en el laboratorio, mucho menos que la imagen completa basada en Debian, que trae compiladores y herramientas que no necesitas para ejecutar. Next 16 necesita Node 20.9 o superior; Node 24 es una versión LTS. Fija siempre la versión mayor (node:24-alpine, nonode:alpine) para que una reconstrucción no cambie de Node sin que lo decidas.- La etapa
depscopia solopackage.jsonypackage-lock.jsonantes del código. Docker guarda cada paso como una capa en caché y solo repite un paso si cambia lo que copia. Como las dependencias cambian mucho menos que el código, elnpm ci(el paso más lento) se reutiliza en casi todas las construcciones. npm ciinstala también las devDependencies: hacen falta para compilar (TypeScript, Tailwind). No llegan a la imagen final porque la etaparunnerno copianode_modulesde aquí.- La compilación ocurre dentro de Linux. Es importante: los paquetes nativos (
sharp,@tailwindcss/oxide,lightningcss, el compilador SWC) descargan un binario distinto para cada sistema. Si compilaras en Windows y copiaras el resultado al contenedor, llevarías los binarios de Windows y fallarían. ARG/ENV NEXT_PUBLIC_ENTORNOen la etapa de build: las variables públicas se escriben dentro del JavaScript al compilar (sección 5), así que tienen que existir en ese momento.NEXT_TELEMETRY_DISABLED=1desactiva el envío anónimo de estadísticas de uso de Next.- Usuario
nextjssin privilegios. Por defecto los contenedores ejecutan comoroot. Si alguien encontrara una vulnerabilidad en tu aplicación, tendríarootdentro del contenedor. Con un usuario propio, el daño queda mucho más limitado. La carpeta/cachese crea con ese dueño porque la usaremos como volumen compartido en la sección 6: un volumen nuevo hereda los permisos de la carpeta de la imagen, y si fuera derootel usuarionextjsno podría escribir en ella. - Las tres copias son exactamente lo que dice la sección 3:
standaloney, a mano,publicy.next/static. HEALTHCHECKpide/api/saludcada 10 segundos. Docker marca el contenedor comohealthyounhealthy, y Compose u otros orquestadores pueden esperar a que esté sano antes de mandarle tráfico. Alpine traewget, así que no hace falta instalarcurl.
El fichero .dockerignore es igual de importante. Sin él, COPY . . metería en la imagen tu node_modules de Windows, tu .next local y, peor aún, tus ficheros .env con secretos:
node_modules
.next
out
despliegue
.env*
!.env.example
Dockerfile*
.dockerignore
.git
npm-debug.log*
*.tsbuildinfo
4.2. Un Route Handler para la comprobación de salud
// app/api/salud/route.ts: comprobación de salud para Docker y el balanceador
import { connection } from 'next/server'
import { hostname } from 'node:os'
export async function GET() {
await connection() // se ejecuta en cada petición, nunca se prerenderiza
return Response.json({ ok: true, host: hostname(), hora: new Date().toISOString() })
}
Recuerda del módulo 12 que un GET que no lee nada de la petición puede prerenderizarse en el build como fichero estático. Una comprobación de salud estática siempre diría «estoy bien» aunque el servidor no pudiera renderizar nada. connection() deja escrito, sin depender de detalles, que esta ruta se ejecuta de verdad en cada petición (en el ejercicio 5 verás un adaptador que lo vigila). En una aplicación real, esta ruta puede comprobar también que la base de datos responde.
4.3. Construir y medir
docker build -t empresa-next23:standalone --build-arg NEXT_PUBLIC_ENTORNO=produccion .
La primera vez es lenta, porque descarga la imagen base y todas las dependencias. Estas son las líneas relevantes de la salida real:
#12 [deps 3/3] RUN npm ci
#12 358.1 added 51 packages, and audited 52 packages in 6m
#12 DONE 362.0s
...
#15 [builder 4/4] RUN npm run build
#15 105.1 Route (app)
#15 105.1 ├ ƒ /api/revalidar
#15 105.1 ├ ƒ /api/salud
#15 105.1 ├ ◐ /marca
#15 105.1 └ ◐ /productos
#15 DONE 106.2s
...
real 10m2,646s
Al cambiar una línea de código y repetir, el npm ci sale de la caché de capas y la construcción bajó a 2m31s, casi todo next build.
Para ver de qué está hecha la imagen, docker history muestra el tamaño de cada capa:
0B CMD ["node" "server.js"]
0B HEALTHCHECK {Test:[CMD-SHELL wget -qO- http:…
0B EXPOSE [3000/tcp]
0B USER nextjs
749kB COPY --chown=nextjs:nodejs /app/.next/static…
57MB COPY --chown=nextjs:nodejs /app/.next/standa…
3.31kB COPY --chown=nextjs:nodejs /app/public ./pub…
3.21kB RUN /bin/sh -c addgroup -S -g 1001 nodejs &&…
Toda la aplicación son 57 MB encima de la imagen de Node. Para comparar, se construyó también una imagen «ingenua» con un Dockerfile de una sola etapa que hace npm ci, copia el código, compila y arranca con npm start:
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "start"]
103MB RUN /bin/sh -c npm run build # buildkit
90.4kB COPY . . # buildkit
652MB RUN /bin/sh -c npm ci # buildkit
| Imagen | Tamaño | Arranque | Memoria en reposo |
|---|---|---|---|
node:24-alpine (solo la base) | 172 MB | ||
Ingenua: npm ci + next start | 927 MB | Ready in 1887ms | 92,7 MiB |
Varias etapas + standalone | 230 MB | Ready in 0ms | 52,9 MiB |
| Catálogo estático con Nginx (sección 7) | 63 MB | inmediato |
La imagen standalone ocupa una cuarta parte y arranca antes. En un orquestador que levanta réplicas cuando sube el tráfico, eso se nota: menos que descargar en cada máquina, menos tiempo hasta servir la primera petición y menos superficie de ataque, porque en la imagen no hay compiladores ni herramientas que un atacante pueda aprovechar.
La versión ingenua dejó además un aviso en el registro, porque el proyecto tiene output: 'standalone' y se arrancó con next start:
⚠ "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.
4.4. Ejecutar el contenedor
docker run -d --name empresa -p 127.0.0.1:3001:3000 \
-e SALUDO=hola-desde-docker-run \
-e API_URL=http://host.docker.internal:3000/api \
empresa-next23:standalone
-p 127.0.0.1:3001:3000publica el puerto 3000 del contenedor en el 3001 de tu máquina, y solo en la interfaz local.-edefine variables de entorno de ejecución: las verás en acción en la sección 5.host.docker.internales el nombre con el que un contenedor llega a tu máquina. Dentro del contenedor,localhostes el propio contenedor: si la página/productosintentara leerhttp://localhost:3000/api, se llamaría a sí misma. En Docker Desktop (Windows y macOS) ese nombre existe siempre; en Linux, añade--add-host=host.docker.internal:host-gateway.
El registro del contenedor y una comprobación con curl:
▲ Next.js 16.4.3
- Local: http://localhost:3000
- Network: http://0.0.0.0:3000
✓ Ready in 0ms
✓ Running next.config took 8ms
curl http://127.0.0.1:3001/api/salud
{"ok":true,"host":"540e0d84d820","hora":"2026-11-02T13:03:03.980Z"}
host es el nombre del contenedor, que Docker genera a partir de su identificador. A los pocos segundos, docker inspect empresa --format '{{.State.Health.Status}}' responde healthy. La página /productos, que lee la API EMPRESA del ordenador anfitrión a través de host.docker.internal, devuelve la lista:
<li>Portátil UltraBook 14
<li>Portátil Gaming 16
<li>Portátil Básico 15
4.5. La caché del build entre compilaciones
En el módulo 01 viste que next build guarda en .next/cache trabajo que puede reutilizar. Dentro de Docker, cada construcción empieza con una carpeta .next vacía (el .dockerignore excluye la tuya, y debe hacerlo). Si quieres conservar esa caché entre construcciones, BuildKit permite montar una caché persistente solo durante un paso:
RUN --mount=type=cache,target=/app/.next/cache npm run build
Esa carpeta no entra en la imagen: vive en la caché de BuildKit de la máquina que construye. En integración continua, la documentación (guía CI Build Caching) explica cómo guardar .next/cache entre ejecuciones en cada proveedor.
5. Variables de entorno: el build frente a la ejecución
En el módulo 13 viste los ficheros .env y el prefijo NEXT_PUBLIC_. En un despliegue aparece la pregunta que de verdad importa: ¿cuándo se lee cada variable?
Figura 3. Lo que se calcula en el build queda escrito en la imagen. Lo que se calcula al ejecutar puede cambiar en cada entorno sin reconstruir.
El objetivo es una única imagen que pase por pruebas, preproducción y producción con valores distintos de URL de la API, claves y secretos. Si una variable se congela en el build, necesitas una imagen por entorno, y lo que despliegas en producción ya no es exactamente lo que probaste.
5.1. El experimento
La portada del laboratorio lee la misma variable SALUDO en dos sitios, y NEXT_PUBLIC_ENTORNO en un componente cliente:
// app/page.tsx: qué variables se congelan en el build y cuáles se leen al arrancar
import { Suspense } from 'react'
import { connection } from 'next/server'
import { hostname } from 'node:os'
import Link from 'next/link'
import EntornoCliente from './entorno-cliente'
async function DatosDeEjecucion() {
await connection() // a partir de aquí, todo se calcula en cada petición
return (
<ul className="list-disc pl-6">
<li>SALUDO leído al pedir la página: <b>{process.env.SALUDO ?? '(sin definir)'}</b></li>
<li>Servido por el contenedor: <b>{hostname()}</b></li>
</ul>
)
}
export default function Inicio() {
return (
<main className="mx-auto max-w-2xl p-8 space-y-4">
<h1 className="text-2xl font-bold">EMPRESA en un contenedor</h1>
<p>SALUDO congelado en el build: <b>{process.env.SALUDO ?? '(sin definir)'}</b></p>
<Suspense fallback={<p>Cargando datos de ejecución…</p>}>
<DatosDeEjecucion />
</Suspense>
<EntornoCliente />
<nav className="flex gap-4 text-blue-700 underline">
<Link href="/productos">Productos</Link>
<Link href="/marca">Marca en caché</Link>
</nav>
</main>
)
}
// app/entorno-cliente.tsx
'use client'
// NEXT_PUBLIC_*: el valor se escribe dentro del JavaScript al hacer el build
export default function EntornoCliente() {
return (
<p>
NEXT_PUBLIC_ENTORNO en el navegador:{' '}
<b>{process.env.NEXT_PUBLIC_ENTORNO ?? '(sin definir)'}</b>
</p>
)
}
La imagen se construyó con --build-arg NEXT_PUBLIC_ENTORNO=produccion y sin SALUDO, y el contenedor se arrancó con -e SALUDO=hola-desde-docker-run. Esto es lo que contiene el HTML que envía el servidor:
SALUDO congelado en el build: <b>(sin definir)
SALUDO leído al pedir la página: <b>hola-desde-docker-run
Servido por el contenedor: <b>540e0d84d820
- El primer párrafo forma parte del armazón estático de la página
◐. Se renderizó durantenext build, cuandoSALUDOno existía, y ese HTML se sirve tal cual en cada petición. - El segundo está dentro de un componente que llama a
connection(): se calcula en cada petición y lee el valor del contenedor.
¿Y NEXT_PUBLIC_ENTORNO? Busca el valor dentro de los ficheros JavaScript de la imagen:
docker exec empresa sh -c 'grep -o ".\{30\}produccion.\{10\}" /app/.next/static/chunks/*.js'
/app/.next/static/chunks/03zj8xymo-co4.js:," ",(0,t.jsx)("b",{children:"produccion"})]})}])}
El compilador sustituyó process.env.NEXT_PUBLIC_ENTORNO por el texto "produccion". Ya no hay variable: hay un literal dentro de un fichero con hash en el nombre que el navegador guarda en caché un año. Arrancar el contenedor con -e NEXT_PUBLIC_ENTORNO=otro no cambia nada.
5.2. La trampa: el HTML dice una cosa y la pantalla otra 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 no hay armazón estático con huecos: una página que lee
process.envsin APIs dinámicas es estática entera (valor del build en HTML y RSC), y conconnection()es dinámica entera. Si usas Next.js 15: no verás el cambio en pantalla, pero la regla es la misma: lee las variables de cada entorno después deawait connection(), que existe también en la 15.
Al hacer la captura de la portada en el navegador apareció algo sorprendente:

Figura 4. En pantalla, el párrafo «congelado en el build» muestra el valor de ejecución, aunque el HTML recibido decía (sin definir).
El HTML que llegó al navegador decía (sin definir), pero la pantalla muestra hola-desde-docker-run. Buscando el valor en la respuesta completa aparece la explicación:
\"children\":[\"SALUDO congelado en el build: \",[\"$\",\"b\",null,{\"children\":\"hola-desde-docker-run\"}]]
Para completar los huecos dinámicos, Next vuelve a renderizar la página en cada petición y envía la carga útil RSC con el árbol entero. En ese render, el párrafo «estático» leyó process.env.SALUDO en tiempo de ejecución. Al hidratar, React encuentra que el HTML y la carga RSC no coinciden y se queda con la carga RSC. Resultado: el valor cambia delante del usuario y puedes tener errores de hidratación.
⚠️ Importante: No leas
process.envde variables que cambian por entorno en la parte estática de una página. El valor del HTML será el del build y el de la carga RSC el de ejecución. Léelas siempre después deawait connection()(o dentro de algo dinámico:cookies(),headers(), una Server Action, un Route Handler dinámico), como haceDatosDeEjecucion. Si un valor es de verdad constante para toda la vida de la imagen, entonces sí puede ir en el armazón.
5.3. Configuración pública en tiempo de ejecución
¿Y si el navegador necesita un valor que cambia por entorno, por ejemplo la URL pública de la API o la clave pública de un servicio de mapas? NEXT_PUBLIC_ lo congela. Next 16 eliminó publicRuntimeConfig, que era la vía antigua. La forma recomendada es leerla en el servidor en tiempo de ejecución y pasarla como prop:
// app/mapa/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
import Mapa from './mapa' // componente cliente
async function MapaConClave() {
await connection()
return <Mapa clavePublica={process.env.MAPAS_CLAVE_PUBLICA ?? ''} />
}
export default function Pagina() {
return (
<Suspense fallback={<p>Cargando mapa…</p>}>
<MapaConClave />
</Suspense>
)
}
Así el valor viaja en la carga RSC de cada petición y la imagen sirve para todos los entornos. Solo vale para datos públicos: todo lo que pasas como prop a un componente cliente llega al navegador (módulo 05).
5.4. Secretos
- Nunca metas un
.envcon secretos en la imagen: el.dockerignorelo excluye. Cualquiera que tenga la imagen podría leerlo condocker historyo extrayendo las capas. - Pasa los secretos al ejecutar:
docker run -e,env_file:en Compose, los secrets del orquestador o el gestor de secretos de tu proveedor. - Si el build necesita un secreto (por ejemplo, un token para descargar paquetes privados), usa los secretos de BuildKit (
RUN --mount=type=secret,...) y noARG: losARGquedan registrados en el historial de la imagen.
5.5. ¿Necesita el build la API?
Las páginas /productos y /marca del laboratorio leen datos después de connection(), así que el build dentro de Docker no necesitó la API, que no estaba accesible desde allí. Si una página ○ o el armazón de una ◐ leyera la API o la base de datos, el RUN npm run build fallaría o, peor, congelaría datos de la máquina de construcción. Tienes tres salidas:
- Mover la lectura detrás de
connection()o dentro de<Suspense>con datos de ejecución, si el dato debe ser fresco. - Usar
'use cache'concacheLifey dejar que se rellene en la primera petición. Si el dato se lee en el armazón, sigue haciendo falta en el build. - Dar acceso a la fuente de datos durante el build (una base de datos de pruebas, una API de preproducción), sabiendo que lo prerenderizado lleva esos datos.
5.6. Una base de datos de fichero en un contenedor
La aplicación EMPRESA usa SQLite (módulo 16). Un contenedor es efímero: al recrearlo, todo lo que escribió en su sistema de ficheros desaparece. Por eso:
- El fichero
.dbno va dentro de la imagen. Va en un volumen (-v empresa-datos:/datos) y la aplicación lo encuentra por una variable de ejecución, por ejemploDB_FILE=/datos/empresa.db. - Las migraciones (
drizzle-kit migrate) se ejecutan como un paso aparte del despliegue, no en cada arranque de cada réplica. - SQLite en un volumen solo sirve para una réplica. Si necesitas varias, la base de datos tiene que ser un servicio aparte (PostgreSQL, MySQL o un SQLite remoto como Turso, que
@libsql/clienttambién admite con una URLlibsql://).
6. Varias réplicas: lo que deja de ser automático
Con una sola instancia de next start, todo funciona sin configurar nada. En cuanto pones dos o más detrás de un balanceador, para aguantar más tráfico o para no tener cortes al actualizar, aparecen tres problemas.
Figura 5. Dos réplicas de la misma imagen. Cada una tiene su propia memoria; lo que deban compartir hay que sacarlo fuera.
6.1. El montaje: Compose con Nginx delante
# compose.yaml: dos instancias de la MISMA imagen detrás de Nginx, con caché compartida
name: next23
x-web: &web
image: empresa-next23:standalone
environment:
SALUDO: hola-desde-compose
API_URL: http://host.docker.internal:3000/api
SECRETO_REVALIDAR: cambia-esto
CACHE_DIR: ${CACHE_DIR-/cache}
volumes:
- cache-next:/cache
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
services:
web1:
<<: *web
hostname: web1
web2:
<<: *web
hostname: web2
proxy:
image: nginx:1.29-alpine
volumes:
- ./nginx/balanceador.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "127.0.0.1:3001:80"
depends_on:
web1:
condition: service_healthy
web2:
condition: service_healthy
volumes:
cache-next:
# nginx/balanceador.conf
upstream next {
server web1:3000;
server web2:3000;
}
server {
listen 80;
location / {
proxy_pass http://next;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Sin esto Nginx acumula la respuesta y se pierde el streaming de Suspense
proxy_buffering off;
}
}
x-web: &web es un bloque reutilizable de YAML: las dos réplicas comparten imagen, variables y volumen, y solo cambia su nombre. depends_on con service_healthy aprovecha el HEALTHCHECK de la imagen: Nginx no arranca hasta que las dos réplicas responden en /api/salud. docker compose up -d --wait espera a que todo esté sano antes de devolver el control.
Las réplicas solo se ven entre sí dentro de la red de Compose (web1:3000); hacia fuera solo se publica Nginx.
6.2. Problema 1: cada réplica tiene su propia caché 🔄 16.0
🔄 Cambia en Next.js 16.0. El ejemplo usa
'use cache',cacheTagycacheLife, que en Next.js 15 son experimentales. Si usas Next.js 15: el problema es idéntico conunstable_cacheofetchconnext: { revalidate, tags }, cuya caché también vive en cada réplica, yrevalidateTag('marca')con un solo argumento.
La página /marca guarda un dato con 'use cache' y muestra quién lo calculó y quién sirve la página:
// app/marca/page.tsx: un dato en caché ('use cache') y quién lo calculó
import { Suspense } from 'react'
import { connection } from 'next/server'
import { cacheLife, cacheTag } from 'next/cache'
import { hostname } from 'node:os'
async function obtenerMarca() {
'use cache'
cacheTag('marca')
cacheLife('hours')
console.log('[marca] calculando en', hostname())
return { creada: new Date().toISOString(), calculadaPor: hostname() }
}
async function Marca() {
await connection() // se calcula al pedir la página, no en el build
const marca = await obtenerMarca()
return (
<dl className="grid grid-cols-2 gap-2">
<dt>Marca creada</dt><dd><b>{marca.creada}</b></dd>
<dt>Calculada por</dt><dd><b>{marca.calculadaPor}</b></dd>
<dt>Servida por</dt><dd><b>{hostname()}</b></dd>
</dl>
)
}
export default function PaginaMarca() {
return (
<main className="mx-auto max-w-2xl p-8 space-y-4">
<h1 className="text-2xl font-bold">Marca en caché</h1>
<Suspense fallback={<p>Cargando…</p>}>
<Marca />
</Suspense>
</main>
)
}
Y un Route Handler para invalidarla, protegido con un secreto:
// app/api/revalidar/route.ts: invalida la etiqueta 'marca' (protegido con un secreto)
import { revalidateTag } from 'next/cache'
import { hostname } from 'node:os'
export async function POST(request: Request) {
if (request.headers.get('x-secreto') !== process.env.SECRETO_REVALIDAR) {
return Response.json({ error: 'No autorizado' }, { status: 401 })
}
revalidateTag('marca', { expire: 0 }) // caduca ya: la siguiente lectura recalcula
return Response.json({ revalidada: 'marca', por: hostname() })
}
Primero, sin caché compartida (CACHE_DIR= vacío, así el manejador de la sección 6.3 guarda en memoria, igual que el de serie). Diez peticiones a /marca, agrupadas con sort | uniq -c:
8 creada=2026-11-02T13:06:45.438Z Calculada por=web1 Servida por=web1
2 creada=2026-11-02T13:07:19.425Z Calculada por=web2 Servida por=web2
Cada réplica calculó su propio valor. Un usuario que recarga puede ver alternarse dos versiones del mismo dato. Ahora invalidamos:
$ curl -X POST -H "x-secreto: cambia-esto" http://127.0.0.1:3001/api/revalidar
{"revalidada":"marca","por":"web2"}
5 creada=2026-11-02T13:06:45.438Z Calculada por=web1 Servida por=web1
5 creada=2026-11-02T13:07:53.420Z Calculada por=web2 Servida por=web2
La petición de invalidación cayó en web2, y solo web2 recalculó. web1 sigue sirviendo el valor antiguo, y lo hará hasta que caduque por su cuenta (horas, con el perfil 'hours'). Si esto fuera el precio de un producto, la mitad de tus clientes verían el precio viejo.
💡 Consejo: ¿Por qué en las diez primeras peticiones Nginx mandó ocho a
web1? El reparto por turnos de Nginx lleva la cuenta en cada proceso trabajador, y cadacurlabre una conexión nueva que puede caer en otro trabajador que empieza su cuenta porweb1. Con tráfico real el reparto se equilibra; no lo confundas con un fallo.
6.3. La solución: un manejador de caché compartido 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15,
'use cache'y la opcióncacheHandlersson experimentales (experimental.cacheHandlers, junto conexperimental.useCache). Si usas Next.js 15: con el modelo de caché de la 15 (fetchconnext.revalidate, ISR,unstable_cache) usa la opción establecacheHandler(en singular), que cubre exactamente esas cachés y se comparte igual a través de Redis.
Next tiene dos puntos de extensión para la caché del servidor, y conviene no confundirlos:
| Opción | Qué cubre |
|---|---|
cacheHandlers (plural) | Las entradas de 'use cache' y 'use cache: remote' (Cache Components) |
cacheHandler (singular) | La caché «clásica»: páginas ISR, Route Handlers en caché, fetch con caché, unstable_cache y optimización de imágenes |
Por defecto, 'use cache' guarda en una caché LRU en la memoria de cada proceso. Un manejador propio es un módulo que implementa cinco métodos: get, set, refreshTags, getExpiration y updateTags. Este, escrito para el laboratorio, guarda las entradas en una carpeta. Si varias réplicas montan la misma carpeta, comparten la caché:
// cache-handlers/disco.js
// Manejador de caché para 'use cache' que guarda las entradas en una carpeta.
// Si varias instancias montan la MISMA carpeta (un volumen), comparten la caché.
// Sin CACHE_DIR se comporta como la caché por defecto: memoria de este proceso.
// Es un módulo CommonJS que Next carga con require: la regla de ESLint no aplica.
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('node:fs/promises')
const path = require('node:path')
const crypto = require('node:crypto')
const DIR = process.env.CACHE_DIR // se lee al arrancar el servidor, no en el build
const memoria = new Map() // solo se usa sin CACHE_DIR
let etiquetas = {} // { etiqueta: { stale, expired } } en milisegundos
const ficheroEntrada = (clave) =>
path.join(DIR, 'entradas', crypto.createHash('sha256').update(clave).digest('hex') + '.json')
const ficheroEtiquetas = () => path.join(DIR, 'etiquetas.json')
async function escribirAtomico(fichero, contenido) {
// Escribir en un temporal y renombrar: nadie lee nunca un fichero a medias
await fs.mkdir(path.dirname(fichero), { recursive: true })
const tmp = `${fichero}.${process.pid}.${Date.now()}.tmp`
await fs.writeFile(tmp, contenido)
await fs.rename(tmp, fichero)
}
async function leerEtiquetas() {
if (!DIR) return
const texto = await fs.readFile(ficheroEtiquetas(), 'utf8').catch(() => null)
etiquetas = texto ? JSON.parse(texto) : {}
}
function flujo(bytes) {
return new ReadableStream({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
}
module.exports = {
async get(clave) {
try {
let guardada
if (DIR) {
const texto = await fs.readFile(ficheroEntrada(clave), 'utf8').catch(() => null)
if (!texto) return undefined
const datos = JSON.parse(texto)
guardada = { ...datos, value: Buffer.from(datos.value, 'base64') }
} else {
guardada = memoria.get(clave)
if (!guardada) return undefined
}
let revalidate = guardada.revalidate
for (const etiqueta of guardada.tags) {
const { stale = 0, expired = 0 } = etiquetas[etiqueta] ?? {}
if (expired > guardada.timestamp && expired <= Date.now()) return undefined
if (stale > guardada.timestamp) revalidate = -1 // servir y regenerar detrás
}
return { ...guardada, revalidate, value: flujo(guardada.value) }
} catch (error) {
console.error('[cache] error al leer', error)
return undefined // un fallo de la caché nunca debe romper la página
}
},
async set(clave, entradaPendiente) {
try {
const entrada = await entradaPendiente
const bytes = new Uint8Array(await new Response(entrada.value).arrayBuffer())
if (DIR) {
const datos = { ...entrada, value: Buffer.from(bytes).toString('base64') }
await escribirAtomico(ficheroEntrada(clave), JSON.stringify(datos))
} else {
memoria.set(clave, { ...entrada, value: bytes })
}
} catch (error) {
console.error('[cache] error al guardar', error)
}
},
async refreshTags() {
// Una vez por petición: aprender las invalidaciones hechas por OTRAS instancias
await leerEtiquetas()
},
async getExpiration(tags) {
return Math.max(...tags.map((t) => etiquetas[t]?.expired ?? 0), 0)
},
async updateTags(tags, durations) {
await leerEtiquetas() // partir del estado compartido más reciente
const ahora = Date.now()
for (const etiqueta of tags) {
const marcas = etiquetas[etiqueta] ?? {}
if (durations) {
marcas.stale = ahora
if (durations.expire !== undefined) marcas.expired = ahora + durations.expire * 1000
} else {
marcas.expired = ahora
}
etiquetas[etiqueta] = marcas
}
if (DIR) await escribirAtomico(ficheroEtiquetas(), JSON.stringify(etiquetas))
},
}
Y se registra en next.config.ts:
const nextConfig: NextConfig = {
output: 'standalone',
cacheComponents: true,
partialPrefetching: true,
cacheHandlers: {
default: require.resolve('./cache-handlers/disco.js'),
},
}
Qué hace cada método, según el contrato de la documentación:
set(clave, entradaPendiente)recibe una promesa (la entrada puede estar aún generándose). El valor llega como unReadableStreamque solo se puede leer una vez: se lee entero, se guarda como bytes y nunca se guarda el stream, que además retiene en memoria la petición que lo creó.get(clave)devuelve la entrada oundefined(fallo de caché). Debe construir un stream nuevo en cada acierto. No necesita mirar la edad: Next comparatimestampconexpirepor su cuenta. Sí debe consultar las etiquetas: si una etiqueta caducó después de crear la entrada, devuelveundefined; si solo se marcó como obsoleta, devuelve la entrada conrevalidate: -1para que Next la sirva y la regenere detrás.updateTags(tags, durations)se llama al invalidar. Sindurations(updateTag, orevalidateTagcon un solo argumento) la etiqueta caduca ya. Condurations(revalidateTag(tag, 'max')) la etiqueta queda obsoleta y caduca a losexpiresegundos.refreshTags()se llama una vez por petición antes de la primera lectura. Es donde cada réplica aprende lo que han invalidado las demás.getExpiration(tags)devuelve la última invalidación de un grupo de etiquetas; Next la usa para las etiquetas implícitas de ruta (las derevalidatePath).- Los errores se capturan: un
getque lanza una excepción rompe el render, así que ante cualquier problema el manejador responde «no lo tengo». - Las escrituras son atómicas (fichero temporal +
rename), para que otra réplica nunca lea una entrada a medio escribir. - El comentario
eslint-disablees necesario porque la configuración de ESLint de la plantilla prohíberequire(), y este fichero es CommonJS a propósito: Next lo carga tal cual, sin compilarlo.
Con el volumen compartido (CACHE_DIR=/cache), la misma prueba:
10 creada=2026-11-02T13:08:56.084Z Calculada por=web1 Servida por=web1
{"revalidada":"marca","por":"web2"}
5 creada=2026-11-02T13:09:07.410Z Calculada por=web1 Servida por=web1
5 creada=2026-11-02T13:09:07.410Z Calculada por=web1 Servida por=web2
Ahora la invalidación cayó en web2, pero las dos réplicas dejaron de usar el valor viejo. web1 recalculó y web2 sirve el valor que calculó web1 («Calculada por=web1 Servida por=web2»). Dentro del volumen:
/cache:
entradas
etiquetas.json
/cache/entradas:
627798662bc1f8272814fdc386f7f448d8b672292788583b85fae83570d1b6df.json
{"marca":{"stale":1793624945701,"expired":1793624945701}}
⚠️ Importante: Este manejador es didáctico. Un disco compartido en red es lento, no limpia entradas viejas (Next nunca borra de tu almacén: la expulsión es cosa tuya) y dos réplicas que invalidan a la vez pueden pisarse el fichero de etiquetas. En producción usa un almacén pensado para esto, normalmente Redis, con caducidad (TTL) a partir de
expire. El ejemplo oficialcache-handler-redisdel repositorio de Next y los manejadores comunitarios que enlaza su README son el punto de partida.
6.4. Problema 2: la clave de las Server Actions
Next cifra las variables que una Server Action captura de su entorno (los cierres que viste en el módulo 10) antes de enviarlas al navegador. La clave se genera en cada build. Si todas tus réplicas usan la misma imagen, comparten clave y no hay problema. Pero si construyes la imagen varias veces (una por región, o reconstruyes mientras la versión anterior sigue sirviendo), una réplica no podrá descifrar lo que cifró otra, y el usuario verá errores «Failed to find Server Action».
La solución es fijar la clave con una variable durante el build:
# Genera una clave AES de 32 bytes en base64 (una vez) y guárdala como secreto
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=<clave> next build
La clave queda dentro del resultado del build. Trátala como un secreto: si se filtra, se pueden descifrar los valores capturados.
6.5. Problema 3: el desfase de versiones (version skew)
Durante una actualización gradual, unas réplicas tienen la versión nueva y otras la vieja. Un navegador que cargó la página con la versión vieja puede pedir un fichero JavaScript que ya no existe en una réplica nueva, invocar una Server Action con un identificador que la nueva no conoce, o recibir datos de prefetch incompatibles.
Next lo detecta con deploymentId:
const nextConfig: NextConfig = {
deploymentId: process.env.DEPLOYMENT_ID, // por ejemplo, el hash del commit
}
Con un identificador de despliegue, los ficheros estáticos se piden con ?dpl=<id>, las navegaciones del cliente envían la cabecera x-deployment-id y, si el servidor detecta que el cliente es de otra versión, fuerza una navegación completa (recarga la página) en lugar de una navegación del cliente. Se pierde el estado de los componentes (useState), pero no hay errores. Recuerda que next.config.ts se evalúa en el build (sección 3.1): DEPLOYMENT_ID tiene que existir al construir, por ejemplo con un ARG en el Dockerfile.
Una alternativa más sencilla cuando puedes: no borrar nunca los ficheros de /_next/static/ de versiones anteriores. Como llevan hash en el nombre, no chocan, y los navegadores antiguos siguen encontrándolos.
6.6. Lista de lo que deben compartir las réplicas
| Qué | Por qué | Cómo |
|---|---|---|
| La misma imagen | Mismo buildId, mismos ficheros con hash | Construir una vez, desplegar muchas |
Caché de 'use cache' | Mismo dato en todas, invalidación global | cacheHandlers con Redis |
Caché clásica (ISR, fetch) | Igual | cacheHandler con Redis, cacheMaxMemorySize: 0 |
| Clave de Server Actions | Descifrar lo que cifró otra réplica | NEXT_SERVER_ACTIONS_ENCRYPTION_KEY |
| Identificador de despliegue | Detectar clientes de otra versión | deploymentId |
| Sesiones | Que el login valga en todas | Cookies firmadas (JWT, módulo 17) o sesiones en base de datos |
7. Exportación estática: output: 'export'
El catálogo público de EMPRESA cambia poco y lo ve todo el mundo igual. Para eso no hace falta un servidor de Node: se puede generar como HTML, CSS y JavaScript y servir desde cualquier servidor web, un bucket de almacenamiento o GitHub Pages. Es la opción más barata, más rápida y más difícil de atacar, porque no hay código ejecutándose en el servidor.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'export', // next build genera la carpeta out/ con HTML, CSS y JS
// cacheComponents: true, <- incompatible con 'export' en Next 16.4
images: { unoptimized: true }, // sin servidor no hay optimizador de imágenes
}
export default nextConfig
7.1. La primera sorpresa: Cache Components no es compatible 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 la plantilla no activa Cache Components, así que
output: 'export'funciona sin quitar nada. Si usas Next.js 15: añadeoutput: 'export'y sigue con la sección 7.2; el catálogo usa ya el modelo de datos de la 15.
La plantilla de create-next-app 16.4 activa cacheComponents: true. Al añadir output: 'export' sin quitarlo, el build compila, prerenderiza las 25 páginas y falla al final:
✓ Generating static pages using 6 workers (25/25) in 35.3s
Finalizing page optimization ...
> Build error occurred
Error: Invariant: PPR cannot be enabled in export mode
at ignore-listed frames
Cache Components implica Partial Prerendering, y PPR necesita un servidor que complete los huecos. La exportación estática no lo tiene. El mensaje es poco claro (en el código de Next la comprobación lleva un comentario TODO: add message), así que quédate con la regla: con output: 'export', quita cacheComponents y partialPrefetching. Sin Cache Components, 'use cache' tampoco compila, así que el catálogo vuelve al modelo de datos clásico, el de Next.js 15 que viste en el módulo 09: una página que no usa cookies(), headers() ni searchParams se prerenderiza entera en el build.
7.2. El catálogo
// lib/catalogo.ts: lectura de la API EMPRESA durante el build
// Sin cacheComponents (incompatible con output: 'export'), una página que no usa
// cookies(), headers() ni searchParams se prerenderiza entera en el build.
import { cache } from 'react'
export type Producto = {
productoId: number
nombre: string
descripcion: string
precio: number
categoria: string
}
const API = process.env.API_URL ?? 'http://localhost:3000/api'
// cache de React: una sola petición por render aunque la llamen varios componentes
export const obtenerProductos = cache(async (): Promise<Producto[]> => {
const respuesta = await fetch(`${API}/productos?tamano=100&orden=nombre`)
if (!respuesta.ok) throw new Error(`La API respondió ${respuesta.status}`)
const { contenido } = (await respuesta.json()) as { contenido: Producto[] }
return contenido
})
export async function fechaDeGeneracion(): Promise<string> {
return new Date().toLocaleString('es-ES', { timeZone: 'Europe/Madrid' })
}
export async function obtenerProducto(id: number): Promise<Producto | undefined> {
const productos = await obtenerProductos()
return productos.find((p) => p.productoId === id)
}
// app/page.tsx: catálogo completo, generado una sola vez en el build
import Link from 'next/link'
import { fechaDeGeneracion, obtenerProductos } from '@/lib/catalogo'
import Buscador from './buscador'
export default async function Catalogo() {
const [productos, generado] = await Promise.all([obtenerProductos(), fechaDeGeneracion()])
return (
<main className="mx-auto max-w-3xl p-8 space-y-4">
<h1 className="text-2xl font-bold">Catálogo EMPRESA</h1>
<p className="text-gray-600">
{productos.length} productos · generado el {generado}
</p>
<Buscador productos={productos.map((p) => ({ id: p.productoId, nombre: p.nombre }))} />
<ul className="grid grid-cols-2 gap-2">
{productos.map((p) => (
<li key={p.productoId} className="rounded border p-3">
<Link href={`/productos/${p.productoId}`} className="font-semibold text-blue-700">
{p.nombre}
</Link>
<div className="text-sm text-gray-600">{p.precio.toLocaleString('es-ES')} €</div>
</li>
))}
</ul>
</main>
)
}
// app/buscador.tsx
'use client'
// La interactividad del cliente funciona igual en una exportación estática
import { useState } from 'react'
export default function Buscador({ productos }: { productos: { id: number; nombre: string }[] }) {
const [texto, setTexto] = useState('')
const encontrados = texto
? productos.filter((p) => p.nombre.toLowerCase().includes(texto.toLowerCase()))
: []
return (
<div className="space-y-1">
<input
value={texto}
onChange={(e) => setTexto(e.target.value)}
placeholder="Buscar en el navegador…"
className="w-full rounded border px-3 py-2"
/>
{texto && <p className="text-sm">{encontrados.length} coincidencias</p>}
</div>
)
}
// app/productos/[id]/page.tsx: una página HTML por producto, generadas en el build
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { obtenerProducto, obtenerProductos } from '@/lib/catalogo'
export async function generateStaticParams() {
const productos = await obtenerProductos()
return productos.map((p) => ({ id: String(p.productoId) }))
}
export default async function Producto({ params }: PageProps<'/productos/[id]'>) {
const { id } = await params
const producto = await obtenerProducto(Number(id))
if (!producto) notFound()
return (
<main className="mx-auto max-w-2xl p-8 space-y-3">
<Link href="/" className="text-blue-700 underline">← Catálogo</Link>
<h1 className="text-2xl font-bold">{producto.nombre}</h1>
<p className="text-gray-600">{producto.categoria}</p>
<p>{producto.descripcion}</p>
<p className="text-xl font-semibold">{producto.precio.toLocaleString('es-ES')} €</p>
</main>
)
}
generateStaticParams es obligatorio en las rutas dinámicas de una exportación: Next tiene que saber en el build qué páginas generar, porque luego no habrá nadie para generar las que falten. La API tiene que estar en marcha durante el build:
API_URL=http://localhost:3000/api npm run build
Route (app)
┌ ○ /
├ ○ /_not-found
└ /productos/[id]
├ ● /productos/14
├ ● /productos/13
├ ● /productos/19
└ ● [+17 more paths]
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
7.3. Qué hay en out/
out/
├── index.html ← la portada
├── index.txt ← su carga RSC, para navegar con <Link>
├── __next._tree.txt __next._full.txt __next.__PAGE__.txt ← prefetch por segmentos
├── 404.html _not-found.html _not-found.txt
├── productos/
│ ├── 1.html 1.txt 1/
│ ├── 10.html 10.txt 10/
│ └── ... ← 60 entradas para 20 productos
├── _next/static/... ← JS y CSS con hash
└── favicon.ico
En total, 131 ficheros y 1,5 MB. Cada página tiene su .html (para la primera visita) y su .txt con la carga RSC (para cuando llegas desde otra página con <Link>: el navegador no descarga HTML, solo esos datos). Por eso la navegación del cliente y el prefetch siguen funcionando sin servidor.
7.4. Servirlo con Nginx en un contenedor
# nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
# /productos/3 -> /productos/3.html ; / -> /index.html
location / {
try_files $uri $uri.html $uri/ =404;
}
# Lo que lleva hash en el nombre no cambia nunca: caché de un año
location /_next/static/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}
# Dockerfile: solo Nginx y la carpeta out/
FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY out/ /usr/share/nginx/html/
EXPOSE 80
La línea clave es try_files $uri $uri.html $uri/ =404. Las URL de Next no llevan extensión (/productos/3), pero el fichero se llama productos/3.html: Nginx prueba primero el nombre exacto, luego con .html, luego como carpeta, y si nada existe responde 404 con la página 404.html que generó Next. Si prefieres carpetas con index.html (/productos/3/index.html), activa trailingSlash: true en next.config.ts.
docker build -t empresa-catalogo .
docker run -d -p 127.0.0.1:8080:80 empresa-catalogo
La imagen ocupa 63,3 MB (casi todo es Nginx). Las comprobaciones con curl:
/ 200 17297 B text/html
/productos/3 200 7809 B text/html
/productos/999 404 8248 B text/html
/productos/3.txt 200 5022 B text/plain
Y un fichero de /_next/static/chunks/:
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable

Figura 6. El catálogo estático servido por Nginx. La fecha de generación es la del build: hasta que no vuelvas a construir, no cambia.
Fíjate en el precio «1499 €» del portátil de gaming: toLocaleString('es-ES') no separa los miles con números de cuatro cifras, como ya viste en módulos anteriores.
7.5. Lo que se pierde
La exportación estática no tiene servidor, así que todo lo que necesita la petición o ejecutar código en el servidor después del build desaparece. Con el mismo catálogo, se añadió primero un proxy.ts y después una página con una Server Action, cada uno por separado. Los dos casos se comportan de forma muy distinta.
Con una Server Action, el build se detiene con un mensaje claro:
// app/contacto/page.tsx: un formulario con Server Action en una exportación estática
async function enviar(formData: FormData) {
'use server'
console.log('Mensaje de', formData.get('email'))
}
export default function Contacto() {
return (
<form action={enviar}>
<input name="email" type="email" />
<button>Enviar</button>
</form>
)
}
Collecting page data using 7 workers ...
> Server Actions are not supported with static export.
Read more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports#unsupported-features
Con un proxy.ts que redirige a quien no tiene sesión, en cambio, el build termina bien y hasta lo anuncia en la tabla de rutas:
Route (app)
┌ ○ /
├ ○ /_not-found
└ /productos/[id]
├ ● /productos/14
├ ● /productos/13
├ ● /productos/19
└ ● [+17 more paths]
ƒ Proxy (Middleware)
Pero en out/ no hay nada que pueda ejecutarlo: Nginx sirve ficheros y no sabe qué es un proxy.ts. Tu comprobación de sesión desaparece en silencio. Es el caso más peligroso de esta lista, porque nada te avisa en producción.
⚠️ Importante: Nunca confíes en
proxy.tspara proteger nada en una exportación estática. Si una zona necesita control de acceso, necesita un servidor (o las reglas de autenticación de tu servidor web o CDN).
La documentación da la lista completa de funciones no disponibles con output: 'export':
| Función | Qué hacer en su lugar |
|---|---|
Rutas dinámicas sin generateStaticParams o con dynamicParams: true | Generar todas en el build; lo que no esté, es 404 |
| Route Handlers que leen la petición | Solo GET sin leer request: se convierten en ficheros (un feed.xml, un datos.json) |
cookies(), headers() | Leerlas en el navegador, en un componente cliente |
redirects, rewrites y headers de next.config | Configurarlos en el servidor web (Nginx, _redirects de tu proveedor) |
proxy | Reglas en el servidor web o en la CDN |
ISR, revalidateTag, revalidatePath | Volver a construir y publicar |
next/image con el optimizador por defecto | images.unoptimized: true o un loader propio (un servicio de imágenes externo) |
| Draft Mode | Nada: necesita servidor |
| Server Actions | Una API externa llamada desde componentes cliente |
| Rutas interceptadas | Rutas normales o modales en el cliente |
Cache Components (cacheComponents), PPR | El modelo clásico: todo se resuelve en el build |
Lo que sí funciona: Server Components (se ejecutan en el build), componentes cliente con toda su interactividad, <Link> con prefetch, generateStaticParams, generateMetadata, sitemap.ts y robots.ts, los estilos y next/font.
💡 Consejo: Una buena arquitectura para EMPRESA mezcla las dos cosas: el catálogo público como exportación estática en una CDN (barato y casi imposible de tirar) y la zona privada de gestión en un contenedor con
standalone. Los dos proyectos pueden compartir componentes en un monorepo.
7.6. GitHub Pages y subcarpetas
Este mismo curso se publica en GitHub Pages. Si publicas una exportación de Next en https://usuario.github.io/catalogo/, la aplicación vive en una subcarpeta, y las rutas /_next/... apuntarían a la raíz del dominio. Se arregla con basePath: '/catalogo' en next.config.ts, que antepone ese prefijo a todas las rutas y enlaces. La documentación enlaza una plantilla oficial para GitHub Pages con el flujo de GitHub Actions ya preparado.
8. Vercel
Vercel es la empresa que crea y mantiene Next.js, y tiene su propia plataforma de despliegue. Es el camino con menos configuración, porque la plataforma entiende el resultado de next build y lo reparte por su infraestructura sin que escribas un Dockerfile.
8.1. Cómo se despliega
El flujo habitual es conectar el repositorio de GitHub, GitLab o Bitbucket a un proyecto de Vercel. A partir de ahí:
- Cada
pusha la rama principal crea un despliegue de producción. - Cada
pusha otra rama o cada pull request crea un despliegue de vista previa (preview) con su propia URL, para revisar el cambio antes de fusionarlo. - Las variables de entorno se definen en el panel del proyecto, separadas por entorno (producción, vista previa, desarrollo).
También existe la herramienta de línea de órdenes vercel, que despliega desde tu máquina. En este módulo no se ha desplegado nada en Vercel: tienes que crear tu cuenta y revisar sus condiciones y límites de uso, que cambian con el tiempo y dependen del plan.
8.2. Qué cambia respecto a tu contenedor
En lugar de un proceso de Node con todo dentro, la plataforma reparte cada pieza donde mejor encaja:
- Lo prerenderizado (páginas
○, armazones◐,/_next/static) se sirve desde la CDN de la plataforma, cerca del usuario. - Lo dinámico (huecos de
<Suspense>, Server Actions, Route Handlers) se ejecuta en funciones que escalan solas según el tráfico. - La caché de datos y las invalidaciones con
revalidateTagse propagan a toda la plataforma, sin que escribas un manejador como el de la sección 6. - La protección frente al desfase de versiones, la optimización de imágenes y las cabeceras de caché vienen configuradas.
Lo que en la sección 6 has tenido que montar a mano (caché compartida, clave de Server Actions, deploymentId) es lo que una plataforma te da hecho. Ese es su valor, y también su coste: pagas por uso, y la aplicación queda más atada a un proveedor.
📘 Concepto: Desde Next.js 16.2, el adaptador de Vercel usa la misma API pública que cualquier otro adaptador (sección 9) y es de código abierto (
nextjs/adapter-vercelen GitHub). La documentación insiste en que no hay «ganchos privados»: cualquier plataforma puede integrarse igual.
9. La Adapter API 🆕 16.2
🆕 Desde Next.js 16.2. No existe en Next.js 15 como API estable. Si usas Next.js 15: la 15.5 tiene una versión experimental en
experimental.adapterPath, con una interfaz distinta y sin garantías; despliega connext start, con Dockerstandaloneo con la integración propia de cada proveedor.
Durante años, cada plataforma que quería alojar Next (Netlify, Cloudflare, AWS Amplify) tenía que leer la carpeta .next, que es un formato interno sin documentar, y adaptarse a cada cambio de versión. Next.js 16.2 estabilizó la Adapter API: un contrato público para que una plataforma (o tú) se enganche al proceso de build y reciba una descripción ordenada de todo lo que ha generado.
Figura 7. El adaptador participa dos veces en el build: antes, para ajustar la configuración, y después, para transformar el resultado.
9.1. La interfaz
Un adaptador es un módulo que exporta un objeto con la forma NextAdapter, que se importa como tipo desde next:
import type { NextAdapter } from 'next'
Tiene un nombre y dos funciones opcionales:
modifyConfig(config, { phase, nextVersion, projectDir }): recibe la configuración ya resuelta y devuelve la que se usará. Se llama en cualquier orden de Next que cargue la configuración (tambiénnext dev), así que usaphasepara actuar solo en el build ('phase-production-build').onBuildComplete({ outputs, routing, buildId, distDir, projectDir, config, nextVersion, ... }): se llama al final del build con:outputs.appPages,outputs.appRoutes,outputs.pages,outputs.pagesApi: cada página, Route Handler o ruta del Pages Router, con su fichero compilado y su ruta.outputs.prerenders: todo lo prerenderizado, incluidos los segmentos de prefetch.outputs.staticFiles: los ficheros estáticos.outputs.middleware: elproxy, si existe.routing: redirecciones, reescrituras, rutas dinámicas y cabeceras, en el orden en que deben aplicarse.
Con eso, el adaptador genera lo que necesite su plataforma: funciones serverless, reglas de enrutado, ficheros para subir a la CDN.
9.2. Un adaptador propio que no despliega nada
Para ver qué recibe un adaptador sin depender de ningún proveedor, este escribe un informe del build:
// adaptador/informe.js
// Adaptador mínimo: no despliega nada; enseña qué entrega Next a un adaptador.
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('node:fs/promises')
const path = require('node:path')
/** @type {import('next').NextAdapter} */
const adaptador = {
name: 'informe-empresa',
modifyConfig(config, { phase, nextVersion }) {
console.log(`[adaptador] modifyConfig fase=${phase} next=${nextVersion}`)
return config
},
async onBuildComplete({ outputs, routing, buildId, distDir, projectDir }) {
const resumen = {
buildId,
paginasApp: outputs.appPages.map((p) => p.pathname),
routeHandlers: outputs.appRoutes.map((r) => r.pathname),
prerenderizadas: outputs.prerenders.map((p) => p.pathname),
ficherosEstaticos: outputs.staticFiles.length,
rutasDinamicas: routing.dynamicRoutes.map((r) => r.source ?? r.sourceRegex),
}
console.log('[adaptador] páginas:', resumen.paginasApp.join(', '))
console.log('[adaptador] handlers:', resumen.routeHandlers.join(', '))
console.log('[adaptador] prerenderizadas:', resumen.prerenderizadas.length,
'· estáticos:', resumen.ficherosEstaticos)
const destino = path.join(projectDir, 'despliegue', 'informe.json')
await fs.mkdir(path.dirname(destino), { recursive: true })
await fs.writeFile(destino, JSON.stringify(resumen, null, 2))
console.log('[adaptador] escrito', path.relative(projectDir, destino), 'distDir=', path.relative(projectDir, distDir))
},
}
module.exports = adaptador
Se activa con adapterPath en next.config.ts (en el laboratorio, solo si se pide, para no mezclarlo con Docker):
const nextConfig: NextConfig = {
output: 'standalone',
...(process.env.CON_ADAPTADOR === '1'
? { adapterPath: require.resolve('./adaptador/informe.js') }
: {}),
}
O sin tocar la configuración, con la variable NEXT_ADAPTER_PATH, que es como lo hacen las plataformas («configuración cero»: la plataforma la define en su entorno de build y tu proyecto no sabe nada del adaptador).
La salida real del build de app-docker:
Applying modifyConfig from informe-empresa
[adaptador] modifyConfig fase=phase-production-build next=16.4.3
✓ Running next.config.ts took 1157ms
...
Finalizing page optimization ...
Running onBuildComplete from informe-empresa
[adaptador] páginas: /_not-found.rsc, /_not-found, /_global-error.rsc, /_global-error, /marca.rsc, /marca, /index.rsc, /, /productos.rsc, /productos
[adaptador] handlers: /api/revalidar, /api/revalidar.rsc, /api/salud, /api/salud.rsc
[adaptador] prerenderizadas: 20 · estáticos: 27
[adaptador] escrito despliegue\informe.json distDir= .next
Y un fragmento de despliegue/informe.json:
{
"buildId": "c5PF6HXINgEsBgrJlUxFu",
"prerenderizadas": [
"/",
"/index.rsc",
"\\index.segments\\_tree.segment.rsc",
"\\index.segments\\_full.segment.rsc",
"\\index.segments\\__PAGE__.segment.rsc",
"/marca",
"/marca.rsc",
"\\marca.segments\\_tree.segment.rsc"
],
"ficherosEstaticos": 27,
"rutasDinamicas": []
}
Fíjate en tres cosas:
- Cada página aparece dos veces: como HTML (
/marca) y como carga RSC (/marca.rsc). Una plataforma tiene que saber servir las dos según lo que pida el navegador. - Las páginas
◐aparecen como prerenderizadas y como páginas: el armazón se sirve desde la CDN y la función completa los huecos. Cómo se hace bien eso tiene su propia guía (Implementing PPR in an Adapter). - Los segmentos de prefetch (
_tree,__PAGE__) son los que viste en el módulo 15. Al construir en Windows, sus rutas salen con barras invertidas: si escribes un adaptador, normaliza las rutas.
Y en Next.js 15.5
La 15.5 ya tenía un esbozo experimental de esta API. Se activa dentro de experimental y con otra interfaz:
// next.config.ts (Next.js 15.5)
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
experimental: {
// En Next.js 15.5 la Adapter API es experimental y su interfaz es otra
adapterPath: require.resolve('./adaptador15.js'),
},
}
export default nextConfig
// adaptador15.js: el mismo informe con la interfaz experimental de Next.js 15.5
/** @type {import('next').NextAdapter} */
module.exports = {
name: 'informe-15',
modifyConfig(config) {
console.log('[adaptador15] modifyConfig')
return config
},
onBuildComplete({ routes, outputs }) {
console.log('[adaptador15] claves de outputs:', Object.keys(outputs).join(', '))
console.log('[adaptador15] rutas dinámicas:', routes.dynamicRoutes.length)
},
}
Applying modifyConfig from informe-15
[adaptador15] modifyConfig
▲ Next.js 15.5.28 (Turbopack)
- Experiments (use with caution):
· adapterPath: "C:\\proyectos\\empresa\\adaptador15.js"
...
[adaptador15] claves de outputs: 0, 1, 2, 3, 4, 5, ..., 45, 46
[adaptador15] rutas dinámicas: 0
En la 15.5, onBuildComplete recibe routes (no routing) y outputs es una lista de 47 elementos, no un objeto con appPages, prerenders, etc. Un adaptador escrito para la 16.2 no funciona en la 15.5, y Next lo marca como experimento «úsalo con cuidado». Por eso los adaptadores de las plataformas exigen Next 16.2 o superior.
9.3. Adaptadores verificados
La documentación de la 16.4 distingue dos niveles:
- Adaptadores verificados: de código abierto, alojados en la organización
nextjsde GitHub, y que ejecutan la batería de pruebas de compatibilidad de Next. El equipo de Next coordina con ellos las pruebas antes de cada versión mayor. Hoy son Vercel y Bun. Cloudflare y Netlify están trabajando en adaptadores verificados sobre esta API. - Otras plataformas con integración propia, no basada en la Adapter API y no verificada: AWS Amplify, Cloudflare, Deno Deploy, Firebase App Hosting, Netlify, Appwrite.
La documentación separa también dos ideas útiles al elegir proveedor: fidelidad funcional (todas las funciones funcionan; se mide con la batería de pruebas y es sí o no) y fidelidad de rendimiento (las funciones rinden como deberían; por ejemplo, el armazón de PPR servido desde la CDN y no desde el servidor de origen). Una plataforma puede ser totalmente compatible sin dar el mismo rendimiento que otra.
10. OpenNext
OpenNext es un proyecto de código abierto nacido en 2023 para que Next.js se pueda desplegar «en cualquier plataforma, no solo en Vercel». Empezó como un adaptador para AWS Lambda de la comunidad de SST y hoy es un esfuerzo con tres adaptadores:
| Adaptador | Mantenido por | Destino |
|---|---|---|
| OpenNext para AWS | La comunidad de SST | AWS Lambda y servicios de AWS |
OpenNext para Cloudflare (@opennextjs/cloudflare) | El equipo de Cloudflare | Cloudflare Workers |
| OpenNext para Netlify | El equipo de Netlify | Netlify |
La idea es la misma que la de la sección 9: tomar el resultado de next build y convertirlo en paquetes que la plataforma sabe ejecutar. Según su web, OpenNext ayudó a crear el grupo de trabajo de adaptadores con Vercel, Cloudflare, Netlify, Google y AWS Amplify que diseñó la Adapter API estable de la 16.2, para que las plataformas dejen de tener que interpretar la carpeta .next por su cuenta.
10.1. Cómo es un proyecto con OpenNext para Cloudflare
Para que reconozcas un proyecto que lo usa, esto es lo que indica su guía de inicio (no se ha desplegado en este módulo):
- Se instala el paquete
@opennextjs/cloudflarey, como dependencia de desarrollo,wrangler(la herramienta de Cloudflare). - Aparecen dos ficheros de configuración:
wrangler.jsonc(el Worker, con la opción de compatibilidadnodejs_compat) yopen-next.config.ts. - Los scripts de
package.jsoncambian:previewejecutaopennextjs-cloudflare build && opennextjs-cloudflare previewpara probar en local con el entorno de Cloudflare, ydeployhace lo mismo condeploy. - Hay que quitar cualquier
export const runtime = 'edge', que ese adaptador no admite. Con Cache Components ya no puede existir (módulo 09). - La carpeta generada
.open-nextse añade a.gitignore.
⚠️ Importante: Antes de elegir OpenNext (o cualquier plataforma que no sea un adaptador verificado), comprueba en su documentación qué versiones de Next admite y qué funciones tiene en cada una. La web de OpenNext para AWS habla, a fecha de este módulo, de soportar todas las funciones de Next.js 15. Cache Components, Instant Navigations o Partial Prefetching de la 16 pueden no estar disponibles o comportarse distinto.
11. Caché HTTP y CDN
Pongas lo que pongas delante de Next (Nginx, una CDN como Cloudflare o CloudFront, o la de tu plataforma), esa capa decide qué guarda según las cabeceras Cache-Control que envía Next.
11.1. Las cabeceras que pone Next
| Tipo de respuesta | Cache-Control |
|---|---|
Ficheros de /_next/static/ (con hash) | public, max-age=31536000, immutable, y no se puede cambiar |
| Página estática sin revalidación | s-maxage=31536000 |
Página ISR o con cacheLife | s-maxage=<revalidate>, stale-while-revalidate=<expire - revalidate> |
| Página dinámica o con huecos dinámicos | private, no-cache, no-store, max-age=0, must-revalidate |
La portada del contenedor, que es ◐ (armazón estático con un hueco dinámico), respondió así:
Vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-stale-time: 300
x-nextjs-prerender: 1
x-nextjs-postponed: 1
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Content-Type: text/html; charset=utf-8
x-nextjs-prerender: 1yx-nextjs-postponed: 1: la respuesta empezó con el armazón prerenderizado y tenía huecos aplazados que se completaron en la petición.Cache-Control: private, no-store: como el HTML final incluye datos de esa petición, ninguna CDN debe guardarlo. Next protege así los datos de cada usuario.Varyavisa a las cachés de que la respuesta cambia según varias cabeceras propias de Next.
s-maxage solo lo respetan las cachés compartidas (CDN, proxies); max-age lo respeta también el navegador. stale-while-revalidate permite servir una copia caducada mientras se pide una nueva detrás.
11.2. Lo que la CDN debe respetar
La guía de CDN de la documentación da reglas concretas para que la navegación del App Router no se rompa:
- La cabecera
rscdebe llegar al servidor. Le dice a Next que devuelva la carga RSC en lugar de HTML. Si la CDN la elimina, el navegador recibe HTML cuando esperaba datos, y las navegaciones con<Link>se convierten en recargas completas. - El parámetro
_rscdebe formar parte de la clave de caché. Muchas CDN no respetanVarycon cabeceras propias, así que Next añade?_rsc=<hash>a las peticiones RSC para que cada variante tenga una URL distinta. Si la CDN ignora los parámetros al guardar, servirá a un usuario la respuesta pensada para otro. - El
proxydebe ir antes de la caché. Si la CDN sirve una página guardada sin pasar por elproxy, se salta tu comprobación de sesión. Si no puede ser, excluye de la caché las rutas que dependen delproxy. - Invalidar en Next no invalida la CDN.
revalidateTagyrevalidatePathlimpian la caché del servidor de Next, pero la CDN seguirá sirviendo su copia hasta que caduque sus-maxage. Para propagar al momento, llama también a la API de purga de tu CDN (para las variantes HTML y RSC).
📘 Concepto: La documentación explica que el equipo de Next trabaja en que todo lo que afecta a la caché vaya en la ruta de la URL (
/pagina.rsc,/pagina.segments/...segment.rsc), sin cabeceras propias ni_rsc. Con Cache Components, el prefetch por segmentos ya usa esas rutas, que cualquier CDN puede guardar con la clave por ruta de siempre. Es lo mismo que viste enout/(sección 7.3) y en el informe del adaptador (sección 9.2).
11.3. Ficheros estáticos en otro dominio
Con assetPrefix puedes servir /_next/static desde otro dominio o bucket:
const nextConfig: NextConfig = {
assetPrefix: process.env.CDN_URL, // p. ej. https://estaticos.empresa.es
}
Tras el build, subes .next/static a ese destino. Ganas descargar trabajo del servidor; pagas una resolución DNS y una conexión TLS más en la primera visita.
12. Lista de comprobación antes de producción
| Comprobación | Dónde se vio |
|---|---|
next build sin errores y next start (o el contenedor) respondiendo a una petición real | Secciones 2 y 4 |
eslint y pruebas pasando antes de construir (next build ya no ejecuta el linter) | Módulo 01, módulo 21 |
| Node 20.9 o superior en la máquina o imagen | Sección 4.1 |
npm ci con package-lock.json | Sección 2 |
| Ninguna variable de entorno de cada entorno leída en el armazón estático | Sección 5.2 |
NEXT_PUBLIC_* definidas en el build | Sección 5.1 |
| Secretos fuera de la imagen | Sección 5.4 |
Contenedor con usuario sin privilegios y HEALTHCHECK | Sección 4.1 |
| Proxy inverso sin buffering, TLS y límites | Sección 2.3 |
Varias réplicas: caché compartida, clave de Server Actions, deploymentId | Sección 6 |
CDN que respeta rsc, _rsc y Cache-Control | Sección 11 |
| Último parche de seguridad de Next 16.4 instalado y calendario de parches vigilado | Módulo 22 |
| Cabeceras de seguridad y CSP | Módulo 22 |
Parada ordenada con SIGTERM y tiempo para after() | Sección 2.2 |
13. Errores frecuentes
| Síntoma o mensaje | Causa | Solución |
|---|---|---|
Error: Invariant: PPR cannot be enabled in export mode | output: 'export' con cacheComponents: true (lo trae la plantilla) | Quita cacheComponents y partialPrefetching en el proyecto exportado |
Server Actions are not supported with static export. | Una Server Action en un proyecto con output: 'export' | Llama a una API externa desde un componente cliente, o despliega con servidor |
El build estático pasa con ƒ Proxy (Middleware) pero en producción nadie comprueba la sesión | proxy.ts no se ejecuta en una exportación estática | Control de acceso en el servidor web o con un servidor de Next |
La página carga sin estilos y los .js dan 404 tras node server.js | No se copiaron .next/static ni public a standalone | cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/ |
ESLint: A `require()` style import is forbidden en cache-handlers/*.js | La plantilla prohíbe require(), y el manejador es CommonJS | /* eslint-disable @typescript-eslint/no-require-imports */ en ese fichero |
⚠ "next start" does not work with "output: standalone" configuration | Arrancar con next start un proyecto standalone | node .next/standalone/server.js |
server.js no está en .next/standalone/ | Raíz de rastreo por encima del proyecto (monorepo, node_modules compartido) | Busca la ruta anidada, fija outputFileTracingRoot o construye en Docker |
⚠ Warning: Next.js ignored package-lock.json in C:\Users\... | Un package-lock.json suelto en tu carpeta personal | Bórralo o fija turbopack.root |
La página /productos del contenedor no llega a la API con localhost | Dentro del contenedor, localhost es el propio contenedor | host.docker.internal (y host-gateway en Linux) o el nombre del servicio en Compose |
He cambiado una variable con -e y no cambia nada | Es NEXT_PUBLIC_* o se lee en el armazón estático | Léela tras connection(); para el cliente, pásala como prop |
| El valor cambia en pantalla al cargar o hay errores de hidratación | process.env leído en el armazón: HTML del build, RSC de ejecución | Igual que el anterior |
| El contenedor no responde desde fuera aunque esté arrancado | server.js escucha en una interfaz interna | HOSTNAME=0.0.0.0 (ya en el Dockerfile) |
Una réplica sirve datos viejos tras revalidateTag | Caché en memoria por réplica | cacheHandlers / cacheHandler compartido |
| «Failed to find Server Action» al desplegar | Réplicas de builds distintos o clientes de una versión anterior | NEXT_SERVER_ACTIONS_ENCRYPTION_KEY y deploymentId |
Las páginas con <Suspense> llegan de golpe detrás de Nginx | Buffering del proxy | proxy_buffering off o X-Accel-Buffering: no |
| El build de Docker falla al prerenderizar | La página lee una API o base de datos que no existe en el build | Sección 5.5 |
/productos/25 da 404 en la exportación aunque el producto exista | Se creó después del último build | Reconstruir y publicar; o usar un servidor |
14. Ejercicios
Ejercicio 1: El catálogo en tu máquina
Construye la exportación estática del catálogo con la API EMPRESA en marcha, sírvela con Nginx en un contenedor y comprueba con curl que /, /productos/3 y /productos/3.txt responden 200 y /productos/999 responde 404. Después, para la API y vuelve a construir: ¿qué pasa?
Ver solución
# 1. La API en el puerto 3000 (en otra terminal)
node server.mjs
# 2. Build del catálogo (en la carpeta del proyecto)
API_URL=http://localhost:3000/api npm run build
# 3. Imagen y contenedor
docker build -t empresa-catalogo .
docker run -d --name catalogo -p 127.0.0.1:8080:80 empresa-catalogo
# 4. Comprobaciones
for u in / /productos/3 /productos/3.txt /productos/999; do
curl -s -o /dev/null -w "$u %{http_code}\n" http://127.0.0.1:8080$u
done
Con la API parada, fetch falla dentro de obtenerProductos al recoger los datos de generateStaticParams, y next build termina con error:
code: 'ECONNREFUSED',
syscall: 'connect',
address: '127.0.0.1',
port: 3000
}
}
> Build error occurred
Error: Failed to collect page data for /productos/[id]
Es lo esperado: una exportación estática es una foto de los datos en el momento del build, y sin datos no hay foto. Por eso en integración continua el paso de construcción del catálogo necesita acceso a la API (o a una copia).
Limpia al terminar con docker rm -f catalogo y docker rmi empresa-catalogo.
Ejercicio 2: Medir tu propia imagen
Construye la imagen de tu proyecto con el Dockerfile de la sección 4.1 y con el «ingenuo» de la sección 4.3. Compara con docker image ls, docker history y docker stats --no-stream (memoria en reposo). Explica de dónde sale cada diferencia.
Ver solución
docker build -t miapp:standalone .
docker build -f Dockerfile.completo -t miapp:completo .
docker image inspect miapp:standalone miapp:completo --format '{{.RepoTags}} {{.Size}}'
docker history miapp:completo --format '{{.Size}}\t{{.CreatedBy}}' | head -6
En el laboratorio salió 230 MB frente a 927 MB. La diferencia está en dos capas de la imagen ingenua: los 652 MB de npm ci (todas las dependencias, incluidas las de desarrollo y los binarios de compilación) y los 103 MB de .next completo (con cachés de compilación). La imagen standalone solo añade 57 MB de standalone y menos de 1 MB de estáticos.
La memoria en reposo (92,7 MiB frente a 52,9 MiB) también baja: next start carga más maquinaria al arrancar que el server.js mínimo. Y el arranque pasa de casi 2 segundos a ser inmediato.
Ejercicio 3: Una variable por entorno, una sola imagen
La página de contacto debe mostrar un teléfono distinto en preproducción y en producción (TELEFONO_CONTACTO). Escribe la página para que funcione con una sola imagen, y demuestra que funciona arrancando dos contenedores con valores distintos.
Ver solución
// app/contacto/page.tsx
import { Suspense } from 'react'
import { connection } from 'next/server'
async function Telefono() {
await connection() // se lee en cada petición, no en el build
return <p>Llámanos al <b>{process.env.TELEFONO_CONTACTO ?? '900 000 000'}</b></p>
}
export default function Contacto() {
return (
<main className="p-8 space-y-4">
<h1 className="text-2xl font-bold">Contacto</h1>
<Suspense fallback={<p>Cargando teléfono…</p>}>
<Telefono />
</Suspense>
</main>
)
}
docker run -d --name pre -p 127.0.0.1:3001:3000 -e TELEFONO_CONTACTO="911 111 111" miapp:standalone
docker run -d --name prod -p 127.0.0.1:3002:3000 -e TELEFONO_CONTACTO="922 222 222" miapp:standalone
curl -s http://127.0.0.1:3001/contacto | grep -o "Llámanos al <b>[^<]*"
curl -s http://127.0.0.1:3002/contacto | grep -o "Llámanos al <b>[^<]*"
Llámanos al <b>911 111 111
Llámanos al <b>922 222 222
La misma compilación (en el build la página sale como ◐ /contacto) da un teléfono distinto en cada contenedor.
El título <h1> sigue siendo parte del armazón estático y se sirve al instante; solo el teléfono se calcula en cada petición. Si hubieras escrito process.env.TELEFONO_CONTACTO directamente en Contacto, el HTML llevaría el valor del build (sección 5.2).
Ejercicio 4: Invalida en todas las réplicas
Monta el compose.yaml de la sección 6.1 con el manejador disco.js. Comprueba que, sin CACHE_DIR, revalidateTag solo afecta a una réplica, y con CACHE_DIR=/cache a todas. Después cambia revalidateTag('marca', { expire: 0 }) por revalidateTag('marca', 'max') y explica la diferencia que observas.
Ver solución
# Sin caché compartida
CACHE_DIR= docker compose up -d --wait
for i in $(seq 1 10); do curl -s http://127.0.0.1:3001/marca \
| grep -oE "(creada|Calculada por|Servida por)</dt><dd><b>[^<]*" | tr '\n' ' '; echo; done | sort | uniq -c
curl -X POST -H "x-secreto: cambia-esto" http://127.0.0.1:3001/api/revalidar
# ...repetir el bucle
# Con caché compartida
docker compose up -d --wait --force-recreate
Los resultados son los de la sección 6.2 y 6.3.
Para comparar los dos perfiles se añadió al handler un parámetro de prueba (?perfil=max llama a revalidateTag('marca', 'max'), y sin él se usa { expire: 0 }) y se pidió /marca antes y después de cada invalidación, con un segundo de pausa entre peticiones:
antes: 2026-11-02T13:29:03.681Z
antes: 2026-11-02T13:29:03.681Z
{"revalidada":"marca","perfil":"max",...}
1.ª tras 'max': 2026-11-02T13:29:03.681Z ← todavía el viejo
2.ª tras 'max': 2026-11-02T13:29:08.446Z ← ya el nuevo
{"revalidada":"marca","perfil":"expire0",...}
1.ª tras expire0: 2026-11-02T13:29:14.130Z ← nuevo al instante
2.ª tras expire0: 2026-11-02T13:29:14.130Z
Con 'max', updateTags recibe durations y el manejador marca la etiqueta como obsoleta (stale) en lugar de caducada. get devuelve la entrada con revalidate: -1: la primera petición tras invalidar recibe todavía el valor viejo, y Next lo regenera detrás. La siguiente ya ve el nuevo. Es stale-while-revalidate: nadie espera, a cambio de servir una vez el dato antiguo. Con { expire: 0 } la primera petición espera al recálculo y ya ve el valor nuevo. Elige según lo que importe más: rapidez o frescura (módulo 09, sección 6).
Ejercicio 5: Un adaptador que vigila
Amplía el adaptador de la sección 9.2 para que escriba, además, cuántas páginas son totalmente estáticas y cuántas tienen huecos dinámicos, y que falle el build si alguna ruta de /api se ha prerenderizado como estática (un error típico con comprobaciones de salud).
Ver solución
// adaptador/vigilante.js
/** @type {import('next').NextAdapter} */
module.exports = {
name: 'vigilante-empresa',
async onBuildComplete({ outputs }) {
const handlers = new Set(outputs.appRoutes.map((r) => r.pathname))
const prerender = new Set(outputs.prerenders.map((p) => p.pathname.replaceAll('\\', '/')))
const apiEstaticas = [...prerender].filter((p) => p.startsWith('/api/'))
const paginas = outputs.appPages.map((p) => p.pathname).filter((p) => !p.endsWith('.rsc'))
const conArmazon = paginas.filter((p) => prerender.has(p))
console.log(`[vigilante] ${paginas.length} páginas, ${conArmazon.length} con HTML prerenderizado`)
console.log(`[vigilante] ${handlers.size} entradas de Route Handlers`)
if (apiEstaticas.length > 0) {
throw new Error(`Rutas /api prerenderizadas como estáticas: ${apiEstaticas.join(', ')}`)
}
},
}
Se activa sin tocar next.config.ts, con la variable de entorno:
NEXT_ADAPTER_PATH=$PWD/adaptador/vigilante.js npm run build
Con el proyecto tal como está, el build pasa:
Running onBuildComplete from vigilante-empresa
[vigilante] 6 páginas, 5 con HTML prerenderizado
[vigilante] 4 entradas de Route Handlers
Para provocar el fallo se añadió un GET que no depende de nada:
// app/api/estado/route.ts: un GET que no lee nada de la petición ni nada variable
export async function GET() {
return Response.json({ ok: true, version: '1.0.0' })
}
Running onBuildComplete from vigilante-empresa
[vigilante] 6 páginas, 5 con HTML prerenderizado
[vigilante] 6 entradas de Route Handlers
⨯ Failed to run onBuildComplete from vigilante-empresa
> Build error occurred
Error: Rutas /api prerenderizadas como estáticas: /api/estado
at Object.onBuildComplete (...\app-docker\adaptador\vigilante.js:14:13)
Lanzar un error en onBuildComplete hace fallar next build, que es lo que quieres en integración continua: es preferible un build rojo a desplegar una comprobación de salud que siempre dice «bien».
Un detalle que salió al probar: quitando el await connection() de /api/salud, el build no falló, porque ese handler llama a new Date() y a hostname(), y Next ya lo trató como dinámico (ƒ /api/salud). Solo el JSON constante de /api/estado se prerenderizó. Por eso conviene escribir connection() de forma explícita en lugar de depender de qué considera Next «variable». Fíjate también en el replaceAll('\\', '/'): en Windows las rutas de los segmentos llegan con barras invertidas.
Ejercicio 6 (propuesto): Compose con la base de datos en un volumen
Escribe un compose.yaml para la aplicación EMPRESA con SQLite del módulo 16: una sola réplica, el fichero de base de datos en un volumen con nombre y su ruta en una variable de entorno (DB_FILE=/datos/empresa.db) que lea la capa de acceso a datos. Pistas: la carpeta /datos debe existir en la imagen con dueño nextjs, como /cache en la sección 4.1; con ${SESSION_SECRET:?Falta SESSION_SECRET} Compose se niega a arrancar si no defines el secreto. Explica por qué no debes subir a dos réplicas con este montaje.
Ejercicio 7 (propuesto): Proxy inverso con TLS
Añade al compose.yaml de la sección 6.1 un certificado autofirmado (generado con openssl) y configura Nginx para escuchar en HTTPS, redirigir HTTP a HTTPS y pasar X-Forwarded-Proto. Comprueba que las cookies de sesión con secure: true del módulo 17 funcionan.
Ejercicio 8 (propuesto): Actualización sin cortes
Con dos réplicas detrás de Nginx, construye una versión nueva de la imagen con otro DEPLOYMENT_ID (pásalo como ARG al Dockerfile) y sustituye las réplicas de una en una. Con la página abierta en el navegador de la versión vieja, navega con <Link> y observa en la pestaña Red cuándo Next hace una recarga completa.
Ejercicio 9 (propuesto): Redis como caché compartida
Sustituye disco.js por un manejador que use Redis (un servicio redis:alpine en Compose), con TTL a partir de expire y la coordinación de etiquetas con refreshTags. Parte del ejemplo cache-handler-redis del repositorio de Next. Repite la prueba del ejercicio 4.
Ejercicio 10 (propuesto): GitHub Pages
Publica el catálogo estático en GitHub Pages en un repositorio llamado catalogo. Necesitarás basePath y la plantilla oficial de GitHub Actions. Comprueba que los enlaces entre productos y los ficheros .txt funcionan dentro de la subcarpeta.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Construir y ejecutar | next build, next start | Lo que se decide en el build queda grabado; lo de ejecución cambia sin reconstruir |
| Servidor Node | next start -p -H | Todas las funciones con un solo proceso; proxy inverso delante y parada con SIGTERM |
| Streaming tras un proxy | proxy_buffering off, X-Accel-Buffering: no | Si algo acumula la respuesta, <Suspense> llega de golpe |
| Salida autosuficiente | output: 'standalone' | server.js + node_modules rastreado; public y .next/static se copian a mano |
| Rastreo de ficheros | outputFileTracingRoot, outputFileTracingIncludes | En monorepos cambia la raíz; lo que se carga por nombre calculado hay que incluirlo |
| Imagen Docker | Dockerfile de cuatro etapas | 230 MB frente a 927 MB; usuario sin privilegios, HEALTHCHECK, compilar dentro de Linux |
| Variables de entorno | connection(), NEXT_PUBLIC_ | Una imagen para todos los entornos: leer tras connection(); nunca en el armazón |
| Varias réplicas | cacheHandlers, cacheHandler | Sin caché compartida, revalidateTag solo afecta a una réplica |
| Clave de Server Actions | NEXT_SERVER_ACTIONS_ENCRYPTION_KEY | Igual en todos los builds que convivan |
| Desfase de versiones | deploymentId | Detecta clientes de otra versión y fuerza recarga completa |
| Exportación estática | output: 'export' | Sin servidor: sin Actions, proxy, ISR, cookies ni Cache Components |
Nginx para out/ | try_files $uri $uri.html $uri/ =404 | Imagen de 63 MB; /_next/static inmutable un año |
| Vercel | Integración con Git | CDN, funciones y caché global ya resueltas; adaptador abierto con la API pública |
| Adapter API 🆕 16.2 | adapterPath, NEXT_ADAPTER_PATH, NextAdapter | modifyConfig y onBuildComplete({ outputs, routing }); verificados: Vercel y Bun |
| OpenNext | @opennextjs/cloudflare, AWS, Netlify | Adaptadores de código abierto; comprueba qué versión de Next admiten |
| Caché HTTP | Cache-Control, Vary, _rsc | Lo dinámico sale private, no-store; la CDN debe respetar rsc y _rsc |
| CDN e invalidación | revalidateTag + purga | Invalidar Next no invalida la CDN |