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/routery las API Routes - Entender qué hacen de verdad
getStaticProps,getStaticPaths,getServerSidePropsy elrevalidatede 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/yapp/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,revalidatey la caché defetchpor'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 denext 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/aapp/. - 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 ennode_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. Enapp/los componentes son de servidor por defecto (módulo 05) y piden sus propios datos conawait. 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>denext/documentno es el mismo que el<Head>denext/head. El primero solo vale dentro de_documenty 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
_appno 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/headmete etiquetas en el<head>desde cualquier componente. Enapp/lo sustituye la Metadata API (export const metadataygenerateMetadata, módulo 07).useRouterdenext/routerdevuelve 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… Enapp/se reparte en tres hooks denext/navigation:useRouter(solo navegar),usePathnameyuseSearchParams, másuseParams(módulo 03).
⚠️ Importante:
useRouterexiste en los dos paquetes con el mismo nombre. Si enapp/importas el denext/router, el error en ejecución esNextRouter was not mounted. Al migrar, cambia siempre la importación anext/navigationy revisa qué propiedades usaba el código:router.queryyrouter.eventsno 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>
</>
)
}

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é
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:
- Crear
app/layout.tsx(sin tocar_appni_document, que siguen sirviendo apages/). - Mover una sección de la web (por ejemplo, el catálogo), compilar, probar y desplegar.
- Repetir con la siguiente.
- Cuando
pages/se quede vacía, borrar_app,_documenty la carpeta.
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.
_appsolo envuelve apages/yapp/layout.tsxsolo aapp/. 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 deapp/a una depages/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. Paso 1: el layout raíz y el catálogo
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'nicacheLifeestables: un componenteasyncque no lee datos de la petición es estático por defecto, y la caché se controla conexport const revalidatey las opciones defetch. Si usas Next.js 15: quita'use cache',cacheLifeycacheTagdelib/datos.tsy usafetch(url, { cache: 'force-cache', next: { revalidate: 60, tags: ['estadisticas'] } }), oexport const revalidate = 60en 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:
getStaticPathsdevolvía{ paths: [{ params: { id: '1' } }], fallback };generateStaticParamsdevuelve directamente[{ id: '1' }, ...]. Los valores siguen siendo strings.fallbackdesaparece. Por defecto unidnuevo se genera bajo demanda, como con'blocking'.- La página ya no recibe el producto por props: recibe
params(una promesa, desde Next.js 15) y lo pide ella.generateMetadatay la página llaman las dos agetProducto(id), pero al estar en caché la API solo recibe una petición. - El botón “Volver” necesita
onClick, así que pasa a un componente cliente propio conuseRouterdenext/navigation. El resto de la ficha se queda en el servidor.

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 defallback: false. Unidfuera degenerateStaticParamsque acaba ennotFound()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/999respondió 200 en la primera petición. Si necesitas un 404 real desde la primera vez, compruébalo antes enproxy.ts. En Next.js 15,export const dynamicParams = falseda 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
searchParamso 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 leesearchParams, sin necesidad de<Suspense>. Si usas Next.js 15: puedes hacerawait searchParamsdirectamente 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).

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.tsy la función exportadamiddleware; en 16 la convención pasa aproxy.tscon la funciónproxy, que siempre usa el runtime de Node. Si usas Next.js 15: llama al ficheromiddleware.tsy a la funciónmiddleware; 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.
revalidateTagexige un segundo argumento con el perfil decacheLife('max'para servir lo antiguo mientras se regenera); en Next.js 15 se llamaba con un solo argumento. Si usas Next.js 15: escriberevalidateTag('estadisticas')y, si la página usaexport const revalidate, también puedes usarrevalidatePath('/').
// 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
- La URL ya no existe en
pages/. - No queda ninguna importación de
next/head,next/router,next/documentninext/app. - La página es un Server Component; lo interactivo está en componentes
'use client'pequeños. paramsysearchParamsse usan como promesas.- La caché está declarada donde están los datos (
'use cache'en 16,fetch/revalidateen 15). - Lo que depende de la petición está dentro de
<Suspense>(Cache Components). - No se escriben cookies ni cabeceras durante el render.
next buildpasa yeslint .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 llamadamiddleware) 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
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
-
Git limpio. Haz commit de todo antes de empezar. Los codemods comprueban el estado de Git:
upgradeavisa 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 diffqué ha cambiado cada codemod. -
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).
-
TypeScript 5.1 o superior y navegadores modernos (Chrome, Edge y Firefox 111+, Safari 16.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-mdpara que un agente de programación tenga a mano la documentación de la versión instalada, ynpx next@canary upgrade --agent=latestpara 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 conpnpm 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 codemodnext-lint-to-eslint-clino estaba en la lista; el script sigue siendo"lint": "next lint". - No quitó
--turbopackde los scriptsdevybuild. No rompe nada (en 16 Turbopack es el predeterminado), pero sobra. - No tocó
params,cookies()nirevalidateTag. 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:
- Actualizar primero, Cache Components después (lo que recomendamos): borra los bloques
instant = falsey sigue con el paso 3. Así separas dos migraciones que no tienen nada que ver. - Activar ya
cacheComponents: true, y entoncesinstant = falsecumple 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
@canarycambian 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, miragit 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()ydraftMode()de forma síncrona por compatibilidad (con avisos); Next.js 16 solo las admite conawait. 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 globalesPageProps<'/productos/[id]'>,LayoutPropsyRouteContextque genera Next (existen desde 15.5; también connpx 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 lintse ha eliminado ynext buildya no ejecuta el linter. En Next.js 15next lintfuncionaba (con aviso de obsoleto) ynext buildlo ejecutaba en la fase “Linting and checking validity of types”. Si usas Next.js 15: puedes seguir connext lint, pero mejor migra ya aeslint .connpx @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 buildya no ejecuta ESLint, añadenpm run linta 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.tsxpara las URL en las que no tienen página; sin él,next buildfalla. En Next.js 15 compilaba y la URL respondía 404 al abrirla directamente. Si usas Next.js 15: añade igualmentedefault.tsxa 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:

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.
revalidateTagexige un segundo argumento con un perfil decacheLife('max'en casi todos los casos), y desaparecen los tiposUnsafeUnwrapped*denext/headers. En Next.js 15revalidateTag('productos')era correcto y los tipos existían. Si usas Next.js 15: sigue conrevalidateTag('productos'); y cambia ya los castsUnsafeUnwrappedCookiesporawait 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.qualitiesvale por defecto[75]: cualquier otra calidad se redondea a 75 en el HTML y, pedida directamente, da 400. En Next.js 15 sinqualitiesse 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 yaimages: { 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&w=1080&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
--turbopackde"dev"y"build"enpackage.json. Si necesitas webpack, la opción ahora es--webpack. - Revisa las redirecciones del proxy. En el laboratorio, la misma redirección de
/adminrespondíalocation: http://localhost:5524/panelen 15 ylocation: /panelen 16. Para el navegador es lo mismo, pero si tienes pruebas que comparan la cabecera exacta, fallarán. next devescribe 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.
cacheComponentsno existe en Next.js 15 (allí había un experimento distinto,experimental.dynamicIO, que 16 elimina). Si usas Next.js 15: quédate conexport const revalidate,dynamicy las opciones defetch(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:
/empleadosha pasado deƒa◐: el título se sirve estático y el listado llega por streaming./productostenía caché indefinida; ahora sale con15m, porque'use cache'sincacheLifeusa el perfil por defecto. Si quieres el comportamiento anterior, añadecacheLife('max')agetProductos(). Este es el tipo de diferencia sutil que hay que revisar ruta a ruta.- La portada mantiene su
1my 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
fetchde 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:
- Activa
cacheComponents: trueypartialPrefetching: true. - Quita
dynamic,revalidateyfetchCachede todos los segmentos (son errores de compilación y no se pueden aplazar). - Ejecuta
npx @next/codemod@canary cache-components-instant-false ./app, que añadeexport const instant = falsea cadapage,layoutydefaultque no lo tenga. Con eso, las rutas sin revisar pueden bloquear la carga en vez de fallar. - Arregla lo que siga fallando a pesar de
instant = false: llamadas comonew Date(),Math.random()ocrypto.randomUUID()fuera de caché o de<Suspense>no se pueden aplazar. - Ve quitando
instant = falseruta 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
getStaticPathsse ejecuta una vez ennext build: genera las fichas 1001 y 1002.getStaticPropsse 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/1014recibe al momento la página conrouter.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: trueacaba 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íaapp/clientes/[id]/page.tsx, congenerateStaticParamsdevolviendo[{ id: '1001' }, { id: '1002' }], una función de datos con'use cache'ycacheLife({ revalidate: 600 }), y unloading.tsxo<Suspense>en lugar derouter.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.querymezclaba parámetros de ruta y de búsqueda; ahora sonuseParamsyuseSearchParams.router.eventsno existe. Para reaccionar a cada navegación, unuseEffectque dependa depathnamey de la búsqueda.useSearchParamsobliga 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 buildfalla (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:
export function middleware(request: NextRequest)enmiddleware.ts<Image src="/logo.png" quality={100} ... />revalidateTag('pedidos')en una Server Action"lint": "next lint"enpackage.jsonconst { slug } = paramsen unpage.tsx, conparams: { slug: string }app/@modal/(.)foto/[id]/page.tsxsinapp/@modal/default.tsxexport const dynamic = 'force-dynamic', sin activarcacheComponents
Ver solución
- Codemod (
middleware-to-proxy, dentro deupgrade): renombra fichero y función aproxy. - Silencioso: el HTML pedirá
q=75. Añadeimages: { qualities: [75, 100] }, o mejor, pregúntate si necesitas calidad 100. - Build (TypeScript,
Expected 2 arguments, but got 1):revalidateTag('pedidos', 'max'), oupdateTag('pedidos')si el usuario debe ver el cambio en la misma respuesta. - Error al ejecutarlo (
Invalid project directory provided, ... \lint). En nuestra pruebaupgradeno lo migró: ejecutanext-lint-to-eslint-cliy añadenpm run linta la integración continua. - Codemod (
next-async-request-api):props: { params: Promise<...> }yconst params = await props.params. Mejor aún, tipa conPageProps<'/blog/[slug]'>. - Build (
Parallel route slots cannot render the same URLs): creaapp/@modal/default.tsxque devuelvanull. - Nada: sin
cacheComponentssigue 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 |