Módulo 24: Pages Router y migración

Objetivos del módulo

  • Saber leer un proyecto escrito con el Pages Router: la carpeta pages/, _app, _document, next/head, next/router y las API Routes
  • Entender qué hacen de verdad getStaticProps, getStaticPaths, getServerSideProps y el revalidate de ISR, y qué envían al navegador
  • Traducir cada pieza del Pages Router a su equivalente del App Router, tanto con Cache Components (Next.js 16) como con el modelo de Next.js 15
  • Migrar una aplicación real ruta a ruta, con pages/ y app/ funcionando a la vez, y reconocer los errores que aparecen por el camino
  • Actualizar un proyecto real de Next.js 15.5 a Next.js 16 con los codemods oficiales, ver qué cambian y arreglar a mano lo que no tocan
  • Activar Cache Components en un proyecto que viene de Next.js 15 y sustituir dynamic, revalidate y la caché de fetch por 'use cache' y <Suspense>
  • Tener a mano una tabla completa de lo que cambia de Next.js 15 a 16, con el módulo del curso donde se explica cada cosa

💡 Consejo: Todo lo que ves en este módulo se ha compilado y ejecutado con Next.js 16.4 y, para la parte de actualización, con un proyecto real creado con create-next-app@15.5.28. Las salidas que aparecen (tablas de next build, cabeceras HTTP, mensajes de error y diferencias que generan los codemods) son reales, copiadas del laboratorio. Instala siempre el último parche de la 16.4.

Este módulo tiene dos partes que se pueden leer por separado:

  • Parte I (secciones 1 a 8): el Pages Router y cómo pasar una aplicación de pages/ a app/.
  • Parte II (secciones 9 a 15): cómo actualizar un proyecto de Next.js 15 a Next.js 16, y después a Cache Components.

1. Por qué todavía tienes que saber leer pages/

Todo el curso usa el App Router, la carpeta app/, que apareció en Next.js 13.4 (2023). Antes, durante siete años, Next.js solo tenía un sistema de rutas: la carpeta pages/. Hoy se le llama Pages Router para distinguirlo.

El Pages Router no ha desaparecido:

  • Next.js 16 lo sigue incluyendo y manteniendo. Puedes crear hoy un proyecto solo con pages/ y funcionará. La documentación instalada en node_modules/next/dist/docs/ tiene una sección entera, 02-pages, para él.
  • Hay muchísimo código escrito así. Tutoriales, respuestas de Stack Overflow, plantillas y, sobre todo, aplicaciones en producción que tu empresa no va a reescribir de golpe.
  • Los dos sistemas pueden convivir en el mismo proyecto. Es la forma recomendada de migrar: una ruta cada vez.

📘 Concepto: La diferencia de fondo no es el nombre de la carpeta. En pages/ cada página es un componente cliente: el servidor genera el HTML, pero después todo el código de la página se descarga y se ejecuta en el navegador. Los datos llegan a la página por props, desde unas funciones especiales que solo se ejecutan en el servidor. En app/ los componentes son de servidor por defecto (módulo 05) y piden sus propios datos con await. Casi todas las diferencias que verás salen de aquí.

En esta parte vamos a trabajar con una pequeña aplicación EMPRESA escrita con el Pages Router, la vamos a ejecutar para ver qué hace de verdad, y después la migraremos al App Router paso a paso. Los datos salen de la API EMPRESA (recursos/empresa-api.zip, que se arranca con node server.mjs y escucha en http://localhost:3000/api), así que arranca Next en otro puerto, por ejemplo con next dev -p 3001.


2. Anatomía de un proyecto con Pages Router

2.1 La estructura

Esta es la aplicación de partida. Tiene un panel con estadísticas, el catálogo de productos con su ficha, la lista de empleados con filtro por departamento, la ficha de cada empleado y dos API Routes:

empresa-pages/
├── components/
│   ├── Cabecera.tsx          # menú común
│   └── Moneda.tsx            # contexto con la moneda elegida
├── lib/
│   └── api.ts                # URL de la API y tipos
├── pages/
│   ├── _app.tsx              # envuelve todas las páginas
│   ├── _document.tsx         # el esqueleto <html> <body>
│   ├── 404.tsx               # página de error 404
│   ├── index.tsx             # /            (ISR)
│   ├── productos/
│   │   ├── index.tsx         # /productos   (SSG)
│   │   └── [id].tsx          # /productos/3 (SSG + getStaticPaths)
│   ├── empleados/
│   │   ├── index.tsx         # /empleados   (SSR)
│   │   └── [id].tsx          # /empleados/101 (SSR)
│   └── api/
│       ├── empleados/[id].ts # GET /api/empleados/101
│       └── revalidar.ts      # POST /api/revalidar
├── styles/
│   └── globals.css
└── next.config.ts

Las reglas para convertir un fichero en URL se parecen a las del App Router, pero con una diferencia importante: en pages/ el propio fichero es la ruta. pages/productos/[id].tsx responde en /productos/3; en app/ necesitarías una carpeta app/productos/[id]/ con un page.tsx dentro.

Fichero en pages/ URL Equivalente en app/
pages/index.tsx / app/page.tsx
pages/productos/index.tsx /productos app/productos/page.tsx
pages/productos.tsx /productos app/productos/page.tsx
pages/productos/[id].tsx /productos/3 app/productos/[id]/page.tsx
pages/docs/[...slug].tsx /docs/a/b app/docs/[...slug]/page.tsx
pages/api/empleados/[id].ts /api/empleados/101 app/api/empleados/[id]/route.ts

Como cualquier fichero de pages/ es una página, no puedes guardar componentes dentro: un pages/productos/Tarjeta.tsx se convertiría en la URL /productos/Tarjeta. Por eso estos proyectos tienen siempre una carpeta components/ aparte. En app/ sí puedes colocar componentes junto a las páginas, porque solo page.tsx y route.ts crean rutas (módulo 04).

Los ficheros que empiezan por guion bajo (_app, _document, _error) son especiales y no generan URL.

2.2 _document.tsx: el esqueleto HTML

// pages/_document.tsx: el esqueleto HTML; solo se ejecuta en el servidor
import { Html, Head, Main, NextScript } from 'next/document'

export default function Document() {
  return (
    <Html lang="es">
      <Head>
        <meta name="theme-color" content="#1a237e" />
      </Head>
      <body className="empresa">
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}

_document decide qué hay fuera de React: el atributo lang, clases en <body>, etiquetas <meta> fijas. <Main /> es el hueco donde se pinta la aplicación y <NextScript /> añade los <script> de Next. Se ejecuta solo en el servidor y solo al generar el HTML: no puedes poner ahí manejadores de eventos ni estado.

⚠️ Importante: El <Head> de next/document no es el mismo que el <Head> de next/head. El primero solo vale dentro de _document y es para lo común a todas las páginas. El segundo se usa dentro de las páginas para el <title> de cada una. Confundirlos es un error clásico al leer código antiguo.

2.3 _app.tsx: lo que envuelve a todas las páginas

// pages/_app.tsx: envuelve TODAS las páginas de pages/
import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import Head from 'next/head'
import Cabecera from '@/components/Cabecera'
import { ProveedorMoneda } from '@/components/Moneda'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ProveedorMoneda>
      <Head>
        <title>EMPRESA</title>
      </Head>
      <Cabecera router="pages" />
      <main>
        <Component {...pageProps} />
      </main>
    </ProveedorMoneda>
  )
}

Next llama a App en cada navegación con dos props: Component, la página que toca, y pageProps, los datos que devolvió su función de datos. _app es el sitio para tres cosas:

  • El CSS global. En pages/ solo se puede importar CSS global desde _app. Si lo importas en una página, el build falla.
  • Los proveedores de contexto (tema, moneda, sesión, React Query), que así envuelven toda la aplicación.
  • El layout común (cabecera y pie). Como _app no se desmonta al cambiar de página, el estado del proveedor se conserva al navegar.

El contexto de la moneda es un contexto normal de React:

'use client'
// components/Moneda.tsx: contexto con la moneda elegida (EUR o USD)
import { createContext, useContext, useState, type ReactNode } from 'react'

type Moneda = 'EUR' | 'USD'
const ContextoMoneda = createContext<{ moneda: Moneda; cambiar: () => void }>({
  moneda: 'EUR',
  cambiar: () => {},
})

export function ProveedorMoneda({ children }: { children: ReactNode }) {
  const [moneda, setMoneda] = useState<Moneda>('EUR')
  const cambiar = () => setMoneda((m) => (m === 'EUR' ? 'USD' : 'EUR'))
  return <ContextoMoneda.Provider value={{ moneda, cambiar }}>{children}</ContextoMoneda.Provider>
}

export function useMoneda() {
  return useContext(ContextoMoneda)
}

export function Precio({ euros }: { euros: number }) {
  const { moneda } = useMoneda()
  const valor = moneda === 'EUR' ? euros : euros * 1.08
  return <>{valor.toLocaleString('es-ES', { style: 'currency', currency: moneda })}</>
}

export function BotonMoneda() {
  const { moneda, cambiar } = useMoneda()
  return <button onClick={cambiar}>Ver en {moneda === 'EUR' ? 'USD' : 'EUR'}</button>
}

La directiva 'use client' no hace nada en pages/: allí todo es cliente. La hemos puesto desde el principio porque este mismo fichero lo usará después el App Router, donde sí es necesaria. Un fichero compartido entre los dos sistemas tiene que funcionar en ambos.

2.4 next/head y next/router

Dentro de las páginas verás dos importaciones que no existen en app/:

import Head from 'next/head'          // <title> y <meta> de cada página
import { useRouter } from 'next/router' // ruta actual, query y navegación
  • next/head mete etiquetas en el <head> desde cualquier componente. En app/ lo sustituye la Metadata API (export const metadata y generateMetadata, módulo 07).
  • useRouter de next/router devuelve un objeto con todo: router.pathname, router.query (parámetros de la ruta y de la búsqueda mezclados), router.push, router.back, router.events… En app/ se reparte en tres hooks de next/navigation: useRouter (solo navegar), usePathname y useSearchParams, más useParams (módulo 03).

⚠️ Importante: useRouter existe en los dos paquetes con el mismo nombre. Si en app/ importas el de next/router, el error en ejecución es NextRouter was not mounted. Al migrar, cambia siempre la importación a next/navigation y revisa qué propiedades usaba el código: router.query y router.events no existen en la versión nueva.


3. Las funciones de datos

En pages/ una página no puede ser async ni hacer await fetch(...): es un componente cliente. Los datos se piden en una función aparte, exportada desde el mismo fichero, que Next ejecuta solo en el servidor y cuyo resultado pasa a la página como props. Hay tres, y el nombre decide cuándo se ejecuta.

Función Cuándo se ejecuta Modelo (módulo 02)
getStaticProps En next build SSG
getStaticProps con revalidate En next build y, después, como mucho cada N segundos ISR
getStaticPaths En next build, para decidir qué rutas dinámicas generar SSG de rutas [id]
getServerSideProps En cada petición SSR

3.1 getStaticProps: datos en tiempo de build

// pages/productos/index.tsx: SSG puro, se genera una vez en next build
import type { GetStaticProps, InferGetStaticPropsType } from 'next'
import Head from 'next/head'
import Link from 'next/link'
import { API, type Pagina, type Producto } from '@/lib/api'
import { BotonMoneda, Precio } from '@/components/Moneda'

export const getStaticProps = (async () => {
  const res = await fetch(`${API}/productos?disponibles=true`)
  const pagina: Pagina<Producto> = await res.json()
  return { props: { productos: pagina.contenido } }
}) satisfies GetStaticProps

export default function Productos({ productos }: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <>
      <Head>
        <title>Productos | EMPRESA</title>
      </Head>
      <h1>Productos</h1>
      <BotonMoneda />
      <table>
        <thead>
          <tr><th>Producto</th><th>Categoría</th><th>Precio</th></tr>
        </thead>
        <tbody>
          {productos.map((p) => (
            <tr key={p.productoId}>
              <td><Link href={`/productos/${p.productoId}`}>{p.nombre}</Link></td>
              <td>{p.categoria}</td>
              <td><Precio euros={p.precio} /></td>
            </tr>
          ))}
        </tbody>
      </table>
    </>
  )
}

Fíjate en el patrón de tipos, que verás en casi todo el código moderno de Pages Router: la función se escribe con satisfies GetStaticProps y la página recibe InferGetStaticPropsType<typeof getStaticProps>. Así TypeScript deduce las props a partir de lo que devuelve la función, y no hay que escribir el tipo dos veces.

El fichero lib/api.ts solo tiene la URL de la API y los tipos:

// lib/api.ts: acceso a la API EMPRESA, compartido por pages y app
export const API = process.env.API_URL ?? 'http://localhost:3000/api'

export type Producto = {
  productoId: number
  nombre: string
  descripcion: string
  precio: number
  stock: number
  categoria: string
  descatalogado: boolean
}

export type Empleado = {
  empId: number
  nombre: string
  apellidos: string
  oficio: string
  salario: number
  deptId: number | null
  departamento?: string
}

export type Estadisticas = {
  empleados: number
  departamentos: number
  productos: number
  pedidos: number
  facturacion: number
}

export type Pagina<T> = { contenido: T[]; total: number }

3.2 ISR: revalidate en getStaticProps

Si añades revalidate al objeto que devuelve getStaticProps, la página se genera en el build y Next la vuelve a generar en segundo plano cuando alguien la pide y han pasado más de esos segundos. Es la ISR (Incremental Static Regeneration):

// pages/index.tsx: ISR, se regenera como mucho cada 60 segundos
import type { GetStaticProps, InferGetStaticPropsType } from 'next'
import Head from 'next/head'
import { API, type Estadisticas } from '@/lib/api'

export const getStaticProps = (async () => {
  const res = await fetch(`${API}/estadisticas`)
  const estadisticas: Estadisticas = await res.json()
  return {
    props: { estadisticas, generada: new Date().toISOString() },
    revalidate: 60,
  }
}) satisfies GetStaticProps

export default function Inicio({
  estadisticas,
  generada,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <>
      <Head>
        <title>Inicio | EMPRESA</title>
      </Head>
      <h1>Panel de EMPRESA</h1>
      <div className="tarjetas">
        <div className="tarjeta"><strong>{estadisticas.empleados}</strong>empleados</div>
        <div className="tarjeta"><strong>{estadisticas.departamentos}</strong>departamentos</div>
        <div className="tarjeta"><strong>{estadisticas.productos}</strong>productos</div>
        <div className="tarjeta"><strong>{estadisticas.pedidos}</strong>pedidos</div>
      </div>
      <p className="pie">Página generada: {generada}</p>
    </>
  )
}

3.3 getStaticPaths y fallback

Una ruta dinámica como /productos/[id] necesita saber qué valores de id generar en el build. Eso lo dice getStaticPaths:

// pages/productos/[id].tsx: rutas estáticas con getStaticPaths
import type { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from 'next'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { API, type Producto } from '@/lib/api'
import { Precio } from '@/components/Moneda'

export const getStaticPaths = (async () => {
  // Solo los cinco primeros en el build; el resto, bajo demanda
  return {
    paths: [1, 2, 3, 4, 5].map((id) => ({ params: { id: String(id) } })),
    fallback: 'blocking',
  }
}) satisfies GetStaticPaths

export const getStaticProps = (async ({ params }) => {
  const res = await fetch(`${API}/productos/${params?.id}`)
  if (res.status === 404) return { notFound: true }
  const producto: Producto = await res.json()
  return { props: { producto }, revalidate: 300 }
}) satisfies GetStaticProps<{ producto: Producto }, { id: string }>

export default function DetalleProducto({ producto }: InferGetStaticPropsType<typeof getStaticProps>) {
  const router = useRouter()
  return (
    <>
      <Head>
        <title>{`${producto.nombre} | EMPRESA`}</title>
      </Head>
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
      <p>
        <strong><Precio euros={producto.precio} /></strong> · {producto.stock} en almacén
      </p>
      <button onClick={() => router.back()}>Volver</button>
    </>
  )
}

fallback decide qué pasa con un id que no estaba en paths:

fallback Qué pasa con /productos/12 Equivalente en app/
false 404 dynamicParams = false (Next.js 15)
'blocking' Se genera en el servidor la primera vez, se guarda y se sirve como estática Comportamiento por defecto
true Se envía al momento una versión “cargando” de la página (router.isFallback es true) y los datos llegan después Comportamiento por defecto + loading.tsx o <Suspense>

getStaticProps también puede devolver { notFound: true } (404) o { redirect: { destination: '/', permanent: false } }. En app/ son las funciones notFound() y redirect() de next/navigation.

3.4 getServerSideProps: datos en cada petición

// pages/empleados/index.tsx: SSR, se ejecuta en cada petición
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import Head from 'next/head'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { API, type Empleado, type Pagina } from '@/lib/api'

export const getServerSideProps = (async ({ query }) => {
  const deptId = typeof query.deptId === 'string' ? query.deptId : ''
  const url = deptId ? `${API}/empleados?deptId=${deptId}` : `${API}/empleados`
  const res = await fetch(url)
  const pagina: Pagina<Empleado> = await res.json()
  return { props: { empleados: pagina.contenido, deptId } }
}) satisfies GetServerSideProps

export default function Empleados({
  empleados,
  deptId,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  const router = useRouter()
  return (
    <>
      <Head>
        <title>Empleados | EMPRESA</title>
      </Head>
      <h1>Empleados</h1>
      <label>
        Departamento:{' '}
        <select
          value={deptId}
          onChange={(e) => router.push({ pathname: '/empleados', query: e.target.value ? { deptId: e.target.value } : {} })}
        >
          <option value="">Todos</option>
          {[10, 20, 30, 40, 50, 60, 70].map((d) => (
            <option key={d} value={d}>{d}</option>
          ))}
        </select>
      </label>
      <table>
        <thead>
          <tr><th>Nombre</th><th>Oficio</th><th>Departamento</th></tr>
        </thead>
        <tbody>
          {empleados.map((e) => (
            <tr key={e.empId}>
              <td><Link href={`/empleados/${e.empId}`}>{e.nombre} {e.apellidos}</Link></td>
              <td>{e.oficio}</td>
              <td>{e.departamento ?? 'Sin departamento'}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="pie">{empleados.length} empleados</p>
    </>
  )
}

Página de empleados del Pages Router con el selector de departamento en 30 y seis empleados de INFORMÁTICA; la etiqueta naranja de la cabecera dice pages/

Figura 1. La lista de empleados servida por pages/, filtrada por el departamento 30. Es SSR: cada cambio del selector vuelve a ejecutar getServerSideProps.

getServerSideProps recibe un contexto con todo lo de la petición: params, query, req y res (los objetos de Node), resolvedUrl, locale… La ficha del empleado lee y escribe una cookie:

// pages/empleados/[id].tsx: SSR con params, cookies y notFound
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import Head from 'next/head'
import { API, type Empleado } from '@/lib/api'

export const getServerSideProps = (async ({ params, req, res }) => {
  const r = await fetch(`${API}/empleados/${params!.id}`)
  if (r.status === 404) return { notFound: true }
  const empleado: Empleado = await r.json()
  // Leer y escribir cookies con los objetos de Node
  const anterior = req.cookies.ultimoEmpleado ?? null
  res.setHeader('Set-Cookie', `ultimoEmpleado=${empleado.empId}; Path=/; HttpOnly`)
  return { props: { empleado, anterior } }
}) satisfies GetServerSideProps<{ empleado: Empleado; anterior: string | null }, { id: string }>

export default function DetalleEmpleado({
  empleado,
  anterior,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <>
      <Head>
        <title>{`${empleado.nombre} ${empleado.apellidos} | EMPRESA`}</title>
      </Head>
      <h1>{empleado.nombre} {empleado.apellidos}</h1>
      <p>{empleado.oficio} · {empleado.departamento ?? 'Sin departamento'}</p>
      <p className="pie">Último empleado consultado antes: {anterior ?? 'ninguno'}</p>
    </>
  )
}

3.5 Lo que dice next build

Con la API en marcha, next build de esta aplicación (en Next.js 16.4) imprime:

Route (pages)                   Revalidate  Expire
┌ ● / (3967 ms)                         1m      1y
├   /_app
├ ○ /404 (6131 ms)
├ ƒ /api/empleados/[id]
├ ƒ /api/revalidar
├ ƒ /empleados
├ ƒ /empleados/[id]
├ ● /productos (4088 ms)
└   /productos/[id] (20812 ms)          5m      1y
  ├ ● /productos/4 (4205 ms)
  ├ ● /productos/1 (4174 ms)
  ├ ● /productos/2 (4161 ms)
  ├ ● /productos/3 (4160 ms)
  └ ● /productos/5 (4112 ms)

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses getStaticProps)
ƒ  (Dynamic)  server-rendered on demand

El encabezado dice Route (pages). El símbolo ● (SSG) solo aparece para páginas con getStaticProps; la columna Revalidate muestra el revalidate: 60 de la portada (1m) y los 300 segundos de las fichas (5m). /404 no tiene funciones de datos y sale como ○. Las páginas con getServerSideProps y las API Routes son ƒ.

Con next start, las cabeceras confirman cada modelo:

$ curl -i http://localhost:3001/
HTTP/1.1 200 OK
x-nextjs-cache: STALE
Cache-Control: s-maxage=60, stale-while-revalidate=31535940

$ curl -i http://localhost:3001/productos/12      (primera vez: no estaba en paths)
HTTP/1.1 200 OK
x-nextjs-cache: MISS
Cache-Control: s-maxage=300, stale-while-revalidate=31535700

$ curl -i http://localhost:3001/productos/12      (segunda vez)
HTTP/1.1 200 OK
x-nextjs-cache: HIT

$ curl -i http://localhost:3001/productos/999
HTTP/1.1 404 Not Found

$ curl -i -b "ultimoEmpleado=100" http://localhost:3001/empleados/101
HTTP/1.1 200 OK
Set-Cookie: ultimoEmpleado=101; Path=/; HttpOnly
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

La portada sale STALE porque el build tardó más de 60 segundos en terminar: la primera petición recibe la copia antigua y dispara la regeneración en segundo plano. /productos/12 muestra fallback: 'blocking' en acción: MISS la primera vez (se genera) y HIT la segunda (ya está guardada).

3.6 __NEXT_DATA__: todo lo que devuelves viaja al navegador

Esto es lo más importante que tienes que saber al leer código de Pages Router. Las props que devuelve la función de datos se incrustan en el HTML dentro de un <script id="__NEXT_DATA__">, porque la página es un componente cliente y el navegador las necesita para hidratarla:

$ curl -s http://localhost:3001/empleados/101 | grep -o '<script id="__NEXT_DATA__".*'
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"empleado":
{"empId":101,"nombre":"Luis","apellidos":"Martínez Ruiz","email":"luis.martinez@empresa.es",
"telefono":"600100101","fechaAlta":"2012-03-01","oficio":"DIRECTOR","salario":4800,
"comision":null,"jefeId":100,"deptId":20,"departamento":"VENTAS"},"anterior":"100"},
"__N_SSP":true},"page":"/empleados/[id]","query":{"id":"101"},...

La página solo muestra el nombre, el oficio y el departamento, pero el correo, el teléfono y el salario del empleado están en el HTML para cualquiera que mire el código fuente. Lo mismo ocurre en la portada: devolvemos el objeto entero de /api/estadisticas, con la masa salarial de cada departamento, aunque solo pintemos cuatro números. Al navegar con <Link>, Next pide esos mismos datos como JSON a /_next/data/<buildId>/empleados.json?deptId=40.

⚠️ Importante: En Pages Router, filtra siempre lo que devuelves en props: solo lo que la página pinta. Es un fallo de seguridad muy frecuente en proyectos reales. En el App Router el problema casi desaparece: un Server Component envía al navegador el HTML resultante y la carga RSC de lo que pinta, no el objeto completo. Lo comprobaremos en la sección 7.2.

3.7 API Routes

Las API Routes son ficheros dentro de pages/api/ que exportan una sola función handler(req, res) al estilo de Express:

// pages/api/empleados/[id].ts: API Route al estilo de Express
import type { NextApiRequest, NextApiResponse } from 'next'
import { API } from '@/lib/api'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'GET') {
    res.setHeader('Allow', 'GET')
    return res.status(405).json({ error: `Método ${req.method} no permitido` })
  }
  const r = await fetch(`${API}/empleados/${req.query.id}`)
  if (!r.ok) return res.status(r.status).json({ error: 'Empleado no encontrado' })
  const { empId, nombre, apellidos, oficio } = await r.json()
  res.setHeader('Cache-Control', 'no-store')
  res.status(200).json({ empId, nombre: `${nombre} ${apellidos}`, oficio })
}
$ curl -i http://localhost:3001/api/empleados/101
HTTP/1.1 200 OK
Cache-Control: no-store
{"empId":101,"nombre":"Luis Martínez Ruiz","oficio":"DIRECTOR"}

$ curl -i -X DELETE http://localhost:3001/api/empleados/101
HTTP/1.1 405 Method Not Allowed
Allow: GET
{"error":"Método DELETE no permitido"}

Rasgos que delatan una API Route: req.method comprobado a mano, req.query para los parámetros de la ruta y de la búsqueda a la vez, req.body ya leído (Next lo convierte de JSON por ti, configurable con export const config = { api: { bodyParser: ... } }), y res.status().json().

La otra API Route sirve para la revalidación bajo demanda: regenerar una página ISR sin esperar al tiempo de revalidate, por ejemplo cuando el CMS avisa de un cambio:

// pages/api/revalidar.ts: ISR bajo demanda
import type { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end()
  if (req.query.secreto !== process.env.SECRETO_REVALIDAR) {
    return res.status(401).json({ error: 'Secreto incorrecto' })
  }
  await res.revalidate('/')
  return res.json({ revalidado: true })
}
$ curl -i -X POST "http://localhost:3001/api/revalidar?secreto=abc"
HTTP/1.1 200 OK
{"revalidado":true}

res.revalidate(ruta) solo existe en Pages Router y trabaja por URL. En el App Router se invalida por etiquetas (revalidateTag, updateTag) o por ruta (revalidatePath), como viste en el módulo 09.


4. El plan de migración

4.1 Qué sustituye a qué

Dos columnas con flechas: _app y _document pasan a app/layout.tsx; getStaticProps a un componente async con 'use cache'; revalidate a cacheLife; getStaticPaths a generateStaticParams; getServerSideProps a un componente async dentro de Suspense; next/head y next/router a metadata y next/navigation; pages/api a route.ts; 404 y _error a not-found y error

Figura 2. Las equivalencias entre los dos routers. La columna de la derecha usa Cache Components; con Next.js 15 la caché se expresa con export const revalidate y opciones de fetch (secciones 5 y 6).

Pages Router App Router con Cache Components (16) App Router en Next.js 15
pages/_app.tsx + pages/_document.tsx app/layout.tsx Igual
CSS global solo en _app CSS global en cualquier layout Igual
Proveedores en _app Componente cliente dentro del layout Igual
getStaticProps async + 'use cache' + cacheLife('max') async + fetch (estático por defecto si no lee nada de la petición)
revalidate: 60 cacheLife({ revalidate: 60 }) export const revalidate = 60 o fetch(url, { next: { revalidate: 60 } })
getStaticPaths generateStaticParams Igual
fallback: false No hay equivalente directo (dynamicParams no se admite) export const dynamicParams = false
getServerSideProps async sin caché dentro de <Suspense> async que lee cookies, headers o searchParams, o dynamic = 'force-dynamic'
context.query searchParams (promesa) Igual
context.params params (promesa) Igual
req.cookies, req.headers await cookies(), await headers() Igual
res.setHeader('Set-Cookie') Server Action, Route Handler o proxy.ts Igual, con middleware.ts
{ notFound: true } notFound() Igual
{ redirect: ... } redirect() Igual
next/head metadata, generateMetadata Igual
next/router next/navigation Igual
pages/404.tsx app/not-found.tsx Igual
pages/_error.tsx, 500.tsx error.tsx, global-error.tsx Igual
pages/api/x.ts app/api/x/route.ts Igual
res.revalidate('/') revalidateTag(tag, 'max'), revalidatePath('/') revalidateTag(tag), revalidatePath('/')

4.2 Migrar una ruta cada vez

La documentación oficial insiste en no migrarlo todo de golpe. app/ y pages/ pueden convivir en el mismo proyecto, así que el plan es:

  1. Crear app/layout.tsx (sin tocar _app ni _document, que siguen sirviendo a pages/).
  2. Mover una sección de la web (por ejemplo, el catálogo), compilar, probar y desplegar.
  3. Repetir con la siguiente.
  4. Cuando pages/ se quede vacía, borrar _app, _document y la carpeta.

Tres fases: primero todo en pages/; después app/ con layout y productos conviviendo con pages/ con el resto; al final todo en app/. Una nota avisa de que entre rutas de app y de pages la navegación es completa

Figura 3. La migración incremental. Mientras convivan los dos routers, pasar de una ruta de app/ a una de pages/ recarga la página entera.

Tres reglas de la convivencia:

  • Una URL solo puede estar en un sitio. Si existe en los dos, el build falla (lo veremos ahora).
  • Los estilos y proveedores no se comparten. _app solo envuelve a pages/ y app/layout.tsx solo a app/. Durante la migración, los dos tienen que importar el CSS global y montar los proveedores.
  • Entre routers, la navegación es completa. Un <Link> de una página de app/ a una de pages/ funciona, pero el navegador descarga una página nueva: se pierde el estado del cliente (la moneda elegida, por ejemplo) y no hay precarga. Por eso conviene migrar juntas las páginas entre las que más se navega.

5.1 app/layout.tsx sustituye a _app y _document

// app/layout.tsx: sustituye a pages/_app.tsx y pages/_document.tsx
import '@/styles/globals.css'
import type { Metadata } from 'next'
import Cabecera from '@/components/Cabecera'
import { ProveedorMoneda } from '@/components/Moneda'

export const metadata: Metadata = {
  title: { default: 'EMPRESA', template: '%s | EMPRESA' },
}

export const viewport = { themeColor: '#1a237e' }

export default function RootLayout({ children }: LayoutProps<'/'>) {
  return (
    <html lang="es">
      <body className="empresa">
        <ProveedorMoneda>
          <Cabecera router="app" />
          <main>{children}</main>
        </ProveedorMoneda>
      </body>
    </html>
  )
}

Qué ha pasado con cada pieza:

Antes Ahora
<Html lang="es"> de _document <html lang="es"> escrito por ti: el layout raíz debe tener <html> y <body>
<body className="empresa"> de _document Igual, en el layout
<meta name="theme-color"> en _document export const viewport
<title> de next/head en _app metadata.title con template: cada página pone su parte y el layout añade " \| EMPRESA"
import '@/styles/globals.css' en _app El mismo import en el layout
<ProveedorMoneda> en _app El mismo componente; como es 'use client', puede envolver a los Server Components que le llegan como children (módulo 05)
<Main /> y <NextScript /> Desaparecen: los pone Next

LayoutProps<'/'> es el tipo global que genera Next (desde 15.5) a partir de tus rutas.

💡 Consejo: No hagas 'use client' en el layout raíz para poder meter el proveedor dentro. Convertirías toda la aplicación en componentes cliente y perderías lo que buscas con la migración. Saca el proveedor a su propio fichero 'use client' y úsalo desde un layout de servidor, como aquí.

5.2 Una capa de datos con caché

En pages/ el fetch estaba dentro de cada función de datos. En app/ conviene sacarlo a funciones reutilizables, y ahí es donde decides la caché. Con Cache Components:

// lib/datos.ts: funciones de datos con caché para el App Router
import 'server-only'
import { cacheLife, cacheTag } from 'next/cache'
import { API, type Empleado, type Estadisticas, type Pagina, type Producto } from './api'

export async function getEstadisticas() {
  'use cache'
  cacheLife({ revalidate: 60 }) // el antiguo revalidate: 60
  cacheTag('estadisticas')
  const res = await fetch(`${API}/estadisticas`)
  const e: Estadisticas = await res.json()
  return { ...e, generada: new Date().toISOString() }
}

export async function getProductos() {
  'use cache'
  cacheLife('max') // el antiguo getStaticProps sin revalidate
  cacheTag('productos')
  const res = await fetch(`${API}/productos?disponibles=true`)
  const pagina: Pagina<Producto> = await res.json()
  return pagina.contenido
}

export async function getProducto(id: string) {
  'use cache'
  cacheLife({ revalidate: 300 })
  cacheTag('productos', `producto-${id}`)
  const res = await fetch(`${API}/productos/${id}`)
  if (res.status === 404) return null
  return (await res.json()) as Producto
}

// Sin 'use cache': datos de cada petición, como getServerSideProps
export async function getEmpleados(deptId: string) {
  const url = deptId ? `${API}/empleados?deptId=${deptId}` : `${API}/empleados`
  const res = await fetch(url)
  const pagina: Pagina<Empleado> = await res.json()
  return pagina.contenido
}

export async function getEmpleado(id: string) {
  const res = await fetch(`${API}/empleados/${id}`)
  if (res.status === 404) return null
  return (await res.json()) as Empleado
}

import 'server-only' hace que el build falle si un componente cliente importa este fichero por error (módulo 05). El new Date() dentro de una función 'use cache' está permitido: se ejecuta al rellenar la caché, no en cada petición, igual que en getStaticProps.

5.3 getStaticProps → 'use cache' 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 no existen 'use cache' ni cacheLife estables: un componente async que no lee datos de la petición es estático por defecto, y la caché se controla con export const revalidate y las opciones de fetch. Si usas Next.js 15: quita 'use cache', cacheLife y cacheTag de lib/datos.ts y usa fetch(url, { cache: 'force-cache', next: { revalidate: 60, tags: ['estadisticas'] } }), o export const revalidate = 60 en la página.

La página del catálogo queda así:

// app/productos/page.tsx: antes pages/productos/index.tsx con getStaticProps
import Link from 'next/link'
import { getProductos } from '@/lib/datos'
import { BotonMoneda, Precio } from '@/components/Moneda'

export const metadata = { title: 'Productos' }

export default async function Productos() {
  const productos = await getProductos()
  return (
    <>
      <h1>Productos</h1>
      <BotonMoneda />
      <table>
        <thead>
          <tr><th>Producto</th><th>Categoría</th><th>Precio</th></tr>
        </thead>
        <tbody>
          {productos.map((p) => (
            <tr key={p.productoId}>
              <td><Link href={`/productos/${p.productoId}`}>{p.nombre}</Link></td>
              <td>{p.categoria}</td>
              <td><Precio euros={p.precio} /></td>
            </tr>
          ))}
        </tbody>
      </table>
    </>
  )
}

Compara con la versión de pages/: desaparecen getStaticProps, InferGetStaticPropsType y <Head>. El componente es async y pide sus datos él mismo. BotonMoneda y Precio siguen siendo componentes cliente, y son lo único de esta página que se hidrata en el navegador.

Para quien siga en Next.js 15, esta es la misma idea, comprobada en un proyecto 15.5 (la usaremos en la parte II):

// Next.js 15: caché de fetch con etiqueta
const res = await fetch(`${API}/productos`, {
  cache: 'force-cache',
  next: { tags: ['productos'] },
})

5.4 getStaticPaths → generateStaticParams

// app/productos/[id]/page.tsx: antes getStaticPaths + getStaticProps
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getProducto } from '@/lib/datos'
import { Precio } from '@/components/Moneda'
import BotonVolver from '@/components/BotonVolver'

// Sustituye a getStaticPaths: los cinco primeros se generan en el build
export async function generateStaticParams() {
  return [1, 2, 3, 4, 5].map((id) => ({ id: String(id) }))
}

export async function generateMetadata({ params }: PageProps<'/productos/[id]'>): Promise<Metadata> {
  const { id } = await params
  const producto = await getProducto(id)
  return { title: producto?.nombre ?? 'Producto no encontrado' }
}

export default async function DetalleProducto({ params }: PageProps<'/productos/[id]'>) {
  const { id } = await params
  const producto = await getProducto(id)
  if (!producto) notFound() // antes: return { notFound: true }
  return (
    <>
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
      <p>
        <strong><Precio euros={producto.precio} /></strong> · {producto.stock} en almacén
      </p>
      <BotonVolver />
    </>
  )
}
'use client'
// components/BotonVolver.tsx: useRouter de next/navigation, no de next/router
import { useRouter } from 'next/navigation'

export default function BotonVolver() {
  const router = useRouter()
  return <button onClick={() => router.back()}>Volver</button>
}

Cuatro cambios a la vez:

  1. getStaticPaths devolvía { paths: [{ params: { id: '1' } }], fallback }; generateStaticParams devuelve directamente [{ id: '1' }, ...]. Los valores siguen siendo strings.
  2. fallback desaparece. Por defecto un id nuevo se genera bajo demanda, como con 'blocking'.
  3. La página ya no recibe el producto por props: recibe params (una promesa, desde Next.js 15) y lo pide ella. generateMetadata y la página llaman las dos a getProducto(id), pero al estar en caché la API solo recibe una petición.
  4. El botón “Volver” necesita onClick, así que pasa a un componente cliente propio con useRouter de next/navigation. El resto de la ficha se queda en el servidor.

Ficha del producto Portátil Básico 15 servida por el App Router, con precio 499,00 €, 40 en almacén y botón Volver; la etiqueta naranja dice app/

Figura 4. La ficha del producto 3 ya migrada. Visualmente es idéntica; la etiqueta de la cabecera dice qué router la ha servido.

⚠️ Importante: Con Cache Components no se admite export const dynamicParams, así que no hay equivalente directo de fallback: false. Un id fuera de generateStaticParams que acaba en notFound() responde 200 con <meta name="robots" content="noindex"> la primera vez y 404 después (lo comprobamos en el módulo 04). En el laboratorio, /productos/999 respondió 200 en la primera petición. Si necesitas un 404 real desde la primera vez, compruébalo antes en proxy.ts. En Next.js 15, export const dynamicParams = false da el 404 directamente.

5.5 El primer intento: la misma URL en los dos sitios

Si creas app/productos/ y te olvidas de borrar pages/productos/, el build se para:

> Build error occurred
Error: Turbopack build failed with 2 errors:
Error: App Router and Pages Router both match path: /productos
Next.js does not support having both App Router and Pages Router routes matching the same path. Please remove one of the conflicting routes.
Error: App Router and Pages Router both match path: /productos/[id]
Next.js does not support having both App Router and Pages Router routes matching the same path. Please remove one of the conflicting routes.

La solución es mover (o borrar) pages/productos/ en el mismo cambio en que creas app/productos/. Con eso el build pasa y muestra dos tablas, una por router:

Route (app)              Revalidate  Expire
┌ ○ /_not-found
├ ○ /productos                  30d      1y
└   /productos/[id]
  ├ ◐ /productos/[id]
  ├ ○ /productos/1               5m      1y
  ├ ○ /productos/2               5m      1y
  └ ◐ [+3 more paths]

Route (pages)            Revalidate  Expire
┌ ● / (4110 ms)                  1m      1y
├   /_app
├ ○ /404 (4733 ms)
├ ƒ /api/empleados/[id]
├ ƒ /api/revalidar
├ ƒ /empleados
└ ƒ /empleados/[id]

/productos aparece con 30d de revalidación: es el perfil 'max' de cacheLife. Las fichas tienen 5m, los 300 segundos de antes. Fíjate en que app/ ya tiene su propio /_not-found, mientras pages/ sigue usando su /404.

En este momento la aplicación funciona entera: el menú lleva de / (pages) a /productos (app) con una navegación completa, y de /productos a /productos/3 (los dos en app) con navegación de cliente.


6. Paso 2: el resto de páginas

6.1 La portada: ISR con cacheLife

// app/page.tsx: antes pages/index.tsx con getStaticProps + revalidate: 60
import { getEstadisticas } from '@/lib/datos'

export default async function Inicio() {
  const e = await getEstadisticas()
  return (
    <>
      <h1>Panel de EMPRESA</h1>
      <div className="tarjetas">
        <div className="tarjeta"><strong>{e.empleados}</strong>empleados</div>
        <div className="tarjeta"><strong>{e.departamentos}</strong>departamentos</div>
        <div className="tarjeta"><strong>{e.productos}</strong>productos</div>
        <div className="tarjeta"><strong>{e.pedidos}</strong>pedidos</div>
      </div>
      <p className="pie">Página generada: {e.generada}</p>
    </>
  )
}

Tras el build, la portada sale como ○ / 1m 1y, y next start responde con las mismas cabeceras que la versión ISR de pages/:

HTTP/1.1 200 OK
x-nextjs-cache: STALE
Cache-Control: s-maxage=60, stale-while-revalidate=31535940

Para Next.js 15, el equivalente directo (compilado en el proyecto 15.5 de la parte II) es:

// app/page.tsx: ISR al estilo de Next.js 15
import { API, type Estadisticas } from '@/lib/api'

export const revalidate = 60

export default async function Inicio() {
  const res = await fetch(`${API}/estadisticas`)
  const e: Estadisticas = await res.json()
  return (
    <>
      <h1>Panel de EMPRESA</h1>
      <p>{e.empleados} empleados, {e.productos} productos, {e.pedidos} pedidos.</p>
      <p className="pie">Generada: {new Date().toISOString()}</p>
    </>
  )
}

6.2 La lista de empleados: SSR con <Suspense> 🔄 16.0

🔄 Cambia en Next.js 16.0. Con Cache Components, leer searchParams o pedir datos sin caché fuera de <Suspense> hace fallar el build; la página queda como ◐ (armazón estático y hueco dinámico). En Next.js 15 la página se vuelve dinámica entera (ƒ) en cuanto lee searchParams, sin necesidad de <Suspense>. Si usas Next.js 15: puedes hacer await searchParams directamente en la página y pedir los datos ahí; <Suspense> es opcional y solo sirve para mostrar el mensaje de carga mientras llegan.

getServerSideProps se ejecutaba en cada petición y la página no se enviaba hasta tener todos los datos. En el App Router con Cache Components, lo que depende de la petición va dentro de <Suspense>: Next envía al momento el armazón (cabecera, título) y el listado llega después por streaming (módulo 08):

// app/empleados/page.tsx: antes getServerSideProps leyendo query
import { Suspense } from 'react'
import Link from 'next/link'
import { getEmpleados } from '@/lib/datos'
import FiltroDepartamento from '@/components/FiltroDepartamento'

export const metadata = { title: 'Empleados' }

export default function Empleados({ searchParams }: PageProps<'/empleados'>) {
  return (
    <>
      <h1>Empleados</h1>
      <Suspense fallback={<p>Cargando empleados...</p>}>
        <Listado searchParams={searchParams} />
      </Suspense>
    </>
  )
}

async function Listado({ searchParams }: Pick<PageProps<'/empleados'>, 'searchParams'>) {
  const { deptId } = await searchParams
  const filtro = typeof deptId === 'string' ? deptId : ''
  const empleados = await getEmpleados(filtro)
  return (
    <>
      <FiltroDepartamento deptId={filtro} />
      <table>
        <thead>
          <tr><th>Nombre</th><th>Oficio</th><th>Departamento</th></tr>
        </thead>
        <tbody>
          {empleados.map((e) => (
            <tr key={e.empId}>
              <td><Link href={`/empleados/${e.empId}`}>{e.nombre} {e.apellidos}</Link></td>
              <td>{e.oficio}</td>
              <td>{e.departamento ?? 'Sin departamento'}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="pie">{empleados.length} empleados</p>
    </>
  )
}

Fíjate en que la página no hace await searchParams: pasa la promesa a Listado, que está dentro de <Suspense>. Con Cache Components, esperar los parámetros arriba de la página bloquearía el armazón entero.

El selector necesita onChange, así que es un componente cliente:

'use client'
// components/FiltroDepartamento.tsx: antes router.push de next/router
import { useRouter } from 'next/navigation'

export default function FiltroDepartamento({ deptId }: { deptId: string }) {
  const router = useRouter()
  return (
    <label>
      Departamento:{' '}
      <select
        value={deptId}
        onChange={(e) =>
          router.push(e.target.value ? `/empleados?deptId=${e.target.value}` : '/empleados')
        }
      >
        <option value="">Todos</option>
        {[10, 20, 30, 40, 50, 60, 70].map((d) => (
          <option key={d} value={d}>{d}</option>
        ))}
      </select>
    </label>
  )
}

router.push de next/router aceptaba un objeto { pathname, query }. El de next/navigation solo acepta una cadena: construye tú la URL (con URLSearchParams si hay varios parámetros).

Página de empleados del App Router, idéntica a la de la figura 1 pero con la etiqueta app/

Figura 5. La lista de empleados migrada, con el mismo filtro. Al cambiar el selector, Next pide solo la carga RSC de la página nueva, no un JSON de /_next/data/.

6.3 La ficha del empleado: cookies sin req ni res

En getServerSideProps teníamos req.cookies para leer y res.setHeader('Set-Cookie', ...) para escribir. En el App Router se lee con cookies() y headers() de next/headers, pero no se puede escribir una cookie durante el render de un Server Component. Si lo intentas:

async function Escribe() {
  ;(await cookies()).set('ultimoEmpleado', '100')
  return <p>escrita</p>
}

la página responde y el servidor registra:

⨯ Error: Cookies can only be modified in a Server Action or Route Handler. Read more: https://nextjs.org/docs/app/api-reference/functions/cookies#options

La razón es el streaming: cuando el componente se ejecuta, las cabeceras HTTP de la respuesta pueden haber salido ya hacia el navegador. Para escribir una cookie tienes tres sitios: una Server Action (módulo 10), un Route Handler (módulo 12) o el proxy (módulo 13), que se ejecuta antes de la página.

Aquí la cookie se escribe en cada visita a una ficha, así que el sitio natural es proxy.ts:

// proxy.ts: escribe la cookie que antes ponía getServerSideProps con res.setHeader
import { NextResponse, type NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const id = request.nextUrl.pathname.split('/')[2]
  if (!id || !/^\d+$/.test(id)) return NextResponse.next()

  // Guardamos el valor ANTERIOR en una cabecera de la petición...
  const cabeceras = new Headers(request.headers)
  cabeceras.set('x-empleado-anterior', request.cookies.get('ultimoEmpleado')?.value ?? '')
  const respuesta = NextResponse.next({ request: { headers: cabeceras } })
  // ...porque la cookie nueva también la verá la página en esta misma petición
  respuesta.cookies.set('ultimoEmpleado', id, { path: '/', httpOnly: true })
  return respuesta
}

export const config = { matcher: '/empleados/:id' }

¿Por qué esa cabecera intermedia? Porque la primera versión que escribimos ponía la cookie en el proxy y la leía con cookies() en la página, y el resultado fue este:

$ curl -b "ultimoEmpleado=100" http://localhost:3001/empleados/101
...Último empleado consultado antes: 101

Esperábamos 100. Cuando el proxy escribe una cookie en NextResponse.next(), Next la añade también a la petición que llega a la página, para que la vea en ese mismo render. Es útil (por ejemplo, para renovar una sesión), pero cambia el significado de nuestro código. Copiando el valor anterior a una cabecera propia antes de escribir, la página recibe lo que necesita:

// app/empleados/[id]/page.tsx: antes getServerSideProps con params, req y res
import { Suspense } from 'react'
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { getEmpleado } from '@/lib/datos'

export async function generateMetadata({ params }: PageProps<'/empleados/[id]'>) {
  const empleado = await getEmpleado((await params).id)
  return { title: empleado ? `${empleado.nombre} ${empleado.apellidos}` : 'Empleado no encontrado' }
}

export default function DetalleEmpleado({ params }: PageProps<'/empleados/[id]'>) {
  return (
    <Suspense fallback={<p>Cargando empleado...</p>}>
      <Ficha params={params} />
    </Suspense>
  )
}

async function Ficha({ params }: Pick<PageProps<'/empleados/[id]'>, 'params'>) {
  const { id } = await params
  const empleado = await getEmpleado(id)
  if (!empleado) notFound()
  // Escribir cookies aquí no se puede: lo hace proxy.ts, que nos deja el valor anterior
  const anterior = (await headers()).get('x-empleado-anterior') || 'ninguno'
  return (
    <>
      <h1>{empleado.nombre} {empleado.apellidos}</h1>
      <p>{empleado.oficio} · {empleado.departamento ?? 'Sin departamento'}</p>
      <p className="pie">Último empleado consultado antes: {anterior}</p>
    </>
  )
}
$ curl -i -b "ultimoEmpleado=100" http://localhost:3001/empleados/101
HTTP/1.1 200 OK
Set-Cookie: ultimoEmpleado=101; Path=/; HttpOnly
...Último empleado consultado antes: 100

$ curl http://localhost:3001/empleados/101
...Último empleado consultado antes: ninguno

Ahora sí se comporta igual que la versión de pages/.

6.4 proxy.ts 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 el fichero se llama middleware.ts y la función exportada middleware; en 16 la convención pasa a proxy.ts con la función proxy, que siempre usa el runtime de Node. Si usas Next.js 15: llama al fichero middleware.ts y a la función middleware; el cuerpo es el mismo, incluida la copia del valor anterior en una cabecera.

El detalle completo está en el módulo 13. En la parte II verás cómo el codemod hace este cambio por ti.

6.5 Las API Routes → Route Handlers 🔄 16.0

🔄 Cambia en Next.js 16.0. revalidateTag exige un segundo argumento con el perfil de cacheLife ('max' para servir lo antiguo mientras se regenera); en Next.js 15 se llamaba con un solo argumento. Si usas Next.js 15: escribe revalidateTag('estadisticas') y, si la página usa export const revalidate, también puedes usar revalidatePath('/').

// app/api/empleados/[id]/route.ts: antes pages/api/empleados/[id].ts
import { API } from '@/lib/api'

export async function GET(_request: Request, ctx: RouteContext<'/api/empleados/[id]'>) {
  const { id } = await ctx.params
  const r = await fetch(`${API}/empleados/${id}`)
  if (!r.ok) return Response.json({ error: 'Empleado no encontrado' }, { status: r.status })
  const { empId, nombre, apellidos, oficio } = await r.json()
  return Response.json(
    { empId, nombre: `${nombre} ${apellidos}`, oficio },
    { headers: { 'Cache-Control': 'no-store' } },
  )
}
// Sin if (req.method !== 'GET'): Next responde 405 a los métodos que no exportas
// app/api/revalidar/route.ts: antes res.revalidate('/')
import { revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  if (request.nextUrl.searchParams.get('secreto') !== process.env.SECRETO_REVALIDAR) {
    return Response.json({ error: 'Secreto incorrecto' }, { status: 401 })
  }
  revalidateTag('estadisticas', 'max')
  return Response.json({ revalidado: true })
}
$ curl -i http://localhost:3001/api/empleados/101
HTTP/1.1 200 OK
cache-control: no-store
{"empId":101,"nombre":"Luis Martínez Ruiz","oficio":"DIRECTOR"}

$ curl -i -X DELETE http://localhost:3001/api/empleados/101
HTTP/1.1 405 Method Not Allowed

$ curl -i -X POST "http://localhost:3001/api/revalidar?secreto=abc"
HTTP/1.1 200 OK
{"revalidado":true}

$ curl -i -X POST "http://localhost:3001/api/revalidar?secreto=mal"
HTTP/1.1 401 Unauthorized
{"error":"Secreto incorrecto"}
API Route Route Handler
export default function handler(req, res) export function GET(request), POST…
if (req.method !== 'GET') y 405 a mano 405 automático para los métodos no exportados
req.query.id await ctx.params (ruta) y request.nextUrl.searchParams (búsqueda)
req.body ya convertido await request.json(), formData(), text()
res.status(200).json(datos) Response.json(datos, { status: 200 })
res.setHeader(...) headers en el Response
res.revalidate('/') revalidateTag(tag, 'max') o revalidatePath('/')

Antes de migrar una API Route, pregúntate si sigue haciendo falta. Muchas existían solo porque en pages/ un componente no podía pedir datos al servidor de forma segura. Si quien la llama es tu propia interfaz, en app/ lo normal es que un Server Component lea los datos directamente o una Server Action haga la escritura (módulo 12, sección 1.1).

6.6 La página 404

// app/not-found.tsx: antes pages/404.tsx
import Link from 'next/link'

export default function NoEncontrado() {
  return (
    <>
      <h1>404: no existe</h1>
      <p><Link href="/">Volver al inicio</Link></p>
    </>
  )
}

not-found.tsx en la raíz de app/ se usa para las URL que no existen y para cada notFound() que no tenga un not-found.tsx más cercano. Lo mismo con pages/_error.tsx, que se reparte en error.tsx por segmento y global-error.tsx para el layout raíz (módulo 14).


7. El resultado final

7.1 La tabla de rutas

Con pages/ vacía, se borran _app.tsx, _document.tsx y la carpeta. El build final:

Route (app)              Revalidate  Expire
┌ ○ /                            1m      1y
├ ○ /_not-found
├ ƒ /api/empleados/[id]
├ ƒ /api/revalidar
├ ◐ /empleados
├   /empleados/[id]
│ └ ◐ /empleados/[id]
├ ○ /productos                  30d      1y
├   /productos/[id]
│ ├ ◐ /productos/[id]
│ ├ ○ /productos/1               5m      1y
│ ├ ○ /productos/2               5m      1y
│ └ ◐ [+3 more paths]


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

Compara con la tabla de la sección 3.5:

Ruta Pages Router App Router
/ ● ISR 1m ○ 1m
/productos ● SSG ○ 30d ('max')
/productos/[id] ● × 5, 5m ○ 1 y 2, ◐ el resto, 5m
/empleados ƒ ◐: armazón estático y listado en streaming
/empleados/[id] ƒ ◐
API ƒ ƒ

La mejora más visible está en las páginas que eran SSR: antes no se enviaba nada hasta tener los datos; ahora la cabecera, el título y el mensaje “Cargando…” salen del armazón estático al instante.

7.2 Qué viaja ahora al navegador

Repetimos la prueba de la sección 3.6 sobre la ficha migrada, buscando el teléfono del empleado:

$ curl -s http://localhost:3001/empleados/101 | grep -c "600100101"
0

No aparece. El componente Ficha recibe el objeto completo del empleado, pero se ejecuta en el servidor y solo envía lo que pinta. Lo que sí viaja al navegador son las props de los componentes cliente (deptId en FiltroDepartamento, euros en Precio): ahí es donde tienes que seguir teniendo cuidado.

7.3 Lista de comprobación de una página migrada

  1. La URL ya no existe en pages/.
  2. No queda ninguna importación de next/head, next/router, next/document ni next/app.
  3. La página es un Server Component; lo interactivo está en componentes 'use client' pequeños.
  4. params y searchParams se usan como promesas.
  5. La caché está declarada donde están los datos ('use cache' en 16, fetch/revalidate en 15).
  6. Lo que depende de la petición está dentro de <Suspense> (Cache Components).
  7. No se escriben cookies ni cabeceras durante el render.
  8. next build pasa y eslint . no da errores.

8. Errores frecuentes al migrar de pages/ a app/

Síntoma Causa Solución
App Router and Pages Router both match path: /productos La ruta existe en los dos routers Borra o mueve la versión de pages/ en el mismo cambio
NextRouter was not mounted useRouter de next/router en app/ Importa de next/navigation
router.query es undefined En next/navigation no existe useParams() y useSearchParams()
router.push({ pathname, query }) no navega bien next/navigation solo acepta cadenas Construye la URL con URLSearchParams
Error de compilación al usar useState, useRouter u onClick en una página copiada de pages/ En app/ la página es un Server Component Saca la parte interactiva a un componente 'use client'
El CSS global no se aplica a las páginas de app/ Solo se importa en _app Impórtalo también en app/layout.tsx
El contexto vale el valor por defecto en app/ El proveedor solo está en _app Móntalo en app/layout.tsx (componente cliente)
Se pierde el estado al ir de una página de app/ a una de pages/ Navegación completa entre routers Migra juntas las páginas relacionadas
Cookies can only be modified in a Server Action or Route Handler cookies().set() durante el render Server Action, Route Handler o proxy.ts
La cookie escrita en el proxy ya se ve en la página NextResponse.next() la añade a la petición Copia el valor anterior en una cabecera antes de escribir
La página copiada con su getServerSideProps no recibe datos Las funciones de datos no existen en app/ Componente async que pide sus datos
next build falla con “uncached or runtime data” (16) Datos de la petición fuera de <Suspense> con Cache Components Mete esa parte en <Suspense> o cachéala con 'use cache'
El id inexistente devuelve 200 con noindex (16) notFound() dentro de <Suspense> o fuera de generateStaticParams Es lo esperado la primera vez; compruébalo antes en proxy.ts si necesitas 404

9. Actualizar de Next.js 15 a 16: el proyecto de partida

9.1 Por qué actualizar y cuándo

Next.js 15 está en Maintenance LTS: solo recibe parches críticos y de seguridad, y la ampliación anunciada termina el 31 de enero de 2027. A partir de ahí, un fallo de seguridad en tu versión no tendrá arreglo. Next.js 16 es la Active LTS y la versión de este curso (módulo 00).

El salto 15 → 16 es una versión mayor: tiene cambios incompatibles. La buena noticia es que casi todos se detectan solos: o los arregla un codemod, o next build falla con un mensaje claro. El trabajo consiste en ir de error en error, en orden.

📘 Concepto: Un codemod es un programa que modifica tu código fuente de forma automática. Next usa jscodeshift: lee cada fichero, lo convierte en un árbol sintáctico, busca un patrón (por ejemplo, una función llamada middleware) y lo reescribe. Es más fiable que buscar y reemplazar texto, pero no es magia: solo arregla los patrones que conoce, y a veces deja código que compila mal. Por eso siempre se revisa la diferencia (git diff) después de ejecutarlo.

9.2 La aplicación de partida

Para esta parte hemos creado un proyecto real con create-next-app@15.5.28 --yes, le hemos añadido ESLint (esa versión no lo instala con --yes) y hemos escrito una pequeña aplicación EMPRESA con todas las piezas que el salto a 16 afecta. Es la misma aplicación de la parte I, ya en App Router, tal como la habría dejado un equipo en 2025:

proyecto/                      (Next.js 15.5.28, React 19.1.0)
├── app/
│   ├── layout.tsx
│   ├── page.tsx               export const revalidate = 60
│   ├── actions.ts             revalidateTag('productos')       ← un argumento
│   ├── productos/
│   │   ├── page.tsx           <Image quality={90}>, fetch con tags
│   │   └── [id]/page.tsx      { params: { id: string } }       ← params síncrono
│   ├── empleados/page.tsx     dynamic = 'force-dynamic', cookies() sin await
│   └── panel/
│       ├── layout.tsx         ruta paralela @resumen
│       ├── page.tsx
│       ├── pedidos/page.tsx
│       └── @resumen/page.tsx  ← sin default.tsx
├── middleware.ts              ← convención antigua
├── eslint.config.mjs          FlatCompat
└── package.json               "lint": "next lint", "build": "next build --turbopack"

Las piezas problemáticas, una a una:

// middleware.ts: la convención de Next.js 15
import { NextResponse, type NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/admin')) {
    return NextResponse.redirect(new URL('/panel', request.url))
  }
  const respuesta = NextResponse.next()
  respuesta.headers.set('x-empresa', 'middleware')
  return respuesta
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
// app/productos/[id]/page.tsx: params usado de forma síncrona (compatibilidad de Next.js 15)
import { notFound } from 'next/navigation'
import { API, type Producto } from '@/lib/api'

export function generateStaticParams() {
  return [{ id: '1' }, { id: '2' }, { id: '3' }]
}

export default async function DetalleProducto({ params }: { params: { id: string } }) {
  const res = await fetch(`${API}/productos/${params.id}`)
  if (res.status === 404) notFound()
  const producto: Producto = await res.json()
  return (
    <>
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
    </>
  )
}
// app/empleados/page.tsx: lo que dejó el codemod de Next.js 15 cuando no pudo usar await
import { cookies, type UnsafeUnwrappedCookies } from 'next/headers'
import { API, type Empleado, type Pagina } from '@/lib/api'

export const dynamic = 'force-dynamic'

export default async function Empleados({
  searchParams,
}: {
  searchParams: Promise<{ deptId?: string }>
}) {
  const { deptId } = await searchParams
  const orden = (cookies() as unknown as UnsafeUnwrappedCookies).get('orden')?.value ?? 'nombre'
  const res = await fetch(deptId ? `${API}/empleados?deptId=${deptId}` : `${API}/empleados`)
  const { contenido }: Pagina<Empleado> = await res.json()
  return (
    <>
      <h1>Empleados</h1>
      <p className="pie">Orden: {orden}</p>
      <ul>
        {contenido.map((e) => (
          <li key={e.empId}>{e.nombre} {e.apellidos} ({e.departamento ?? 'sin departamento'})</li>
        ))}
      </ul>
    </>
  )
}

El cast a UnsafeUnwrappedCookies no lo hemos inventado: es lo que el codemod de Next.js 14 → 15 escribía cuando no podía añadir await (por ejemplo, en una función que no era async). Muchos proyectos reales lo arrastran todavía.

// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function refrescarCatalogo() {
  revalidateTag('productos')
}
// app/productos/page.tsx: caché de fetch con etiqueta (Next.js 15)
import Image from 'next/image'
import Link from 'next/link'
import { API, type Pagina, type Producto } from '@/lib/api'
import { refrescarCatalogo } from '../actions'

export default async function Productos() {
  const res = await fetch(`${API}/productos`, {
    cache: 'force-cache',
    next: { tags: ['productos'] },
  })
  const { contenido }: Pagina<Producto> = await res.json()
  return (
    <>
      <h1>Productos</h1>
      <Image src="/escaparate.png" alt="Escaparate de la tienda" width={450} height={280} quality={90} />
      <form action={refrescarCatalogo}>
        <button>Refrescar catálogo</button>
      </form>
      <ul>
        {contenido.map((p) => (
          <li key={p.productoId}>
            <Link href={`/productos/${p.productoId}`}>{p.nombre}</Link>
          </li>
        ))}
      </ul>
    </>
  )
}
// app/panel/layout.tsx: ruta paralela @resumen junto a children
import Link from 'next/link'

export default function PanelLayout({
  children,
  resumen,
}: {
  children: React.ReactNode
  resumen: React.ReactNode
}) {
  return (
    <div>
      <nav>
        <Link href="/panel">Panel</Link> · <Link href="/panel/pedidos">Pedidos</Link>
      </nav>
      <aside className="tarjeta">{resumen}</aside>
      <section>{children}</section>
    </div>
  )
}

app/panel/@resumen/page.tsx solo devuelve <p>24 empleados · 24 pedidos</p>, y no hay @resumen/default.tsx.

La configuración de ESLint es la que generaba create-next-app 15 con ESLint, basada en FlatCompat:

// eslint.config.mjs (Next.js 15)
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
  baseDirectory: __dirname,
});

const eslintConfig = [
  ...compat.extends("next/core-web-vitals", "next/typescript"),
  {
    ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"],
  },
];

export default eslintConfig;

9.3 Cómo funciona en Next.js 15

Antes de tocar nada, comprobamos que todo funciona. next lint ya avisa de lo que viene:

$ npm run lint
> next lint

`next lint` is deprecated and will be removed in Next.js 16.
For new projects, use create-next-app to choose your preferred linter.
For existing projects, migrate to the ESLint CLI:
npx @next/codemod@canary next-lint-to-eslint-cli .

✔ No ESLint warnings or errors

Y next build pasa, con la tabla de rutas que muestra tamaños de JavaScript:

   ▲ Next.js 15.5.28 (Turbopack)
 ✓ Compiled successfully in 23.7s
   Linting and checking validity of types ...
...
Route (app)                         Size  First Load JS  Revalidate  Expire
┌ ○ /                                0 B         118 kB          1m      1y
├ ○ /_not-found                      0 B         118 kB
├ ƒ /empleados                       0 B         118 kB
├ ○ /panel                           0 B         118 kB
├ ○ /panel/pedidos                   0 B         118 kB
├ ○ /productos                    5.3 kB         123 kB
└ ● /productos/[id]                  0 B         118 kB
    ├ /productos/1
    ├ /productos/2
    └ /productos/3
+ First Load JS shared by all     119 kB
...
ƒ Middleware                     39.3 kB

Fíjate en dos cosas: Next.js 15.5 acepta el tipo síncrono { params: { id: string } } sin error de TypeScript, y /productos/7 (fuera de generateStaticParams) funciona porque params.id se lee sin await gracias a la compatibilidad temporal. Con next start:

GET /productos/1                                 → <h1>Portátil UltraBook 14</h1>
GET /productos/7                                 → <h1>Teclado mecánico</h1>
GET /_next/image?url=%2Fescaparate.png&w=640&q=90 → 200
GET /admin                                        → 307, location: http://localhost:5524/panel
GET /panel                                        → 200, x-empresa: middleware
GET /panel/pedidos                                → 404

La última línea es un error que el proyecto ya tenía sin saberlo: al abrir /panel/pedidos directamente (o recargar), el slot @resumen no tiene página para esa URL ni default.tsx, y Next responde 404. Navegando con <Link> desde /panel sí funciona, porque el slot conserva lo que mostraba. Next.js 16 convierte este fallo silencioso en un error de compilación, como veremos.

9.4 El plan

Cinco pasos: punto de partida limpio; codemod upgrade latest; codemod de APIs asíncronas; arreglos a mano guiados por next build y eslint; y, opcional, activar cacheComponents

Figura 6. El orden de la actualización. Los pasos 2 y 3 son automáticos; el 4 lo guían los errores; el 5 es una migración aparte que puedes hacer más tarde.


10. Paso 1: preparar el terreno

  1. Git limpio. Haz commit de todo antes de empezar. Los codemods comprueban el estado de Git: upgrade avisa y sigue (WARNING: Git directory is not clean. Forcibly continuing.), pero los codemods sueltos se niegan a ejecutarse si hay cambios sin guardar:

    But before we continue, please stash or commit your git changes.
    
    You may use the --force flag to override this safety check.
    

    Lo ideal es un commit después de cada paso, para poder ver con git diff qué ha cambiado cada codemod.

  2. Node.js 20.9 o superior. Next.js 16 ya no funciona con Node 18 (módulo 01). Comprueba también la versión del servidor o la imagen Docker de producción (módulo 23).

  3. TypeScript 5.1 o superior y navegadores modernos (Chrome, Edge y Firefox 111+, Safari 16.4+).

  4. Que el proyecto compile en 15. No mezcles errores antiguos con los de la actualización.

💡 Consejo: La guía oficial propone dos atajos más: npx @next/codemod@canary agents-md para que un agente de programación tenga a mano la documentación de la versión instalada, y npx next@canary upgrade --agent=latest para que sea el propio agente quien aplique los cambios y los verifique (módulo 01, sección 10). Aquí lo haremos a mano, porque necesitas entender cada cambio para poder revisar lo que haga un agente.


11. Paso 2: npx @next/codemod@canary upgrade latest

11.1 Ejecutarlo

Desde la carpeta del proyecto:

npx @next/codemod@canary upgrade latest

upgrade hace dos cosas: actualiza las dependencias (next, react, react-dom, eslint-config-next y los tipos) y ejecuta los codemods recomendados para el salto. Cuando no hay un terminal interactivo (un agente, CI) acepta todas las opciones por defecto; desde un terminal te pregunta qué codemods aplicar.

Esta es la salida real (resumida en las partes repetitivas):

  Running in non-interactive mode. Every prompt will accept its default.
  Detected installed versions:
  - React: v19.1.0
  - Next.js: v15.5.28
  Applying all codemods recommended for your upgrade:
    - middleware-to-proxy
    - remove-unstable-prefix
    - remove-experimental-ppr
    - cache-components-instant-false
    - remove-partial-prefetch
Upgrading your project to Next.js 16.4.4...

dependencies:
- next 15.5.28
+ next 16.4.4
- react 19.1.0
+ react 19.3.1
- react-dom 19.1.0
+ react-dom 19.3.1

devDependencies:
- eslint-config-next 15.5.28
+ eslint-config-next 16.4.4

WARNING: Git directory is not clean. Forcibly continuing.
Executing command: jscodeshift --parser=tsx ... transforms\middleware-to-proxy.js ...
Results:
0 errors
15 unmodified
1 skipped
0 ok
...
Executing command: jscodeshift --parser=tsx ... transforms\cache-components-instant-false.js ...
Results:
0 errors
7 unmodified
0 skipped
9 ok
...
✔ Codemods have been applied successfully.

⚠️ Importante: El codemod instala las dependencias con el gestor de paquetes que lo lanza. Ejecutado con npx, lanzó npm install; ejecutado con pnpm dlx @next/codemod@canary upgrade latest, usó pnpm. Si tu proyecto usa pnpm o Yarn, lánzalo con su equivalente (pnpm dlx, yarn dlx) para no acabar con dos ficheros de bloqueo.

11.2 Qué ha cambiado

package.json: versiones nuevas (next 16.4.4, la última publicada, y React 19.3.1) y una sección para fijar los tipos de React:

   "dependencies": {
-    "react": "19.1.0",
-    "react-dom": "19.1.0",
-    "next": "15.5.28"
+    "react": "19.3.1",
+    "react-dom": "19.3.1",
+    "next": "16.4.4"
   },
   "devDependencies": {
...
-    "eslint": "^9",
-    "eslint-config-next": "15.5.28",
+    "eslint": "9.39.5",
+    "eslint-config-next": "16.4.4",
     "@eslint/eslintrc": "^3"
+  },
+  "pnpm": {
+    "overrides": {
+      "@types/react": "19.3.0",
+      "@types/react-dom": "19.3.0"
+    }
   }

middleware.ts → proxy.ts: el fichero cambia de nombre y la función también. El “1 skipped” del registro es este fichero, que se trata aparte con un renombrado:

// proxy.ts (antes middleware.ts)
import { NextResponse, type NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/admin')) {
    return NextResponse.redirect(new URL('/panel', request.url))
  }
  const respuesta = NextResponse.next()
  respuesta.headers.set('x-empresa', 'middleware')
  return respuesta
}

El comentario de la primera línea (// middleware.ts: la convención de Next.js 15) se queda como estaba: el codemod no toca comentarios.

export const instant = false en nueve ficheros: el codemod cache-components-instant-false añadió esto a cada page y layout:

+// @next-codemod-ignore Cache Components adoption: this segment temporarily allows blocking.
+// Remove this opt-out after verifying the segment passes validation without it.
+// See: https://nextjs.org/docs/app/guides/migrating-to-cache-components
+export const instant = false;
+
 export const revalidate = 60

Es la preparación para Cache Components (paso 5): marca todas las rutas como “todavía sin revisar”. Lo veremos en la sección 14.

11.3 Lo que el codemod no ha hecho

Compara con lo que promete la documentación. En nuestra ejecución (@next/codemod 16.5.0-canary.36):

  • No migró next lint. El codemod next-lint-to-eslint-cli no estaba en la lista; el script sigue siendo "lint": "next lint".
  • No quitó --turbopack de los scripts dev y build. No rompe nada (en 16 Turbopack es el predeterminado), pero sobra.
  • No tocó params, cookies() ni revalidateTag. El primero es tarea del paso 3; el último, manual.
  • No activó cacheComponents, pero sí añadió instant = false, que lo necesita.

El resultado es que, recién actualizado, el proyecto no compila:

$ npm run build
Error: Route segment config "instant" requires `nextConfig.cacheComponents` to be enabled.
> 4 | export const instant = false;
    |              ^^^^^^^
...
./app/productos/[id]/page.tsx:8:14
Error: Route segment config "instant" requires `nextConfig.cacheComponents` to be enabled.
...
./app/productos/page.tsx:10:14
Error: Route segment config "instant" requires `nextConfig.cacheComponents` to be enabled.

Tienes dos salidas:

  1. Actualizar primero, Cache Components después (lo que recomendamos): borra los bloques instant = false y sigue con el paso 3. Así separas dos migraciones que no tienen nada que ver.
  2. Activar ya cacheComponents: true, y entonces instant = false cumple su función de dejar pasar las rutas sin revisar.

Nosotros elegimos la primera. Los bloques empiezan por el comentario @next-codemod-ignore, así que son fáciles de localizar y quitar.

⚠️ Importante: Los codemods de @canary cambian casi cada día. Puede que, cuando ejecutes este paso, la lista de codemods sea otra y el resultado no coincida exactamente con el nuestro. El método no cambia: ejecuta, mira git diff, compila, y arregla lo que falle.


12. Paso 3: el codemod de APIs asíncronas 🔄 16.0

🔄 Cambia en Next.js 16.0. Next.js 15 aceptaba params, searchParams, cookies(), headers() y draftMode() de forma síncrona por compatibilidad (con avisos); Next.js 16 solo las admite con await. Si usas Next.js 15: el mismo codemod (npx @next/codemod@latest next-async-request-api .) ya funciona en 15; aplícalo allí antes de actualizar para que la versión 16 no te sorprenda.

npx @next/codemod@canary next-async-request-api . --force

Usamos --force porque no hemos hecho commit del paso anterior; en un proyecto real, haz el commit y ejecútalo sin la opción.

Processing 16 files...
Results:
0 errors
14 unmodified
0 skipped
2 ok

Las dos modificaciones:

--- a/app/productos/[id]/page.tsx
+++ b/app/productos/[id]/page.tsx
-export default async function DetalleProducto({ params }: { params: { id: string } }) {
+export default async function DetalleProducto(props: { params: Promise<{ id: string }> }) {
+  const params = await props.params;
   const res = await fetch(`${API}/productos/${params.id}`)
--- a/app/empleados/page.tsx
+++ b/app/empleados/page.tsx
-  const orden = (cookies() as unknown as UnsafeUnwrappedCookies).get('orden')?.value ?? 'nombre'
+  const orden = (await cookies() as unknown as UnsafeUnwrappedCookies).get('orden')?.value ?? 'nombre'

La primera es perfecta: cambia el tipo a una promesa y añade el await al principio. La segunda es incompleta: añade await pero deja el cast a UnsafeUnwrappedCookies, un tipo que en Next.js 16 ya no existe. El codemod no sabe que ese cast era un parche del pasado; lo verás en el paso siguiente como error de TypeScript.

💡 Consejo: Para los tipos, en lugar de escribir { params: Promise<{ id: string }> } a mano, usa los tipos globales PageProps<'/productos/[id]'>, LayoutProps y RouteContext que genera Next (existen desde 15.5; también con npx next typegen). Si cambias el nombre de la carpeta, TypeScript te avisa (módulo 04).


13. Paso 4: arreglar a mano, error a error

Con los bloques instant quitados, ejecutamos lint y build y vamos resolviendo en el orden en que aparecen.

13.1 next lint ya no existe 🔄 16.0

🔄 Cambia en Next.js 16.0. next lint se ha eliminado y next build ya no ejecuta el linter. En Next.js 15 next lint funcionaba (con aviso de obsoleto) y next build lo ejecutaba en la fase “Linting and checking validity of types”. Si usas Next.js 15: puedes seguir con next lint, pero mejor migra ya a eslint . con npx @next/codemod@canary next-lint-to-eslint-cli ., que también funciona en 15.

$ npm run lint
> next lint

Invalid project directory provided, no such directory: C:\...\proyecto\lint

El mensaje despista: como lint ya no es una orden de next, la interpreta como el nombre de una carpeta (next [directorio] es lo mismo que next dev [directorio]). El arreglo es el codemod que el paso 2 no ejecutó:

npx @next/codemod@canary next-lint-to-eslint-cli . --force
Migrating from next lint to the ESLint CLI...
   Found existing ESLint Flat config: eslint.config.mjs
   Updated eslint.config.mjs to use direct eslint-config-next imports
   Next.js ESLint configs already present in flat config
   Updated script "lint": "next lint" → "eslint ."
Updated package.json scripts and dependencies

Migration complete! Your project now uses the ESLint CLI.
+import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
+import nextTypescript from "eslint-config-next/typescript";
 import { dirname } from "path";
 import { fileURLToPath } from "url";
-import { FlatCompat } from "@eslint/eslintrc";
...
-const eslintConfig = [
-  ...compat.extends("next/core-web-vitals", "next/typescript"),
-  {
-    ignores: [...],
-  },
-];
+const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
+  ignores: [...],
+}];

Además quita @eslint/eslintrc de package.json. El nuevo npm run lint funciona, pero el propio codemod deja un aviso:

eslint.config.mjs
  7:7  warning  '__dirname' is assigned a value but never used  @typescript-eslint/no-unused-vars
✖ 1 problem (0 errors, 1 warning)

Las líneas de dirname, fileURLToPath, __filename y __dirname ya no hacen falta: bórralas. El fichero final queda como el de create-next-app 16 (módulo 01, sección 9):

import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";

const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
  ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"],
}];

export default eslintConfig;

⚠️ Importante: Como next build ya no ejecuta ESLint, añade npm run lint a tu integración continua. Si no, los errores de lint que antes paraban el despliegue pasarán sin que nadie los vea.

13.2 Rutas paralelas sin default.tsx 🔄 16.0

🔄 Cambia en Next.js 16.0. Todos los slots de rutas paralelas necesitan un default.tsx para las URL en las que no tienen página; sin él, next build falla. En Next.js 15 compilaba y la URL respondía 404 al abrirla directamente. Si usas Next.js 15: añade igualmente default.tsx a cada slot; arregla ese 404 y te ahorra el error al actualizar.

⚠ Strict route matching is enabled by default. This validation indicates a bug in your app's route structure, but you can temporarily restore loose route matching by setting `deprecated.looseRouteMatching` to `true` in your Next.js config.

> Build error occurred
Error: Turbopack build failed with 2 errors:
./app/panel/layout.tsx
Error: Parallel route slots cannot render the same URLs
The following layouts have parallel route slots that cannot render the same URLs:
app/panel/layout.tsx
- /panel/pedidos is missing a matching page or default.tsx in @resumen

Every URL matched by one slot must have a matching page or default.tsx in every sibling slot.


./app/panel/pedidos/page.tsx
Error: Unmatched app pages
The following page files do not match any complete route:
- app/panel/pedidos/page.tsx

El mensaje dice exactamente lo que falta: /panel/pedidos existe en el slot children (app/panel/pedidos/page.tsx) pero no en @resumen. El aviso de arriba ofrece una salida temporal (deprecated.looseRouteMatching), pero lo correcto es decidir qué pinta @resumen en esa URL. Aquí queremos el mismo resumen:

// app/panel/@resumen/default.tsx: qué pinta @resumen en las subrutas sin página propia
export { default } from './page'

Si no quisieras mostrar nada, export default function Default() { return null }. Ahora /panel/pedidos responde 200 con el resumen, también al recargar:

Página de pedidos del panel con el menú Panel · Pedidos, la tarjeta del resumen con 24 empleados y 24 pedidos, y el título Pedidos

Figura 7. /panel/pedidos tras añadir default.tsx. En Next.js 15 esta misma URL, abierta directamente, daba 404.

Las rutas paralelas y default.tsx se explican a fondo en el módulo 15.

13.3 Errores de TypeScript: revalidateTag y UnsafeUnwrappedCookies 🔄 16.0

🔄 Cambia en Next.js 16.0. revalidateTag exige un segundo argumento con un perfil de cacheLife ('max' en casi todos los casos), y desaparecen los tipos UnsafeUnwrapped* de next/headers. En Next.js 15 revalidateTag('productos') era correcto y los tipos existían. Si usas Next.js 15: sigue con revalidateTag('productos'); y cambia ya los casts UnsafeUnwrappedCookies por await cookies(), que funciona igual en 15.

La siguiente compilación llega a la fase de tipos:

  Running TypeScript ...

  We detected TypeScript in your project and reconfigured your tsconfig.json file for you.
  ...
  The following mandatory changes were made to your tsconfig.json:

  	- jsx was set to react-jsx (next.js uses the React automatic runtime)

app/actions.ts(5,3): error TS2554: Expected 2 arguments, but got 1.
app/empleados/page.tsx(2,24): error TS2305: Module '"next/headers.js"' has no exported member 'UnsafeUnwrappedCookies'.
Failed to type check.

Antes de los errores, Next ha modificado tsconfig.json por su cuenta ("jsx": "react-jsx", en 15 era "preserve"). Es un cambio esperado; inclúyelo en el commit.

Los dos arreglos:

// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function refrescarCatalogo() {
  revalidateTag('productos', 'max')
}
// app/empleados/page.tsx
import { cookies } from 'next/headers'
...
  const orden = (await cookies()).get('orden')?.value ?? 'nombre'

¿Por qué 'max'? Con el perfil, revalidateTag marca los datos como caducados y la siguiente visita recibe todavía la versión antigua mientras se genera la nueva (stale-while-revalidate). Si lo que quieres es que quien acaba de guardar vea sus cambios en la respuesta, en una Server Action usa updateTag('productos') (módulo 10, sección 5).

13.4 next/image: la calidad 90 ya no está permitida 🔄 16.0

🔄 Cambia en Next.js 16.0. images.qualities vale por defecto [75]: cualquier otra calidad se redondea a 75 en el HTML y, pedida directamente, da 400. En Next.js 15 sin qualities se aceptaba cualquier valor de 1 a 100. Si usas Next.js 15: no necesitas nada; si quieres limitar las calidades (y evitar que te pidan miles de variantes), declara ya images: { qualities: [75, 90] }.

Este no da error de compilación: pasa en silencio. Con el proyecto compilado y next start:

$ curl -s http://localhost:5524/productos | grep -o 'src="/_next/image[^"]*"'
src="/_next/image?url=%2Fescaparate.png&amp;w=1080&amp;q=75"

$ curl -i "http://localhost:5524/_next/image?url=%2Fescaparate.png&w=640&q=90"
HTTP/1.1 400 Bad Request
"q" parameter (quality) of 90 is not allowed

El componente pide quality={90}, pero el HTML lleva q=75. Para recuperar la calidad 90, decláralo en next.config.ts:

const nextConfig: NextConfig = {
  images: {
    qualities: [75, 90],
  },
}

Tras recompilar, el src vuelve a llevar q=90 y la petición directa responde 200. El resto de cambios de next/image (caché de 4 horas, sin el ancho 16, bloqueo de IP locales, tres redirecciones como mucho, localPatterns con búsqueda) se explican en el módulo 07, sección 8. Revísalos en tu proyecto: ninguno da error al compilar, solo al servir la imagen.

13.5 El resultado

$ npm run build
▲ Next.js 16.4.4 (Turbopack)
✓ Compiled successfully in 3.5s
  Running TypeScript ...
  Finished TypeScript in 8.8s ...
...
Route (app)           Revalidate  Expire
┌ ○ /                         1m      1y
├ ○ /_not-found
├ ƒ /empleados
├ ○ /panel
├ ○ /panel/pedidos
├ ○ /productos
└   /productos/[id]
  ├ ● /productos/1
  ├ ● /productos/2
  └ ● /productos/3


ƒ Proxy (Middleware)

Diferencias con la tabla de 15: no hay columnas de tamaño (Next.js 16 ya no las calcula), el middleware aparece como ƒ Proxy (Middleware) y ya no hay fase de lint. Las rutas son las mismas.

Últimos retoques que no da ningún error:

  • Quita --turbopack de "dev" y "build" en package.json. Si necesitas webpack, la opción ahora es --webpack.
  • Revisa las redirecciones del proxy. En el laboratorio, la misma redirección de /admin respondía location: http://localhost:5524/panel en 15 y location: /panel en 16. Para el navegador es lo mismo, pero si tienes pruebas que comparan la cabecera exacta, fallarán.
  • next dev escribe ahora en .next/dev, así que puedes compilar con el servidor de desarrollo en marcha (módulo 01, sección 6.5).

Con esto el proyecto ya está en Next.js 16, con el mismo modelo de caché que tenía en 15: export const revalidate, fetch con force-cache, dynamic = 'force-dynamic'. Es un punto estable: puedes desplegar aquí y dejar Cache Components para más adelante.


14. Paso 5: migrar a Cache Components 🆕 16.0

🆕 Desde Next.js 16.0. cacheComponents no existe en Next.js 15 (allí había un experimento distinto, experimental.dynamicIO, que 16 elimina). Si usas Next.js 15: quédate con export const revalidate, dynamic y las opciones de fetch (cache, next.revalidate, next.tags); la migración de esta sección se hace después de actualizar a 16.

14.1 Por qué es una migración aparte

Cache Components cambia la regla por defecto: en lugar de “todo es estático salvo que leas algo de la petición”, pasa a ser “nada se guarda en caché salvo lo que marques con 'use cache', y lo que dependa de la petición va dentro de <Suspense>” (módulo 09). Es el modelo recomendado desde 16.4 y será el predeterminado en Next.js 17, pero obliga a revisar cada ruta. Por eso conviene hacerlo después de que la actualización esté desplegada y estable.

14.2 Activarlo

// next.config.ts
const nextConfig: NextConfig = {
  images: {
    qualities: [75, 90],
  },
  cacheComponents: true,
}

Primer aviso:

⚠ `cacheComponents` is enabled without a corresponding `partialPrefetching` option. Set `partialPrefetching` to either `true` or `false`.

The only reason to set `partialPrefetching` to `false` is if you're migrating an older Cache Components app. The initial release of Cache Components did not include Partial Prefetching. New projects should enable both Cache Components and Partial Prefetching.

Añade partialPrefetching: true, como hace create-next-app 16.4 (módulo 03, sección 8.4). Y los primeros errores:

./app/empleados/page.tsx:6:14
Error: Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. Please remove it.
> 6 | export const dynamic = 'force-dynamic'

./app/page.tsx:5:14
Error: Route segment config "revalidate" is not compatible with `nextConfig.cacheComponents`. Please remove it.
> 5 | export const revalidate = 60

14.3 Quitar la configuración de segmento

Si solo borras esas dos líneas, el siguiente build falla en el prerender:

Error: Route "/": Next.js encountered uncached or runtime data during prerendering.
`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience.
Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route

El propio error da las tres salidas. Esta es la tabla de traducción que vamos a aplicar (resumen de la guía oficial Migrating to Cache Components, en node_modules/next/dist/docs/01-app/02-guides/migrating-to-cache-components.md):

Next.js 15 Con Cache Components
export const revalidate = 60 'use cache' + cacheLife({ revalidate: 60 }) o un perfil ('minutes', 'hours'…)
export const revalidate = 0 Borrarlo: sin 'use cache' ya se ejecuta en cada petición
export const dynamic = 'force-dynamic' Borrarlo y meter lo dinámico en <Suspense>
export const dynamic = 'force-static' 'use cache' en lo que deba ser estático
fetch(url, { cache: 'force-cache', next: { revalidate, tags } }) Función 'use cache' con cacheLife y cacheTag; el fetch sin opciones
unstable_cache(fn, claves, { tags }) Función 'use cache' (migrarla es opcional: unstable_cache sigue funcionando)
export const dynamicParams = false No se admite; notFound() para lo que no exista
cookies(), headers(), searchParams arriba de la página Pasarlos a un componente dentro de <Suspense>

Aplicado a nuestro proyecto, la caché pasa a una capa de datos:

// lib/datos.ts: la caché pasa de las opciones de fetch a 'use cache'
import { cacheLife, cacheTag } from 'next/cache'
import { API, type Estadisticas, type Pagina, type Producto } from './api'

export async function getEstadisticas() {
  'use cache'
  cacheLife({ revalidate: 60 }) // antes: export const revalidate = 60
  const res = await fetch(`${API}/estadisticas`)
  const e: Estadisticas = await res.json()
  return { ...e, generada: new Date().toISOString() }
}

export async function getProductos() {
  'use cache'
  cacheTag('productos') // antes: fetch(..., { cache: 'force-cache', next: { tags } })
  const res = await fetch(`${API}/productos`)
  const { contenido }: Pagina<Producto> = await res.json()
  return contenido
}

export async function getProducto(id: string) {
  'use cache'
  cacheTag('productos')
  const res = await fetch(`${API}/productos/${id}`)
  if (res.status === 404) return null
  return (await res.json()) as Producto
}

Fíjate en new Date(): en la versión 15 estaba en la página (<p>Generada: {new Date().toISOString()}</p>). Con Cache Components, leer la hora fuera de una caché o de <Suspense> también hace fallar el prerender, porque daría un valor distinto en cada petición. Dentro de la función 'use cache' se calcula una vez por cada regeneración, que es lo que queríamos.

La portada y el catálogo solo cambian para llamar a estas funciones:

// app/page.tsx: con Cache Components
import { getEstadisticas } from '@/lib/datos'

export default async function Inicio() {
  const e = await getEstadisticas()
  return (
    <>
      <h1>Panel de EMPRESA</h1>
      <p>{e.empleados} empleados, {e.productos} productos, {e.pedidos} pedidos.</p>
      <p className="pie">Generada: {e.generada}</p>
    </>
  )
}
// app/productos/[id]/page.tsx: con Cache Components
import { notFound } from 'next/navigation'
import { getProducto } from '@/lib/datos'

export function generateStaticParams() {
  return [{ id: '1' }, { id: '2' }, { id: '3' }]
}

export default async function DetalleProducto({ params }: PageProps<'/productos/[id]'>) {
  const { id } = await params
  const producto = await getProducto(id)
  if (!producto) notFound()
  return (
    <>
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
    </>
  )
}

app/productos/page.tsx hace lo mismo con getProductos(). La Server Action refrescarCatalogo no cambia: revalidateTag('productos', 'max') invalida ahora las entradas de 'use cache' marcadas con cacheTag('productos').

La página de empleados, que era force-dynamic, pasa a tener un armazón estático y la parte dependiente de la petición en <Suspense>:

// app/empleados/page.tsx: sin dynamic; lo que depende de la petición, en <Suspense>
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { API, type Empleado, type Pagina } from '@/lib/api'

export default function Empleados({ searchParams }: PageProps<'/empleados'>) {
  return (
    <>
      <h1>Empleados</h1>
      <Suspense fallback={<p>Cargando empleados...</p>}>
        <Listado searchParams={searchParams} />
      </Suspense>
    </>
  )
}

async function Listado({ searchParams }: Pick<PageProps<'/empleados'>, 'searchParams'>) {
  const { deptId } = await searchParams
  const orden = (await cookies()).get('orden')?.value ?? 'nombre'
  const res = await fetch(deptId ? `${API}/empleados?deptId=${deptId}` : `${API}/empleados`)
  const { contenido }: Pagina<Empleado> = await res.json()
  return (
    <>
      <p className="pie">Orden: {orden}</p>
      <ul>
        {contenido.map((e) => (
          <li key={e.empId}>{e.nombre} {e.apellidos} ({e.departamento ?? 'sin departamento'})</li>
        ))}
      </ul>
    </>
  )
}

14.4 El resultado

- Cache Components enabled
- Partial Prefetching enabled
...
Route (app)            Revalidate  Expire
┌ ○ /                          1m      1y
├ ○ /_not-found
├ ◐ /empleados
├ ○ /panel
├ ○ /panel/pedidos
├ ○ /productos                15m      1y
└   /productos/[id]
  ├ ◐ /productos/[id]
  ├ ○ /productos/1            15m      1y
  ├ ○ /productos/2            15m      1y
  └ ○ /productos/3            15m      1y


ƒ Proxy (Middleware)

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

Compara con la tabla de la sección 13.5:

  • /empleados ha pasado de ƒ a ◐: el título se sirve estático y el listado llega por streaming.
  • /productos tenía caché indefinida; ahora sale con 15m, porque 'use cache' sin cacheLife usa el perfil por defecto. Si quieres el comportamiento anterior, añade cacheLife('max') a getProductos(). Este es el tipo de diferencia sutil que hay que revisar ruta a ruta.
  • La portada mantiene su 1m y responde con las mismas cabeceras que antes (x-nextjs-cache: HIT, Cache-Control: s-maxage=60, stale-while-revalidate=...).

npm run lint no da avisos y la cookie orden y el filtro ?deptId=40 siguen funcionando.

⚠️ Importante: La caché de fetch de Next.js 15 (Data Cache) se guardaba en disco y sobrevivía entre despliegues. 'use cache' guarda por defecto en memoria, por instancia y por despliegue. En un servidor propio con un solo proceso apenas lo notarás; en una plataforma serverless con muchas instancias, cada una tendrá su propia caché. Para compartirla, usa 'use cache: remote' o un cache handler (módulo 09, sección 7, y módulo 23).

14.5 Proyectos grandes: migrar por partes con instant = false

En una aplicación con cien rutas no vas a arreglarlas todas en una tarde. Para eso existe lo que el codemod del paso 2 hizo por su cuenta:

  1. Activa cacheComponents: true y partialPrefetching: true.
  2. Quita dynamic, revalidate y fetchCache de todos los segmentos (son errores de compilación y no se pueden aplazar).
  3. Ejecuta npx @next/codemod@canary cache-components-instant-false ./app, que añade export const instant = false a cada page, layout y default que no lo tenga. Con eso, las rutas sin revisar pueden bloquear la carga en vez de fallar.
  4. Arregla lo que siga fallando a pesar de instant = false: llamadas como new Date(), Math.random() o crypto.randomUUID() fuera de caché o de <Suspense> no se pueden aplazar.
  5. Ve quitando instant = false ruta a ruta, resolviendo con 'use cache' o <Suspense> lo que señale el build o el panel de desarrollo.

La guía oficial propone también una skill para agentes de programación, next-cache-components-adoption (npx skills add vercel/next.js --skill next-cache-components-adoption), que automatiza este flujo con una PR por funcionalidad. Las navegaciones instantáneas y instant se explican en los módulos 08 y 15.


15. Todos los cambios de Next.js 15 a 16

Esta tabla reúne los cambios del salto con el apartado del curso donde se explican y quién lo arregla. “Codemod” significa que lo hizo upgrade o un codemod específico en nuestra prueba; “Build” que next build falla con un mensaje claro; “Silencioso” que nada avisa y tienes que buscarlo tú.

Cambio Antes (15) Ahora (16) Cómo lo detectas Dónde se explica
Versión de Node 18.18+ 20.9+ Error al arrancar 01 1.1
TypeScript mínimo Anterior a 5.1 5.1+ Error al compilar 01
Navegadores Más antiguos Chrome/Edge/Firefox 111+, Safari 16.4+ Silencioso 01 1.4
Turbopack Con --turbopack Por defecto (--webpack para volver) Silencioso 01 5.2
Carpeta de next dev .next .next/dev Silencioso 01 6.5
Tabla de next build Con tamaños Sin tamaños Silencioso 01 7.2
next lint Obsoleto Eliminado; next build no hace lint Error al ejecutarlo 13.1; 01 9
middleware.ts Convención normal proxy.ts con proxy(), solo Node Codemod middleware-to-proxy 13 2
skipMiddlewareUrlNormalize   skipProxyUrlNormalize Codemod 13
params, searchParams Promesa, con acceso síncrono temporal Solo promesa Codemod next-async-request-api + Build 04 3
cookies(), headers(), draftMode() Síncronas con aviso Solo await Codemod + Build 12
Tipos UnsafeUnwrapped* Existen Eliminados Build (TypeScript) 13.3
params en opengraph-image, icon, sitemap Objeto Promesa Build 07
revalidateTag(tag) Un argumento revalidateTag(tag, perfil) Build (TypeScript) 09 6.3; 10 5.2
updateTag, refresh No existen Nuevos   09 6.2 y 6.4
unstable_cacheLife, unstable_cacheTag Con prefijo Estables, sin prefijo Codemod remove-unstable-prefix 09
cacheComponents No existe (experimental.dynamicIO) Opcional; predeterminado en 17   14; 09 2
experimental.ppr, experimental_ppr Experimentales Eliminados (los sustituye cacheComponents) Codemod remove-experimental-ppr 02 8
experimental.dynamicIO, useCache Experimentales Eliminados Build 14
dynamic, revalidate, fetchCache de segmento Normales Error con cacheComponents Build 14.2
default.tsx en rutas paralelas Opcional (404 al recargar) Obligatorio Build 13.2; 15
images.qualities Cualquier calidad [75] Silencioso (400 al pedir otra) 13.4; 07 8.1
images.imageSizes Incluye 16 Sin 16 Silencioso 07 8.2
images.minimumCacheTTL 60 s 4 horas Silencioso 07 8.3
Imágenes de IP locales Permitidas Bloqueadas (dangerouslyAllowLocalIP) Error al servir la imagen 07 7.3
Redirecciones de imágenes remotas Sin límite Como mucho 3 Error al servir la imagen 07 7.4
Imágenes locales con ?búsqueda Permitidas Exigen localPatterns.search Error al servir la imagen 07 8.4
priority en next/image Normal Obsoleta; usa preload Silencioso 07 6.3
next/legacy/image, images.domains Obsoletos Obsoletos (avisos) Aviso 07
serverRuntimeConfig, publicRuntimeConfig Existen Eliminados; usa variables de entorno Build 13 7.6
AMP (next/amp) Existe Eliminado Build  
unstable_rootParams Experimental Eliminado Build  
scroll-behavior: smooth Next lo anulaba al navegar Respeta tu CSS (data-scroll-behavior) Silencioso 06 2.4
Sass desde node_modules con ~ Admitido Sin ~ Build 06 5.4
error.tsx Recibe reset Recibe retry (16.3) Silencioso 14 3.2
React 19.1 19.2 o superior (<Activity>; 19.3 en 16.4)   03 9; 15
Precarga Por ruta Por segmentos; Partial Prefetching (16.3) Silencioso 03 8
ESLint FlatCompat Configuración plana directa Codemod next-lint-to-eslint-cli 13.1
Servidor MCP de desarrollo No /_next/mcp   01 10.3

Las novedades de las versiones menores (16.1 a 16.4) no rompen nada al actualizar, pero conviene conocerlas: caché de Turbopack en disco (16.1), Adapter API y AGENTS.md (16.2, módulo 23), navegaciones instantáneas, Partial Prefetching, catchError y retry (16.3), y ensureStatic, prefetch() y navigation() (16.4, módulo 09).


16. Ejercicios

Las soluciones de código se han compilado con Next.js 16.4 y cacheComponents: true, y se han probado con la API EMPRESA en marcha.

Ejercicio 1: Leer código de Pages Router

Te pasan este fichero de un proyecto antiguo. Sin ejecutarlo, responde: ¿cuándo se ejecuta cada función?, ¿qué ve un usuario que pide /clientes/1014 si no estaba en paths?, ¿qué datos del cliente acaban en el HTML?, ¿y cuál sería su URL en app/?

// pages/clientes/[id].tsx
export const getStaticPaths = (async () => ({
  paths: [{ params: { id: '1001' } }, { params: { id: '1002' } }],
  fallback: true,
})) satisfies GetStaticPaths

export const getStaticProps = (async ({ params }) => {
  const r = await fetch(`${API}/clientes/${params!.id}`)
  if (!r.ok) return { notFound: true }
  return { props: { cliente: await r.json() }, revalidate: 600 }
}) satisfies GetStaticProps

export default function Cliente({ cliente }: InferGetStaticPropsType<typeof getStaticProps>) {
  const router = useRouter()
  if (router.isFallback) return <p>Cargando...</p>
  return <h1>{cliente.nombre}</h1>
}
Ver solución
  • getStaticPaths se ejecuta una vez en next build: genera las fichas 1001 y 1002.
  • getStaticProps se ejecuta en el build para esas dos, y después, como mucho cada 600 segundos por ficha (ISR), en segundo plano.
  • Con fallback: true, quien pide /clientes/1014 recibe al momento la página con router.isFallback === true (el “Cargando…”); en el navegador, Next pide los datos a /_next/data/.../clientes/1014.json, pinta la ficha y la guarda para los siguientes visitantes. Si el cliente no existe, notFound: true acaba en 404.
  • En el HTML, dentro de __NEXT_DATA__, va el objeto completo del cliente: NIF, correo, teléfono, límite de crédito y comercial, aunque solo se pinte el nombre. Habría que devolver { cliente: { nombre } }.
  • En app/ sería app/clientes/[id]/page.tsx, con generateStaticParams devolviendo [{ id: '1001' }, { id: '1002' }], una función de datos con 'use cache' y cacheLife({ revalidate: 600 }), y un loading.tsx o <Suspense> en lugar de router.isFallback.

Ejercicio 2: ISR de una hora

Migra esta página a app/, primero con Cache Components y después como lo harías en Next.js 15. Devuelve al componente solo los campos que pinta.

// pages/departamentos.tsx
export const getStaticProps = (async () => {
  const res = await fetch(`${API}/departamentos`)
  const { contenido } = await res.json()
  return { props: { departamentos: contenido }, revalidate: 3600 }
}) satisfies GetStaticProps
Ver solución
// app/departamentos/page.tsx (Next.js 16 con Cache Components)
import { cacheLife, cacheTag } from 'next/cache'
import { API } from '@/lib/api'

type Departamento = { deptId: number; nombre: string; localidad: string }

async function getDepartamentos() {
  'use cache'
  cacheLife('hours')
  cacheTag('departamentos')
  const res = await fetch(`${API}/departamentos`)
  const { contenido }: { contenido: Departamento[] } = await res.json()
  return contenido.map(({ deptId, nombre, localidad }) => ({ deptId, nombre, localidad }))
}

export const metadata = { title: 'Departamentos' }

export default async function Departamentos() {
  const departamentos = await getDepartamentos()
  return (
    <ul>
      {departamentos.map((d) => (
        <li key={d.deptId}>{d.nombre} ({d.localidad})</li>
      ))}
    </ul>
  )
}

next build la muestra como ○ /departamentos 1h 1d: el perfil 'hours' revalida cada hora. Si necesitas exactamente 3600 segundos, cacheLife({ revalidate: 3600 }).

En Next.js 15 quitas 'use cache', cacheLife y cacheTag de la función y añades a la página:

export const revalidate = 3600

Lo de recortar los campos con map ya no es por seguridad (un Server Component no envía el objeto al navegador), sino por memoria: la caché guarda lo que devuelve la función.

Ejercicio 3: fallback: false

Un listado de departamentos tiene fichas en pages/departamentos/[id].tsx con getStaticPaths que devuelve los siete departamentos y fallback: false. Mígralo, y comprueba qué responde /departamentos/80.

Ver solución
// app/departamentos/[id]/page.tsx
import { notFound } from 'next/navigation'
import { cacheLife } from 'next/cache'
import { API } from '@/lib/api'

type Departamento = { deptId: number; nombre: string; localidad: string }

async function getDepartamento(id: string) {
  'use cache'
  cacheLife('days')
  const res = await fetch(`${API}/departamentos/${id}`)
  if (!res.ok) return null
  return (await res.json()) as Departamento
}

export async function generateStaticParams() {
  return ['10', '20', '30', '40', '50', '60', '70'].map((id) => ({ id }))
}

export default async function Departamento({ params }: PageProps<'/departamentos/[id]'>) {
  const { id } = await params
  const d = await getDepartamento(id)
  if (!d) notFound()
  return <h1>{d.nombre} · {d.localidad}</h1>
}

Resultado real con next start:

GET /departamentos/80   → 200 (con <meta name="robots" content="noindex">)
GET /departamentos/80   → 404

Con Cache Components no existe dynamicParams = false, así que el primer acceso a un id desconocido se genera, acaba en notFound() y responde 200 con noindex; a partir de ahí, 404. Para los buscadores es suficiente. Si necesitas un 404 desde la primera petición, rechaza los id desconocidos en proxy.ts.

En Next.js 15 la traducción es directa:

export const dynamicParams = false

y cualquier id fuera de generateStaticParams da 404 siempre.

Ejercicio 4: Una API Route con cuerpo JSON

Migra esta API Route a un Route Handler:

// pages/api/departamentos.ts
export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end()
  const { nombre, localidad } = req.body
  if (typeof nombre !== 'string' || nombre.trim() === '') {
    return res.status(400).json({ error: 'Falta el nombre' })
  }
  res.status(201).json({ nombre: nombre.toUpperCase(), localidad: localidad ?? 'Sin localidad' })
}
Ver solución
// app/api/departamentos/route.ts
export async function POST(request: Request) {
  let datos: { nombre?: unknown; localidad?: unknown }
  try {
    datos = await request.json()
  } catch {
    return Response.json({ error: 'El cuerpo no es JSON' }, { status: 400 })
  }
  if (typeof datos.nombre !== 'string' || datos.nombre.trim() === '') {
    return Response.json({ error: 'Falta el nombre' }, { status: 400 })
  }
  const localidad = typeof datos.localidad === 'string' ? datos.localidad : 'Sin localidad'
  return Response.json({ nombre: datos.nombre.toUpperCase(), localidad }, { status: 201 })
}
$ curl -i -X POST -H "Content-Type: application/json" -d '{"nombre":"Logistica","localidad":"Zaragoza"}' http://localhost:3001/api/departamentos
HTTP/1.1 201 Created
{"nombre":"LOGISTICA","localidad":"Zaragoza"}

$ curl -i -X POST -d 'hola' http://localhost:3001/api/departamentos
HTTP/1.1 400 Bad Request
{"error":"El cuerpo no es JSON"}

$ curl -i http://localhost:3001/api/departamentos
HTTP/1.1 405 Method Not Allowed

Tres diferencias: el 405 lo da Next al no exportar GET; el cuerpo hay que leerlo con request.json(), que lanza si no es JSON (en Pages Router bodyParser lo hacía por ti y respondía 400); y req.body estaba tipado como any, mientras que aquí lo tratamos como unknown y lo validamos. En un proyecto real, valida con Zod (módulo 11).

Ejercicio 5: El patrón getLayout

En Pages Router, para que solo algunas páginas tuvieran un menú propio se usaba este patrón:

// pages/tienda/ofertas.tsx
Ofertas.getLayout = (pagina: ReactElement) => <LayoutTienda>{pagina}</LayoutTienda>

// pages/_app.tsx
const getLayout = Component.getLayout ?? ((pagina) => pagina)
return getLayout(<Component {...pageProps} />)

¿Cómo se hace en app/?

Ver solución

Con un layout anidado: todas las páginas bajo la carpeta lo heredan, sin tocar el raíz.

// app/tienda/layout.tsx
import Link from 'next/link'

export default function LayoutTienda({ children }: LayoutProps<'/tienda'>) {
  return (
    <div className="tienda">
      <nav>
        <Link href="/tienda">Portada</Link> · <Link href="/tienda/ofertas">Ofertas</Link>
      </nav>
      {children}
    </div>
  )
}
// app/tienda/page.tsx
export default function PortadaTienda() {
  return <h1>Tienda</h1>
}

// app/tienda/ofertas/page.tsx
export default function Ofertas() {
  return <h1>Ofertas</h1>
}

El HTML de /tienda/ofertas empieza por el <nav> del layout y sigue con <h1>Ofertas</h1>. Además, al navegar entre /tienda y /tienda/ofertas el layout no se vuelve a renderizar, algo que con getLayout dependía de que todas las páginas devolvieran el mismo componente. Si quieres agrupar páginas bajo un layout sin que la carpeta aparezca en la URL, usa un grupo de rutas (tienda) (módulo 04).

Ejercicio 6: Sustituir next/router

Este componente usa casi todo lo que daba useRouter en Pages Router. Reescríbelo para app/:

import { useEffect } from 'react'
import { useRouter } from 'next/router'

export default function InfoRuta() {
  const router = useRouter()
  const { seccion, pagina = '1' } = router.query
  useEffect(() => {
    const alCambiar = (url: string) => console.log('Ruta visitada:', url)
    router.events.on('routeChangeComplete', alCambiar)
    return () => router.events.off('routeChangeComplete', alCambiar)
  }, [router.events])
  return <p>Sección {seccion}, página {pagina} ({router.asPath})</p>
}
Ver solución
'use client'
// app/ruta/[seccion]/InfoRuta.tsx
import { useEffect } from 'react'
import { useParams, usePathname, useSearchParams } from 'next/navigation'

export default function InfoRuta() {
  const pathname = usePathname() // antes router.asPath (sin la búsqueda)
  const params = useParams<{ seccion: string }>() // antes router.query.seccion
  const busqueda = useSearchParams() // antes router.query.pagina
  const pagina = busqueda.get('pagina') ?? '1'

  // Antes: router.events.on('routeChangeComplete', ...)
  useEffect(() => {
    console.log('Ruta visitada:', pathname, busqueda.toString())
  }, [pathname, busqueda])

  return (
    <p>
      Sección {params.seccion}, página {pagina} ({pathname})
    </p>
  )
}
// app/ruta/[seccion]/page.tsx
import { Suspense } from 'react'
import InfoRuta from './InfoRuta'

export function generateStaticParams() {
  return [{ seccion: 'ventas' }]
}

export default function Ruta() {
  return (
    <Suspense fallback={<p>Cargando...</p>}>
      <InfoRuta />
    </Suspense>
  )
}
  • router.query mezclaba parámetros de ruta y de búsqueda; ahora son useParams y useSearchParams.
  • router.events no existe. Para reaccionar a cada navegación, un useEffect que dependa de pathname y de la búsqueda.
  • useSearchParams obliga a envolver el componente en <Suspense>: la página se prerenderiza sin conocer la búsqueda y el componente se completa en el navegador. Sin él, next build falla (módulo 18).

Ejercicio 7: ¿Quién lo arregla?

Para cada línea de un proyecto en Next.js 15, di si al actualizar a 16 la arregla un codemod, la detecta next build o pasa en silencio, y cómo la dejarías:

  1. export function middleware(request: NextRequest) en middleware.ts
  2. <Image src="/logo.png" quality={100} ... />
  3. revalidateTag('pedidos') en una Server Action
  4. "lint": "next lint" en package.json
  5. const { slug } = params en un page.tsx, con params: { slug: string }
  6. app/@modal/(.)foto/[id]/page.tsx sin app/@modal/default.tsx
  7. export const dynamic = 'force-dynamic', sin activar cacheComponents
Ver solución
  1. Codemod (middleware-to-proxy, dentro de upgrade): renombra fichero y función a proxy.
  2. Silencioso: el HTML pedirá q=75. Añade images: { qualities: [75, 100] }, o mejor, pregúntate si necesitas calidad 100.
  3. Build (TypeScript, Expected 2 arguments, but got 1): revalidateTag('pedidos', 'max'), o updateTag('pedidos') si el usuario debe ver el cambio en la misma respuesta.
  4. Error al ejecutarlo (Invalid project directory provided, ... \lint). En nuestra prueba upgrade no lo migró: ejecuta next-lint-to-eslint-cli y añade npm run lint a la integración continua.
  5. Codemod (next-async-request-api): props: { params: Promise<...> } y const params = await props.params. Mejor aún, tipa con PageProps<'/blog/[slug]'>.
  6. Build (Parallel route slots cannot render the same URLs): crea app/@modal/default.tsx que devuelva null.
  7. Nada: sin cacheComponents sigue funcionando igual en 16. Solo da error cuando actives Cache Components; entonces se borra y lo dinámico se mete en <Suspense>.

Ejercicio 8: De revalidate a Cache Components

Esta página de Next.js 15 tiene ISR de una hora y saluda al usuario con una cookie. Conviértela para que compile con cacheComponents: true:

export const revalidate = 3600

export default async function Clientes() {
  const usuario = (await cookies()).get('usuario')?.value ?? 'invitado'
  const res = await fetch(`${API}/clientes`, { next: { tags: ['clientes'] } })
  const { contenido } = await res.json()
  return (
    <>
      <p>Hola, {usuario}</p>
      <ul>{contenido.map((c: Cliente) => <li key={c.clienteId}>{c.nombre} ({c.ciudad})</li>)}</ul>
    </>
  )
}

¿Por qué, por cierto, este código en Next.js 15 no tenía en realidad ISR?

Ver solución

En Next.js 15, leer cookies() vuelve dinámica toda la página: se renderizaba en cada petición y el revalidate = 3600 solo afectaba a la caché del fetch. Con Cache Components puedes tener las dos cosas: la lista en caché y el saludo dinámico.

// app/clientes/page.tsx
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
import { cookies } from 'next/headers'
import { API } from '@/lib/api'

type Cliente = { clienteId: number; nombre: string; ciudad: string }

async function getClientes() {
  'use cache'
  cacheLife('hours')
  cacheTag('clientes')
  const res = await fetch(`${API}/clientes`)
  const { contenido }: { contenido: Cliente[] } = await res.json()
  return contenido
}

async function Saludo() {
  const usuario = (await cookies()).get('usuario')?.value ?? 'invitado'
  return <p>Hola, {usuario}</p>
}

export default async function Clientes() {
  const clientes = await getClientes()
  return (
    <>
      <Suspense fallback={<p>...</p>}>
        <Saludo />
      </Suspense>
      <ul>
        {clientes.map((c) => (
          <li key={c.clienteId}>{c.nombre} ({c.ciudad})</li>
        ))}
      </ul>
    </>
  )
}

next build la muestra como ◐ /clientes 1h 1d: la lista forma parte del armazón estático (revalidado cada hora) y solo el saludo se calcula en cada petición.

Ejercicio 9 (propuesto): _document y _error con fuente propia

Un proyecto antiguo carga una fuente de Google con un <link> en el <Head> de _document.tsx, añade class="oscuro" al <body> según una cookie desde getInitialProps de _document, y tiene un pages/_error.tsx que envía el error a un servicio de registro. Migra las tres cosas: la fuente con next/font (módulo 06), el tema leyendo la cookie en el layout raíz (piensa qué implica para el armazón estático con Cache Components) y el registro de errores con error.tsx, global-error.tsx e instrumentation.ts (módulo 14).

Ejercicio 10 (propuesto): Actualiza un módulo anterior

Coge el proyecto de cualquier módulo anterior, créalo de nuevo con create-next-app@15.5.28, copia el código adaptándolo a Next.js 15 con las alternativas de las cajas 🔄, y actualízalo a 16 siguiendo los cinco pasos de este módulo. Apunta en una tabla qué hizo cada codemod, qué errores dio next build y qué cambió sin avisar. Compara tu tabla con la de la sección 15.

Ejercicio 11 (propuesto): La revalidación bajo demanda, sin Route Handler

La API Route pages/api/revalidar.ts de la sección 3.7 servía para que un sistema externo regenerara la portada. Si quien revalida es en realidad un botón de tu propio panel de administración, sustituye el Route Handler por una Server Action con updateTag('estadisticas') protegida por sesión (módulo 17), y compara qué ve el administrador tras pulsar el botón con updateTag y con revalidateTag(..., 'max').


Resumen

Concepto API Idea clave
Pages Router pages/ Cada página es un componente cliente; los datos llegan por props desde funciones del servidor
Esqueleto HTML _document.tsx Solo servidor; <Html>, <Main />, <NextScript />. En app/, el layout raíz
Envoltorio común _app.tsx CSS global, proveedores y layout. En app/, layout.tsx con proveedores 'use client'
SSG getStaticProps Se ejecuta en next build. En app/: 'use cache' + cacheLife('max') (en 15, estático por defecto)
ISR revalidate: N Regenera en segundo plano. En app/: cacheLife({ revalidate: N }) (en 15, export const revalidate)
Rutas estáticas getStaticPaths En app/: generateStaticParams; fallback desaparece
SSR getServerSideProps En cada petición. En app/: componente sin caché dentro de <Suspense>
Datos al navegador __NEXT_DATA__ Todo lo que devuelves va en el HTML: filtra las props
Rutas y cabeza next/router, next/head En app/: next/navigation y Metadata API
API Routes handler(req, res) En app/: route.ts con GET, POST… y Request/Response
Cookies res.setHeader('Set-Cookie') En el render solo se leen; se escriben en Actions, handlers o proxy.ts
Convivencia pages/ + app/ Una URL en un solo router; navegación completa entre ellos
Actualizar npx @next/codemod@canary upgrade latest Sube dependencias y aplica codemods; revisa git diff
APIs asíncronas next-async-request-api params, cookies()… con await; revisa los casts que deja
Lint next-lint-to-eslint-cli next lint desaparece y next build no hace lint
Rutas paralelas default.tsx Obligatorio en cada slot en 16
Imágenes images.qualities Por defecto [75]; otras calidades, en silencio a 75
Revalidar por etiqueta revalidateTag(tag, 'max') Segundo argumento obligatorio en 16
Cache Components cacheComponents: true Migración aparte: dynamic/revalidate → 'use cache' + <Suspense>
Migración gradual instant = false Deja pasar las rutas sin revisar mientras las conviertes una a una