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 con output: 'standalone', exportación estática con output: 'export' y plataformas con adaptador
  • Escribir un Dockerfile de 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 y deploymentId
  • 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:

  1. 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 de generateStaticParams. El resultado queda en la carpeta .next/ (o en out/ con exportación estática).
  2. 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, el proxy.

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 reciba POST.

1.1. Las cuatro opciones

Desde next build salen cuatro caminos: servidor Node con next start, contenedor con output standalone, ficheros estáticos con output export y plataforma con adaptador; los tres azules admiten todas las funciones y el estático solo algunas

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 /productos que lee la API EMPRESA, una página /marca con un dato en caché ('use cache') y dos Route Handlers: /api/salud para las comprobaciones de salud y /api/revalidar para 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 build prerenderiza 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 a next start. Se arranca con node server.js.
  • package.json y .next/server/: el código compilado del servidor y lo prerenderizado.
  • Un node_modules recortado: 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: standalone no 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.js arranca y el HTML llega, pero cada .js y .css da 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, HOSTNAME y KEEP_ALIVE_TIMEOUT. Por eso en un contenedor se usa HOSTNAME=0.0.0.0: si escuchara solo en 127.0.0.1, nada de fuera del contenedor podría conectarse.
  • Todo lo que pongas en next.config.ts se evalúa al construir. Si escribes deploymentId: process.env.DEPLOYMENT_ID, el valor que cuenta es el que había durante next 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, '../../') en next.config.ts, para que sea predecible.
  • Construir dentro de Docker con el proyecto en /app y su propio node_modules, como en la sección 4. Allí la raíz es el proyecto y server.js queda 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 dev y usa Docker para construir y probar lo que irá a producción.

4.1. El Dockerfile de varias etapas

Cuatro etapas encadenadas: base con node 24 alpine, deps con npm ci, builder con el código y npm run build, y runner, que solo recibe standalone, static y public

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, no node:alpine) para que una reconstrucción no cambie de Node sin que lo decidas.
  • La etapa deps copia solo package.json y package-lock.json antes 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, el npm ci (el paso más lento) se reutiliza en casi todas las construcciones.
  • npm ci instala también las devDependencies: hacen falta para compilar (TypeScript, Tailwind). No llegan a la imagen final porque la etapa runner no copia node_modules de 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_ENTORNO en 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=1 desactiva el envío anónimo de estadísticas de uso de Next.
  • Usuario nextjs sin privilegios. Por defecto los contenedores ejecutan como root. Si alguien encontrara una vulnerabilidad en tu aplicación, tendría root dentro del contenedor. Con un usuario propio, el daño queda mucho más limitado. La carpeta /cache se 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 de root el usuario nextjs no podría escribir en ella.
  • Las tres copias son exactamente lo que dice la sección 3: standalone y, a mano, public y .next/static.
  • HEALTHCHECK pide /api/salud cada 10 segundos. Docker marca el contenedor como healthy o unhealthy, y Compose u otros orquestadores pueden esperar a que esté sano antes de mandarle tráfico. Alpine trae wget, así que no hace falta instalar curl.

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:3000 publica el puerto 3000 del contenedor en el 3001 de tu máquina, y solo en la interfaz local.
  • -e define variables de entorno de ejecución: las verás en acción en la sección 5.
  • host.docker.internal es el nombre con el que un contenedor llega a tu máquina. Dentro del contenedor, localhost es el propio contenedor: si la página /productos intentara leer http://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?

Línea temporal con dos momentos: docker build, donde se congelan NEXT_PUBLIC, el process.env leído en el armazón estático y next.config; y docker run, donde se leen process.env tras connection, Route Handlers dinámicos, Server Actions, PORT y HOSTNAME

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ó durante next build, cuando SALUDO no 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.env sin APIs dinámicas es estática entera (valor del build en HTML y RSC), y con connection() 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 de await connection(), que existe también en la 15.

Al hacer la captura de la portada en el navegador apareció algo sorprendente:

Portada del contenedor en el navegador: SALUDO congelado en el build muestra hola-desde-docker-run, igual que SALUDO leído al pedir la página; el contenedor es 540e0d84d820 y NEXT_PUBLIC_ENTORNO vale produccion

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.env de 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 de await connection() (o dentro de algo dinámico: cookies(), headers(), una Server Action, un Route Handler dinámico), como hace DatosDeEjecucion. 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 .env con secretos en la imagen: el .dockerignore lo excluye. Cualquiera que tenga la imagen podría leerlo con docker history o 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 no ARG: los ARG quedan 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:

  1. Mover la lectura detrás de connection() o dentro de <Suspense> con datos de ejecución, si el dato debe ser fresco.
  2. Usar 'use cache' con cacheLife y dejar que se rellene en la primera petición. Si el dato se lee en el armazón, sigue haciendo falta en el build.
  3. 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 .db no 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 ejemplo DB_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/client también admite con una URL libsql://).

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.

Navegador que llega a Nginx en el puerto 5523, que reparte entre web1 y web2; las dos réplicas escriben y leen en una caché compartida en un volumen /cache

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', cacheTag y cacheLife, que en Next.js 15 son experimentales. Si usas Next.js 15: el problema es idéntico con unstable_cache o fetch con next: { revalidate, tags }, cuya caché también vive en cada réplica, y revalidateTag('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 cada curl abre una conexión nueva que puede caer en otro trabajador que empieza su cuenta por web1. 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ón cacheHandlers son experimentales (experimental.cacheHandlers, junto con experimental.useCache). Si usas Next.js 15: con el modelo de caché de la 15 (fetch con next.revalidate, ISR, unstable_cache) usa la opción estable cacheHandler (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 un ReadableStream que 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 o undefined (fallo de caché). Debe construir un stream nuevo en cada acierto. No necesita mirar la edad: Next compara timestamp con expire por su cuenta. Sí debe consultar las etiquetas: si una etiqueta caducó después de crear la entrada, devuelve undefined; si solo se marcó como obsoleta, devuelve la entrada con revalidate: -1 para que Next la sirva y la regenere detrás.
  • updateTags(tags, durations) se llama al invalidar. Sin durations (updateTag, o revalidateTag con un solo argumento) la etiqueta caduca ya. Con durations (revalidateTag(tag, 'max')) la etiqueta queda obsoleta y caduca a los expire segundos.
  • 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 de revalidatePath).
  • Los errores se capturan: un get que 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-disable es necesario porque la configuración de ESLint de la plantilla prohíbe require(), 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 oficial cache-handler-redis del 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ñade output: '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.

// 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

Catálogo EMPRESA servido por Nginx: título, 20 productos generado el 2/11/2026, un buscador y una cuadrícula de productos con su precio

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.ts para 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 push a la rama principal crea un despliegue de producción.
  • Cada push a 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 revalidateTag se 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-vercel en 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 con next start, con Docker standalone o 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.

next.config con adapterPath apunta a next build, donde modifyConfig ajusta la configuración, se compila y prerenderiza, y onBuildComplete recibe outputs y routing para producir la salida propia de la plataforma

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én next dev), así que usa phase para 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: el proxy, 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 nextjs de 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/cloudflare y, como dependencia de desarrollo, wrangler (la herramienta de Cloudflare).
  • Aparecen dos ficheros de configuración: wrangler.jsonc (el Worker, con la opción de compatibilidad nodejs_compat) y open-next.config.ts.
  • Los scripts de package.json cambian: preview ejecuta opennextjs-cloudflare build && opennextjs-cloudflare preview para probar en local con el entorno de Cloudflare, y deploy hace lo mismo con deploy.
  • 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-next se 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: 1 y x-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.
  • Vary avisa 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 rsc debe 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 _rsc debe formar parte de la clave de caché. Muchas CDN no respetan Vary con 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 proxy debe ir antes de la caché. Si la CDN sirve una página guardada sin pasar por el proxy, se salta tu comprobación de sesión. Si no puede ser, excluye de la caché las rutas que dependen del proxy.
  • Invalidar en Next no invalida la CDN. revalidateTag y revalidatePath limpian la caché del servidor de Next, pero la CDN seguirá sirviendo su copia hasta que caduque su s-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 en out/ (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