Módulo 04: Rutas dinámicas y organización

Objetivos del módulo

  • Crear rutas con segmentos dinámicos ([id]), catch-all ([...slug]) y catch-all opcionales ([[...slug]]), y saber qué valor llega en cada caso
  • Leer params y searchParams como lo que son en Next.js 16: promesas que hay que esperar
  • Validar y convertir lo que llega por la URL, que siempre es texto y siempre lo controla el usuario
  • Tipar páginas y layouts con los ayudantes globales PageProps y LayoutProps
  • Prerenderizar rutas dinámicas con generateStaticParams y entender qué pasa con los valores que no están en la lista
  • Combinar rutas dinámicas con Cache Components: cuándo hace falta <Suspense> y qué opciones desaparecen
  • Responder con un 404 de verdad mediante notFound() y ficheros not-found.tsx por segmento
  • Organizar el proyecto con grupos de rutas (grupo), carpetas privadas _carpeta y colocación de ficheros
  • Reconocer los errores de compilación más frecuentes de este tema y saber arreglarlos

💡 Consejo: En el módulo 03 todas las rutas eran fijas: una carpeta, una URL. Las aplicaciones reales necesitan miles de URLs con la misma forma (/productos/1, /productos/2…), y una carpeta por URL es imposible. Este módulo resuelve eso y, de paso, enseña a ordenar la carpeta app/ para que siga siendo manejable cuando crece. Todo el código se ha compilado y ejecutado con Next.js 16.4 y la API EMPRESA; las alternativas para Next.js 15 se han probado con 15.5.


1. Del sistema de carpetas a la URL: repaso rápido

Recuerda la regla del App Router: cada carpeta dentro de app/ es un segmento de la URL, y una carpeta solo se convierte en página si contiene un page.tsx. Así, app/productos/page.tsx responde en /productos.

Hasta ahora todos los segmentos eran estáticos: el nombre de la carpeta se compara letra a letra con la URL. Un segmento dinámico es un hueco: casa con cualquier texto y te entrega ese texto para que lo uses.

Tipo de segmento Carpeta Casa con Valor que recibes
Estático productos Solo productos Nada
Dinámico [id] Exactamente un tramo string
Catch-all [...tema] Uno o más tramos string[]
Catch-all opcional [[...ruta]] Cero o más tramos string[] o undefined
Grupo (tienda) No ocupa sitio en la URL Nada
Privada _lib Nunca casa: no genera rutas Nada

Las dos últimas filas no tienen que ver con la URL, sino con la organización. Las verás en la segunda mitad del módulo.

📘 Concepto: Un segmento es cada trozo de la URL entre barras. En /departamentos/20/empleados/101 hay cuatro: departamentos, 20, empleados y 101. Cada uno se corresponde con una carpeta.

A lo largo del módulo construirás la parte de navegación de la aplicación EMPRESA: el catálogo de la tienda (productos, categorías y ayuda) y la zona de gestión (departamentos, empleados, clientes, pedidos). Los datos salen de la API EMPRESA.

⚠️ Importante: La API EMPRESA (recursos/empresa-api.zip) arranca en http://localhost:3000/api, que es también el puerto por defecto de Next. Arranca la API primero (node server.mjs) y luego Next en otro puerto: npm run dev -- -p 3001. Para no escribir la dirección de la API en cada fichero, el código la lee de una variable de entorno con un valor por defecto:

const API = process.env.API_URL ?? 'http://localhost:3000/api'

Las variables de entorno se tratan a fondo en el módulo 13.


2. Segmentos dinámicos: [id]

2.1. La convención

Para crear un segmento dinámico, pon el nombre de la carpeta entre corchetes. El nombre que elijas es la clave con la que recibirás el valor:

app/
└── productos/
    ├── page.tsx          → /productos
    └── [id]/
        └── page.tsx      → /productos/1, /productos/2, /productos/lo-que-sea

La página recibe una prop llamada params con un objeto que tiene una propiedad por cada segmento dinámico. Esta es la versión mínima:

// app/productos/[id]/page.tsx
export default async function PaginaProducto({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  return <h1>Producto {id}</h1>
}

Fíjate en tres detalles que repetirás en todas las páginas dinámicas:

  1. El componente es async. Es un Server Component, así que puede serlo (lo viste en el módulo 02).
  2. params no es un objeto, sino una promesa de un objeto. Hay que esperarla con await antes de leer id. El apartado 3 explica por qué.
  3. id es un string, aunque en la URL ponga 7. La URL es texto; convertirlo a número es cosa tuya.

Tabla con los tres tipos de segmentos dinámicos, la carpeta, ejemplos de URL y el valor de params en cada caso

Figura 1. Los tres tipos de segmento dinámico. Un segmento [id] casa con un solo tramo; [...tema], con uno o más; [[...ruta]], también con ninguno.

2.2. Ejemplo completo: la ficha de un producto

Una página dinámica real hace siempre lo mismo: lee el parámetro, lo valida, pide los datos y, si no existen, responde con un 404. Antes de escribirla, crea una capa de acceso a datos para no repetir fetch y tipos en cada página. Va en app/_lib/api.ts; el guion bajo hace que la carpeta no genere rutas (apartado 10).

// app/_lib/api.ts
// Capa de acceso a la API EMPRESA. Carpeta privada: no genera rutas.
const API = process.env.API_URL ?? 'http://localhost:3000/api'

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

export type Categoria = {
  categoriaId: number
  nombre: string
  categoriaPadreId: number | null
}

export type Departamento = {
  deptId: number
  nombre: string
  localidad: string
  presupuesto: number
}

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

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

// Devuelve null si la API responde 404; cualquier otro error se lanza
async function pedir<T>(ruta: string): Promise<T | null> {
  const respuesta = await fetch(`${API}${ruta}`)
  if (respuesta.status === 404) return null
  if (!respuesta.ok) throw new Error(`La API respondió ${respuesta.status} en ${ruta}`)
  return respuesta.json() as Promise<T>
}

export async function obtenerProducto(id: number) {
  'use cache'
  return pedir<Producto>(`/productos/${id}`)
}

export async function obtenerProductos() {
  'use cache'
  const pagina = await pedir<Pagina<Producto>>('/productos?tamano=100')
  return pagina?.contenido ?? []
}

export async function buscarProductos(q: string, pagina: number) {
  'use cache'
  const params = new URLSearchParams({ q, pagina: String(pagina), tamano: '5' })
  return (await pedir<Pagina<Producto>>(`/productos?${params}`))!
}

export async function obtenerCategorias() {
  'use cache'
  const pagina = await pedir<Pagina<Categoria>>('/categorias')
  return pagina?.contenido ?? []
}

export async function obtenerDepartamentos() {
  'use cache'
  const pagina = await pedir<Pagina<Departamento>>('/departamentos')
  return pagina?.contenido ?? []
}

export async function obtenerDepartamento(id: number) {
  'use cache'
  return pedir<Departamento>(`/departamentos/${id}`)
}

export async function obtenerEmpleadosDe(deptId: number) {
  'use cache'
  const pagina = await pedir<Pagina<Empleado>>(`/departamentos/${deptId}/empleados`)
  return pagina?.contenido ?? []
}

export async function obtenerEmpleados() {
  'use cache'
  const pagina = await pedir<Pagina<Empleado>>('/empleados?tamano=100')
  return pagina?.contenido ?? []
}

export async function obtenerEmpleado(id: number) {
  'use cache'
  return pedir<Empleado>(`/empleados/${id}`)
}

📘 Concepto: La directiva 'use cache' al principio de una función le dice a Next que guarde su resultado y lo reutilice. Con Cache Components, que es la configuración que genera create-next-app en Next.js 16, una página que pide datos sin caché y fuera de <Suspense> no compila. Por eso todas las funciones de datos de este módulo llevan la directiva. De momento basta con esa idea; el módulo 09 explica la caché con detalle, y el módulo 08, las peticiones de datos en el servidor.

💡 Consejo: Si sigues en Next.js 15, quita las líneas 'use cache': allí la directiva no existe sin una opción experimental, y una página con fetch y generateStaticParams se prerenderiza igualmente en el build. Todo lo demás del fichero es idéntico.

Ahora, la página. Valida el id, pide el producto y llama a notFound() si no existe:

// app/(tienda)/productos/[id]/page.tsx
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { obtenerProducto, obtenerProductos } from '@/app/_lib/api'
import { CopiarEnlace } from './_componentes/CopiarEnlace'

// Se prerenderizan en el build los cinco primeros productos.
// El resto se genera la primera vez que alguien lo visita.
export async function generateStaticParams() {
  const productos = await obtenerProductos()
  return productos.slice(0, 5).map((p) => ({ id: String(p.productoId) }))
}

// Convierte el texto del segmento en número, o corta con un 404
function leerId(texto: string) {
  if (!/^\d+$/.test(texto)) notFound()
  return Number(texto)
}

export async function generateMetadata({ params }: PageProps<'/productos/[id]'>) {
  const { id } = await params
  const producto = await obtenerProducto(leerId(id))
  return { title: producto ? `${producto.nombre} | EMPRESA` : 'Producto no encontrado' }
}

export default async function PaginaProducto({ params }: PageProps<'/productos/[id]'>) {
  const { id } = await params
  const producto = await obtenerProducto(leerId(id))
  if (!producto) notFound()

  return (
    <article className="rounded-lg bg-white p-6 shadow">
      <Link href="/productos" className="text-sm text-indigo-700 hover:underline">← Todos los productos</Link>
      <h1 className="mt-2 text-2xl font-bold text-indigo-900">{producto.nombre}</h1>
      <p className="text-slate-500">{producto.categoria} · Ref. {producto.productoId}</p>
      <p className="mt-4">{producto.descripcion}</p>
      <p className="mt-4 text-3xl font-bold">{producto.precio.toLocaleString('es-ES')} €</p>
      <p className={producto.stock > 0 ? 'text-green-700' : 'text-red-700'}>
        {producto.stock > 0 ? `${producto.stock} unidades disponibles` : 'Agotado'}
      </p>
      <CopiarEnlace />
    </article>
  )
}

La ruta del fichero tiene una carpeta (tienda) entre paréntesis: es un grupo de rutas y no cambia la URL, que sigue siendo /productos/1. Lo verás en el apartado 9. De momento, quédate con lo que hace la página:

  • PageProps<'/productos/[id]'> es un tipo global que Next genera a partir de tus carpetas. Sustituye al { params: Promise<{ id: string }> } escrito a mano (apartado 4).
  • generateStaticParams le dice a Next qué valores de id debe prerenderizar al compilar (apartado 7).
  • generateMetadata también recibe params, y sirve para poner un título distinto en cada producto. Los metadatos se ven en el módulo 07.
  • notFound() corta el render y muestra el not-found.tsx más cercano (apartado 8).

Ficha del producto 1, Portátil UltraBook 14, con precio, stock y un botón para copiar el enlace

Figura 2. /productos/1. La misma página sirve para los veinte productos de la API.

2.3. Lo que llega por la URL siempre es texto (y no es de fiar)

params.id es un string porque la URL es texto. Y lo escribe el usuario: puede poner /productos/abc, /productos/-3, /productos/1e3 o /productos/99999999999999999999. Si conviertes sin mirar, pasan cosas raras:

Number('abc')      // NaN    → /api/productos/NaN
Number('')         // 0
Number('1e3')      // 1000   → muestra el producto 1000 con una URL extraña
Number('007')      // 7      → la misma página en dos URLs distintas
parseInt('12abc')  // 12     → acepta basura al final

Por eso la ficha usa una expresión regular antes de convertir. En el caso práctico la sacarás a una función reutilizable, leerIdNumerico, en app/_lib/validar.ts:

// app/_lib/validar.ts
import { notFound } from 'next/navigation'

// Convierte el texto de un segmento en un entero positivo, o corta con un 404.
// notFound() lanza una excepción especial, así que el tipo de retorno es number.
export function leerIdNumerico(texto: string): number {
  if (!/^[1-9]\d{0,8}$/.test(texto)) notFound()
  return Number(texto)
}

La expresión ^[1-9]\d{0,8}$ exige de 1 a 9 cifras sin ceros a la izquierda. Así /clientes/0042 da 404 en lugar de duplicar la página de /clientes/42, y nunca se envían a la API números que no caben.

⚠️ Importante: Validar el parámetro no es solo cuestión de estética. Ese texto acaba en una URL de la API, en una consulta SQL o en una ruta de fichero. Trátalo como cualquier otra entrada de usuario: valida su forma y, si lo interpolas en una URL que no controlas, usa encodeURIComponent. La seguridad se trata a fondo en el módulo 22.

notFound() no devuelve nada: lanza una excepción especial que Next captura. TypeScript lo sabe (su tipo de retorno es never), y por eso después de if (!producto) notFound() puedes usar producto sin el null.

2.4. Varios segmentos dinámicos en la misma ruta

Puedes anidar tantos segmentos dinámicos como necesites, siempre con nombres distintos. La zona de gestión muestra un empleado dentro de su departamento:

app/(gestion)/departamentos/[deptId]/empleados/[empId]/page.tsx
→ /departamentos/20/empleados/101
→ params = { deptId: '20', empId: '101' }

La página recibe todos los parámetros de la ruta, también los de las carpetas superiores:

// app/(gestion)/departamentos/[deptId]/empleados/[empId]/page.tsx
import { notFound } from 'next/navigation'
import { obtenerEmpleado, obtenerEmpleadosDe } from '@/app/_lib/api'

// Se ejecuta una vez por cada deptId que genera el layout padre,
// y recibe ese deptId ya resuelto (objeto normal, no promesa)
export async function generateStaticParams({
  params,
}: {
  params: Awaited<LayoutProps<'/departamentos/[deptId]'>['params']>
}) {
  const empleados = await obtenerEmpleadosDe(Number(params.deptId))
  return empleados.map((e) => ({ empId: String(e.empId) }))
}

export default async function PaginaEmpleado({ params }: PageProps<'/departamentos/[deptId]/empleados/[empId]'>) {
  const { deptId, empId } = await params
  const empleado = await obtenerEmpleado(Number(empId))

  // El empleado existe, pero ¿pertenece a este departamento?
  if (!empleado || String(empleado.deptId) !== deptId) notFound()

  return (
    <div className="rounded-lg bg-white p-6 shadow">
      <h2 className="text-xl font-bold">{empleado.nombre} {empleado.apellidos}</h2>
      <p>{empleado.oficio} · {empleado.salario.toLocaleString('es-ES')} €/mes</p>
    </div>
  )
}

Mira la comprobación final. Con dos parámetros, la URL puede ser coherente por separado e incoherente en conjunto: /departamentos/10/empleados/101 pide a Luis Martínez, que existe, pero en un departamento que no es el suyo (él está en el 20). Sin esa línea, la página mostraría al empleado bajo la cabecera de DIRECCIÓN. Comprobado con la aplicación en marcha:

/departamentos/20/empleados/101 -> ficha de Luis Martínez Ruiz
/departamentos/10/empleados/101 -> página de «no encontrado»

⚠️ Importante: En este módulo verás que algunas URLs «no encontradas» responden con código 200 en la primera petición y 404 en las siguientes. No es un error de la aplicación: es cómo funciona notFound() con Cache Components en las rutas que no se prerenderizaron. El apartado 8.2 lo explica con mediciones reales. Mientras tanto, cuando leas «da 404», entiende «muestra la página de no encontrado».

💡 Consejo: Cuando una URL anida recursos (/clientes/1001/pedidos/5001), comprueba siempre que el hijo pertenece al padre. Es un error frecuente y, en una zona privada, además es un fallo de seguridad: permite ver datos ajenos cambiando un número.

Ficha del empleado Luis Martínez Ruiz dentro del departamento VENTAS, con el menú lateral de gestión

Figura 3. /departamentos/20/empleados/101. La cabecera VENTAS la pinta el layout de [deptId]; la ficha, la página de [empId].

2.5. Segmentos estáticos y dinámicos en la misma carpeta

¿Qué pasa si tienes productos/[id] y además productos/nuevo? ¿La URL /productos/nuevo va a la ficha con id = 'nuevo'? No: un segmento estático siempre gana a uno dinámico del mismo nivel.

// app/(tienda)/productos/nuevo/page.tsx
// Segmento estático junto a uno dinámico: /productos/nuevo llega aquí, no a [id]
export default function NuevoProducto() {
  return <h1 className="text-2xl font-bold text-indigo-900">Alta de producto</h1>
}
/productos/nuevo -> página «Alta de producto»
/productos/abc   -> página «Ese producto no existe» (la ficha: leerId rechaza 'abc')

Lo que no puedes tener es dos segmentos dinámicos con nombres distintos al mismo nivel, como productos/[id] y productos/[slug]/editar. El servidor no arranca, y lo peor es que el build no avisa (lo verás en el apartado 12.1).


3. params es una promesa 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 params y searchParams ya eran promesas, pero se podían leer de forma síncrona (params.id) por compatibilidad; en 16 ese acceso síncrono desaparece. Si usas Next.js 15: escribe await params exactamente igual que aquí; el código de este módulo compila y funciona sin cambios en 15.5.

3.1. Un poco de historia

En Next.js 13 y 14, params era un objeto normal: params.id y listo. Next.js 15 lo convirtió en promesa, pero dejó una puerta trasera: si accedías como antes, funcionaba y en desarrollo te avisaba. Lo hemos comprobado en Next.js 15.5 con una página que lee params.id sin esperar:

// Solo para comprobar la compatibilidad de Next.js 15
export default function Sincrono({ params }: { params: any }) {
  return <p>id síncrono = {params.id}</p>
}
/sincrono/42 -> 200
id síncrono = 42

En Next.js 16 esa puerta se ha cerrado. params es solo una promesa: params.id es undefined y, con TypeScript, ni siquiera compila. Este es el error real de next build con 16.4:

  Running TypeScript ...
app/productos/[id]/page.tsx(7,31): error TS2339: Property 'id' does not exist on type 'Promise<{ id: string; }>'.
Failed to type check.

Lo mismo vale para searchParams, cookies(), headers() y draftMode(): en Next.js 16 todas estas APIs de petición son exclusivamente asíncronas.

3.2. Por qué una promesa

Parece una complicación gratuita, pero tiene sentido. Next intenta prerenderizar todo lo que puede antes de que llegue la petición (lo viste en el módulo 02). Si params fuera un objeto normal, Next tendría que conocer su valor antes de empezar a renderizar cualquier cosa. Al ser una promesa, puede empezar a renderizar el layout, la cabecera y todo lo que no dependa del parámetro, y esperar al valor solo en el componente que hace await.

Es la misma idea que verás con <Suspense>: lo que no depende de la petición se adelanta; lo que depende, se aplaza al último momento. Cuanto más abajo en el árbol hagas el await, más parte de la página puede salir ya hecha.

📘 Concepto: Un await params en el componente de página «marca» ese componente como dependiente de la URL. Si el valor está en la lista de generateStaticParams, Next lo resuelve en el build. Si no, lo resuelve en la petición. Tu código es el mismo en los dos casos.

3.3. Tres formas de leer params

Dónde Cómo Ejemplo
Server Component (página o layout) await const { id } = await params
Client Component que es una página use() de React const { id } = use(params)
Cualquier Client Component del árbol Hook useParams() const { id } = useParams<{ id: string }>()

La primera es la habitual. La segunda solo aparece si marcas una página entera con 'use client', que rara vez conviene (las fronteras entre servidor y cliente se ven en el módulo 05). Un Client Component no puede ser async, así que usa use, que espera la promesa y suspende mientras tanto. El hook use se explica en el Curso de React.

'use client'

import { use } from 'react'

export default function PaginaCliente({ params }: PageProps<'/productos/[id]'>) {
  const { id } = use(params)
  return <p>Producto {id}</p>
}

La tercera, useParams, es la más práctica para componentes interactivos que viven dentro de la página y no quieres que reciban el parámetro por props. Este botón está colocado junto a la ficha del producto:

// app/(tienda)/productos/[id]/_componentes/CopiarEnlace.tsx
'use client'

import { useParams } from 'next/navigation'
import { useState } from 'react'

// Componente colocado junto a la página que lo usa.
// Lee el segmento dinámico con useParams, sin recibirlo por props.
export function CopiarEnlace() {
  const { id } = useParams<{ id: string }>()
  const [copiado, setCopiado] = useState(false)

  async function copiar() {
    await navigator.clipboard.writeText(`${location.origin}/productos/${id}`)
    setCopiado(true)
  }

  return (
    <button onClick={copiar} className="mt-4 rounded bg-indigo-700 px-4 py-2 text-white hover:bg-indigo-800">
      {copiado ? '¡Enlace copiado!' : `Copiar enlace al producto ${id}`}
    </button>
  )
}

Es el botón que ves en la figura 2: muestra «Copiar enlace al producto 1» porque useParams devuelve { id: '1' }. useParams es síncrono: en el cliente, la URL ya se conoce.

💡 Consejo: useParams devuelve todos los parámetros dinámicos de la ruta actual, no solo los de la carpeta donde está el componente. Eso lo hace útil en layouts, que no reciben los parámetros de sus hijos. Lo usarás en el ejercicio 6 para resaltar el departamento activo en el menú lateral.

3.4. Migrar código antiguo

Si heredas un proyecto de Next.js 14 o un 15 que usaba el acceso síncrono, no tienes que reescribirlo a mano. Next trae un codemod que hace la transformación:

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

La guía oficial de actualización a la 16 lo recomienda expresamente, porque el codemod general upgrade no lo ejecuta. Donde no puede transformar el código, deja comentarios y conversiones UnsafeUnwrapped que debes revisar. La migración completa de 15 a 16, ejecutando los codemods de verdad, es el tema del módulo 24.


4. Tipar páginas y layouts: PageProps y LayoutProps

4.1. El problema de escribir los tipos a mano

El tipo { params: Promise<{ id: string }> } tiene dos defectos: es largo y no está conectado con las carpetas. Si renombras [id] a [productoId], TypeScript no se queja, y id pasa a ser undefined en tiempo de ejecución.

Next resuelve esto generando tipos a partir de la estructura de app/. Desde Next.js 15.5 tienes tres ayudantes globales, que no hace falta importar:

Ayudante Para Contiene
PageProps<'/ruta'> page.tsx params y searchParams, ambos promesas
LayoutProps<'/ruta'> layout.tsx params y children (y los slots de rutas paralelas)
RouteContext<'/ruta'> route.ts params del Route Handler (módulo 12)

El argumento es la ruta tal y como se escribe en carpetas, con los corchetes, pero sin los grupos: la página app/(tienda)/productos/[id]/page.tsx se tipa con PageProps<'/productos/[id]'>. Si escribes una ruta que no existe, TypeScript la marca como error, porque el argumento debe pertenecer a la unión AppRoutes con todas las rutas de la aplicación (y por eso el editor te las ofrece al autocompletar). Este es el error real cuando se borró la carpeta de una ruta y quedó una página que la mencionaba:

error TS2344: Type '"/catalogo/[[...ruta]]"' does not satisfy the constraint 'AppRoutes'.
error TS2339: Property 'ruta' does not exist on type 'unknown'.
export default async function PaginaCatalogo({ params }: PageProps<'/catalogo/[[...ruta]]'>) {
  const { ruta = [] } = await params // ruta: string[] | undefined
  // ...
}

Los tipos que generan para cada clase de segmento son los de la tabla del apartado 1: string, string[] y string[] | undefined. Nunca number: Next no puede saber qué escribirá el usuario.

💡 Consejo: El layout raíz también se puede tipar así: export default function LayoutRaiz({ children }: LayoutProps<'/'>). Es lo que genera create-next-app en la 16.

4.2. Cuándo se generan

Los tipos se generan al ejecutar next dev o next build, y también a mano con:

npx next typegen

Es útil en integración continua, para pasar tsc --noEmit sin compilar la aplicación. Si acabas de crear una carpeta y el editor dice que PageProps<'/nueva/[id]'> no es válido, es que el servidor de desarrollo aún no ha regenerado los tipos: guarda el fichero o lanza next typegen.

En Next.js 15.5 los ayudantes ya existen. Lo hemos comprobado compilando en 15.5 un layout y una página con LayoutProps<'/departamentos/[deptId]'> y PageProps<'/departamentos/[deptId]'>: compilan y funcionan igual.

4.3. Tipar los argumentos de generateStaticParams

generateStaticParams recibe, cuando hay segmentos dinámicos por encima, los parámetros del padre, pero como objeto normal, no como promesa (se ejecuta en el build, cuando ya se conocen). Para tiparlo a partir de los ayudantes, combina Awaited con LayoutProps, como hicimos en la página del empleado:

export async function generateStaticParams({
  params,
}: {
  params: Awaited<LayoutProps<'/departamentos/[deptId]'>['params']>
}) {
  // params.deptId es string, sin await
}

5. searchParams: la parte de la URL tras el ?

5.1. Qué es y qué tipo tiene

params recoge los segmentos de la ruta. Lo que va después del ? (la query string) llega en otra prop, searchParams, que solo reciben las páginas, no los layouts. También es una promesa, y su tipo es más abierto, porque la URL puede traer cualquier cosa:

searchParams: Promise<{ [clave: string]: string | string[] | undefined }>
URL await searchParams
/productos {}
/productos?q=portatil { q: 'portatil' }
/productos?q=portatil&pagina=2 { q: 'portatil', pagina: '2' }
/productos?cat=2&cat=4 { cat: ['2', '4'] }
/productos?q= { q: '' }

La penúltima fila es la que pilla desprevenido a todo el mundo: si un parámetro se repite, llega un array. Un usuario puede escribir esa URL a mano, así que tu código debe aguantarlo. Una función pequeña lo resuelve:

// Un parámetro de búsqueda puede faltar, venir una vez o repetirse
function primero(valor: string | string[] | undefined) {
  return Array.isArray(valor) ? valor[0] : valor
}

📘 Concepto: searchParams es un objeto JavaScript normal, no una instancia de URLSearchParams. No tiene .get(); se lee con la notación de punto o de corchetes. Para construir URLs, en cambio, URLSearchParams es la herramienta adecuada: codifica los caracteres especiales por ti.

5.2. searchParams con Cache Components 🔄 16.0

🔄 Cambia en Next.js 16.0. En Next.js 15 leer searchParams en cualquier sitio de la página la convierte entera en dinámica (ƒ en la tabla del build) y no exige <Suspense>. Con Cache Components, en 16, leerlo fuera de <Suspense> hace fallar el build. Si usas Next.js 15: puedes hacer await searchParams directamente en la página; el <Suspense> de este ejemplo también funciona en 15 y no estorba.

searchParams es, por definición, un dato de la petición: es imposible conocerlo en el build. Con Cache Components, Next prerenderiza todo lo que puede y exige que lo que depende de la petición esté dentro de un <Suspense>, para enviar primero el armazón de la página y luego el resto. Si haces await searchParams en el cuerpo de la página, no hay nada que prerenderizar y el build se detiene. El mensaje es el mismo que con params sin generateStaticParams, y lo verás completo en el apartado 7.5.

La solución es pasar la promesa, sin esperarla, a un componente hijo envuelto en <Suspense>:

// app/(tienda)/productos/page.tsx
import { Suspense } from 'react'
import Link from 'next/link'
import { buscarProductos } from '@/app/_lib/api'
import { TarjetaProducto } from '@/app/_componentes/TarjetaProducto'

// Un parámetro de búsqueda puede faltar, venir una vez o repetirse
function primero(valor: string | string[] | undefined) {
  return Array.isArray(valor) ? valor[0] : valor
}

export default function PaginaProductos({ searchParams }: PageProps<'/productos'>) {
  return (
    <main>
      <h1 className="mb-4 text-2xl font-bold text-indigo-900">Productos</h1>
      <form className="mb-4 flex gap-2">
        <input name="q" placeholder="Buscar..." className="rounded border px-3 py-1" />
        <button className="rounded bg-indigo-700 px-3 py-1 text-white">Buscar</button>
      </form>
      {/* searchParams solo se conoce en la petición: lo leemos dentro de Suspense */}
      <Suspense fallback={<p>Cargando productos...</p>}>
        <Resultados searchParams={searchParams} />
      </Suspense>
    </main>
  )
}

async function Resultados({ searchParams }: Pick<PageProps<'/productos'>, 'searchParams'>) {
  const parametros = await searchParams
  const q = primero(parametros.q) ?? ''
  const pagina = Math.max(1, Number(primero(parametros.pagina)) || 1)

  const resultado = await buscarProductos(q, pagina)

  // Construye el enlace a otra página conservando la búsqueda
  const enlace = (p: number) => `/productos?${new URLSearchParams({ q, pagina: String(p) })}`

  return (
    <>
      <p className="mb-2 text-sm text-slate-500">
        {resultado.total} productos{q && ` para «${q}»`} · página {resultado.pagina} de {resultado.paginas}
      </p>
      <ul className="grid grid-cols-2 gap-3">
        {resultado.contenido.map((p) => <TarjetaProducto key={p.productoId} producto={p} />)}
      </ul>
      <div className="mt-4 flex gap-4">
        {pagina > 1 && <Link href={enlace(pagina - 1)} className="text-indigo-700">← Anterior</Link>}
        {pagina < resultado.paginas && <Link href={enlace(pagina + 1)} className="text-indigo-700">Siguiente →</Link>}
      </div>
    </>
  )
}

Detalles que conviene mirar con calma:

  • La página no es async y no espera nada. Así, el título y el formulario forman parte del armazón estático, que se envía al instante. En la tabla del build aparece como ◐ /productos (Partial Prerender).
  • El formulario no tiene action ni JavaScript: es un formulario HTML con method="get" implícito. Al enviarlo, el navegador navega a /productos?q=.... Funciona incluso antes de que cargue React.
  • pagina se sanea: Number('abc') || 1 da 1, y Math.max(1, ...) evita páginas negativas.
  • Los enlaces de paginación conservan q gracias a URLSearchParams, que además codifica tildes y espacios.

Comprobado contra la aplicación en marcha: /productos?q=portatil responde 200 y muestra «3 productos para «portatil» · página 1 de 1».

Página de productos con el buscador y tres resultados para la búsqueda portatil

Figura 4. /productos?q=portatil. El título y el formulario son estáticos; la lista llega dentro del <Suspense>.

El componente TarjetaProducto que usa la lista es un componente compartido de la tienda, guardado en una carpeta privada:

// app/_componentes/TarjetaProducto.tsx
import Link from 'next/link'
import type { Producto } from '../_lib/api'

export function TarjetaProducto({ producto }: { producto: Producto }) {
  return (
    <li className="rounded-lg border border-indigo-100 bg-white p-4 shadow-sm">
      <Link href={`/productos/${producto.productoId}`} className="font-semibold text-indigo-800 hover:underline">
        {producto.nombre}
      </Link>
      <p className="text-sm text-slate-500">{producto.categoria}</p>
      <p className="mt-1 font-bold">{producto.precio.toLocaleString('es-ES')} €</p>
    </li>
  )
}

💡 Consejo: La URL es el mejor sitio para guardar el estado que el usuario querría compartir o recuperar: búsqueda, filtros, página, orden. Se puede copiar, guardar en marcadores y volver atrás con el navegador. Leerla en el cliente con useSearchParams y mantenerla sincronizada con la interfaz es el tema del módulo 18.

5.3. Lista blanca para los valores con significado

Cuando un parámetro elige entre opciones (orden, vista, filtro), no lo pases tal cual a la API: compáralo con una lista blanca. Así una URL manipulada no puede ordenar por un campo que no quieres exponer. El ejercicio 4 lo hace con el orden de la lista de empleados:

const ORDENES = { nombre: 'Nombre', apellidos: 'Apellidos', salario: 'Salario (menor primero)', '-salario': 'Salario (mayor primero)' } as const
type Orden = keyof typeof ORDENES

function esOrden(valor: unknown): valor is Orden {
  return typeof valor === 'string' && valor in ORDENES
}

// En el componente:
const orden: Orden = esOrden(textoOrden) ? textoOrden : 'apellidos'

En el módulo 11 harás lo mismo con Zod, que valida y convierte en un solo paso.


6. Segmentos catch-all: [...tema] y [[...ruta]]

6.1. [...tema]: uno o más tramos

Un segmento [id] casa con un solo tramo: /productos/7/x no llega a la ficha. A veces necesitas lo contrario, una página que reciba toda la ruta que cuelga de ella, sea cual sea su profundidad. Es el caso de un centro de ayuda, una documentación o un gestor de contenidos, donde los artículos se organizan en árbol: /ayuda/envios, /ayuda/envios/internacionales, /ayuda/pagos/tarjeta.

Para eso están los segmentos catch-all: tres puntos dentro de los corchetes. El parámetro llega como array con un elemento por tramo.

URL params.tema
/ayuda/envios ['envios']
/ayuda/envios/internacionales ['envios', 'internacionales']
/ayuda/pagos/tarjeta ['pagos', 'tarjeta']
/ayuda No casa: necesita al menos un tramo

El contenido de la ayuda es fijo, así que vive en un fichero de datos colocado junto a la ruta. Al no llamarse page.tsx, no se publica:

// app/(tienda)/ayuda/_contenido.ts
// Fichero colocado junto a la ruta: no es page.tsx, así que no es accesible por URL
export const articulos: Record<string, { titulo: string; texto: string }> = {
  'envios': { titulo: 'Envíos', texto: 'Enviamos a toda España en 24-48 horas laborables.' },
  'envios/internacionales': { titulo: 'Envíos internacionales', texto: 'Enviamos a la Unión Europea en 3-5 días.' },
  'pagos': { titulo: 'Formas de pago', texto: 'Aceptamos tarjeta, transferencia y Bizum.' },
  'pagos/tarjeta': { titulo: 'Pago con tarjeta', texto: 'Visa y Mastercard, con autenticación segura del banco.' },
  'devoluciones': { titulo: 'Devoluciones', texto: 'Tienes 30 días para devolver cualquier producto.' },
}

La página une el array con / para buscar el artículo, y lo recorre para construir las migas de pan:

// app/(tienda)/ayuda/[...tema]/page.tsx
import { notFound } from 'next/navigation'
import { Migas } from '@/app/_componentes/Migas'
import { articulos } from '../_contenido'

// Todas las rutas se conocen de antemano: se prerenderizan todas
export function generateStaticParams() {
  return Object.keys(articulos).map((clave) => ({ tema: clave.split('/') }))
}

export default async function PaginaAyuda({ params }: PageProps<'/ayuda/[...tema]'>) {
  const { tema } = await params // /ayuda/pagos/tarjeta -> ['pagos', 'tarjeta']
  const articulo = articulos[tema.join('/')]
  if (!articulo) notFound()

  const migas = tema.map((_, i) => {
    const clave = tema.slice(0, i + 1).join('/')
    return { texto: articulos[clave]?.titulo ?? tema[i], href: `/ayuda/${clave}` }
  })

  return (
    <main className="rounded-lg bg-white p-6 shadow">
      <Migas migas={[{ texto: 'Ayuda', href: '/ayuda/envios' }, ...migas]} />
      <h1 className="text-2xl font-bold text-indigo-900">{articulo.titulo}</h1>
      <p className="mt-3">{articulo.texto}</p>
      <p className="mt-6 font-mono text-xs text-slate-400">params.tema = {JSON.stringify(tema)}</p>
    </main>
  )
}

Observa generateStaticParams: para un catch-all, cada objeto lleva un array ({ tema: ['pagos', 'tarjeta'] }), no un texto con barras: es la misma forma que tendrá params.tema cuando llegue la petición.

El componente de migas de pan es genérico y lo comparten la ayuda y el catálogo:

// app/_componentes/Migas.tsx
import Link from 'next/link'

export type Miga = { texto: string; href: string }

export function Migas({ migas }: { migas: Miga[] }) {
  return (
    <nav aria-label="Migas de pan" className="mb-4 text-sm text-slate-500">
      {migas.map((miga, i) => (
        <span key={miga.href}>
          {i > 0 && ' / '}
          {i === migas.length - 1 ? (
            <span className="font-semibold text-slate-800">{miga.texto}</span>
          ) : (
            <Link href={miga.href} className="text-indigo-700 hover:underline">{miga.texto}</Link>
          )}
        </span>
      ))}
    </nav>
  )
}

Artículo de ayuda Pago con tarjeta con migas de pan Ayuda, Formas de pago, Pago con tarjeta y el valor de params.tema

Figura 5. /ayuda/pagos/tarjeta. La última línea muestra el array real que recibe la página.

¿Y /ayuda, sin nada más? Con solo [...tema], da 404 (comprobado). Tienes dos salidas: añadir un ayuda/page.tsx aparte, que es lo que harás en el ejercicio 5, o hacer el segmento opcional.

6.2. [[...ruta]]: cero o más tramos

Con dobles corchetes, el catch-all también casa con la ruta vacía. Es perfecto para un catálogo por categorías, donde /catalogo muestra todo y cada tramo adicional baja un nivel en el árbol:

URL params.ruta
/catalogo undefined
/catalogo/informatica ['informatica']
/catalogo/informatica/perifericos ['informatica', 'perifericos']
/catalogo/informatica/perifericos/monitores ['informatica', 'perifericos', 'monitores']

Atención a la primera fila: en la raíz, el parámetro no existe (undefined), no es un array vacío. Por eso el tipo es string[] | undefined, y la forma cómoda de leerlo es con un valor por defecto en la desestructuración: const { ruta = [] } = await params.

Las categorías de la API forman un árbol (cada una tiene un categoriaPadreId), pero las URLs no deben llevar números ni tildes. Un módulo de utilidades traduce entre nombres y slugs, y recorre el árbol:

// app/_lib/categorias.ts
import type { Categoria } from './api'

// "Ratones y teclados" -> "ratones-y-teclados"; "Informática" -> "informatica"
export function aSlug(texto: string) {
  return texto
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '')
}

// Cadena de categorías desde la raíz hasta la indicada
export function cadenaDe(categoria: Categoria, todas: Categoria[]): Categoria[] {
  const cadena: Categoria[] = []
  let actual: Categoria | undefined = categoria
  while (actual) {
    cadena.unshift(actual)
    actual = todas.find((c) => c.categoriaId === actual!.categoriaPadreId)
  }
  return cadena
}

// Ruta de slugs de una categoría: ['informatica', 'perifericos', 'monitores']
export function rutaDe(categoria: Categoria, todas: Categoria[]) {
  return cadenaDe(categoria, todas).map((c) => aSlug(c.nombre))
}

// Recorre la ruta de slugs desde la raíz. Devuelve la categoría final,
// o undefined si algún tramo no existe o no cuelga del anterior.
export function resolverRuta(ruta: string[], todas: Categoria[]) {
  let padreId: number | null = null
  let encontrada: Categoria | undefined
  for (const tramo of ruta) {
    encontrada = todas.find(
      (c) => c.categoriaPadreId === padreId && aSlug(c.nombre) === tramo,
    )
    if (!encontrada) return undefined
    padreId = encontrada.categoriaId
  }
  return encontrada
}

// Identificadores de una categoría y de todas sus descendientes
export function idsDescendientes(raizId: number, todas: Categoria[]): number[] {
  const hijas = todas.filter((c) => c.categoriaPadreId === raizId)
  return [raizId, ...hijas.flatMap((h) => idsDescendientes(h.categoriaId, todas))]
}

resolverRuta es la pieza clave. No basta con buscar el último slug: exige que cada tramo cuelgue del anterior. Así /catalogo/perifericos da 404, porque Periféricos no es una categoría raíz (cuelga de Informática). Sin esa regla, la misma categoría tendría varias URLs válidas.

La página:

// app/(tienda)/catalogo/[[...ruta]]/page.tsx
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { obtenerCategorias, obtenerProductos } from '@/app/_lib/api'
import { aSlug, cadenaDe, idsDescendientes, resolverRuta, rutaDe } from '@/app/_lib/categorias'
import { Migas } from '@/app/_componentes/Migas'
import { TarjetaProducto } from '@/app/_componentes/TarjetaProducto'

// Una entrada por categoría, más la raíz (/catalogo), que es ruta: []
export async function generateStaticParams() {
  const categorias = await obtenerCategorias()
  return [{ ruta: [] }, ...categorias.map((c) => ({ ruta: rutaDe(c, categorias) }))]
}

export default async function PaginaCatalogo({ params }: PageProps<'/catalogo/[[...ruta]]'>) {
  const { ruta = [] } = await params // en /catalogo, ruta es undefined
  const [categorias, productos] = await Promise.all([obtenerCategorias(), obtenerProductos()])

  const actual = ruta.length > 0 ? resolverRuta(ruta, categorias) : undefined
  if (ruta.length > 0 && !actual) notFound()

  const hijas = categorias.filter((c) => c.categoriaPadreId === (actual?.categoriaId ?? null))
  const ids = actual ? idsDescendientes(actual.categoriaId, categorias) : null
  const visibles = ids ? productos.filter((p) => ids.includes(p.categoriaId)) : productos

  const cadena = actual ? cadenaDe(actual, categorias) : []
  const migas = [
    { texto: 'Catálogo', href: '/catalogo' },
    ...cadena.map((c, i) => ({
      texto: c.nombre,
      href: '/catalogo/' + cadena.slice(0, i + 1).map((x) => aSlug(x.nombre)).join('/'),
    })),
  ]

  return (
    <main>
      <Migas migas={migas} />
      <h1 className="text-2xl font-bold text-indigo-900">{actual?.nombre ?? 'Catálogo completo'}</h1>
      {hijas.length > 0 && (
        <ul className="my-3 flex flex-wrap gap-2">
          {hijas.map((h) => (
            <li key={h.categoriaId}>
              <Link href={`/catalogo/${[...ruta, aSlug(h.nombre)].join('/')}`}
                className="rounded-full bg-indigo-100 px-3 py-1 text-sm text-indigo-900 hover:bg-indigo-200">
                {h.nombre}
              </Link>
            </li>
          ))}
        </ul>
      )}
      <p className="mb-2 text-sm text-slate-500">{visibles.length} productos</p>
      <ul className="grid grid-cols-2 gap-3">
        {visibles.map((p) => <TarjetaProducto key={p.productoId} producto={p} />)}
      </ul>
    </main>
  )
}

Fíjate en cómo se prerenderiza la raíz: en generateStaticParams se representa con { ruta: [] }. Next generó en el build /catalogo y las once categorías, y la tabla del build lo resume así:

├   /catalogo/[[...ruta]]
│ ├ ◐ /catalogo/[[...ruta]]
│ ├ ○ /catalogo                                         15m      1y
│ ├ ○ /catalogo/informatica                             15m      1y
│ └ ◐ [+10 more paths]

Respuestas reales:

/catalogo                          -> catálogo completo
/catalogo/informatica/perifericos  -> Periféricos, 7 productos
/catalogo/perifericos              -> no encontrado (Periféricos no es raíz)
/catalogo/xx                       -> no encontrado

Catálogo en la categoría Periféricos, con migas de pan, subcategorías Ratones y teclados y Monitores, y siete productos

Figura 6. /catalogo/informatica/perifericos. Se muestran los productos de la categoría y de todas sus descendientes: siete en total.

💡 Consejo: ¿Opcional o no? Si la página raíz y las páginas internas comparten casi todo (como aquí, donde la raíz es «el catálogo sin filtrar»), usa [[...ruta]]. Si la raíz es algo distinto (un índice, una portada), usa [...tema] más un page.tsx propio en la carpeta padre: queda más claro y cada fichero hace una sola cosa.

6.3. Un catch-all opcional no convive con un page.tsx hermano

Como [[...ruta]] ya responde en /catalogo, si además creas catalogo/page.tsx hay dos páginas para la misma URL. Next lo detecta nada más empezar el build, antes incluso de compilar:

> Build error occurred
Error: You cannot define a route with the same specificity as an optional catch-all route ("/catalogo" and "/catalogo/[[...ruta]]").

O una cosa o la otra: [[...ruta]] sola, o page.tsx más [...ruta] (sin opcional). Es exactamente la decisión del consejo anterior.


7. Prerenderizar rutas dinámicas: generateStaticParams

7.1. La idea

Una página dinámica puede generarse en cada petición o por adelantado, en el build. Para prerenderizarla, Next necesita saber qué valores tomarán los parámetros, y eso es lo que le das con generateStaticParams: una función exportada desde la página (o el layout) que devuelve un array de objetos, uno por cada URL que quieres generar.

export async function generateStaticParams() {
  const productos = await obtenerProductos()
  return productos.slice(0, 5).map((p) => ({ id: String(p.productoId) }))
}

Durante next build ocurre esto:

  1. Next llama a generateStaticParams una vez.
  2. Por cada objeto devuelto, renderiza la página con esos params y guarda el HTML y el payload RSC.
  3. Además guarda un armazón genérico para los valores que no estaban en la lista.

Diagrama: en el build, generateStaticParams devuelve cinco ids y se guarda una página por cada uno; en las peticiones, las páginas generadas se sirven directamente, las nuevas se generan la primera vez y los notFound dan 200 la primera vez y 404 después

Figura 7. Qué hace generateStaticParams en el build y qué pasa después con cada tipo de petición, con Cache Components activado.

Reglas que conviene grabarse:

  • Los valores deben ser texto (o arrays de texto en un catch-all), igual que los que llegan de la URL. Por eso el ejemplo hace String(p.productoId).
  • Cada objeto debe incluir los segmentos dinámicos de la ruta que no genere ya un layout superior (apartado 7.4).
  • En next dev, generateStaticParams se ejecuta al navegar a la ruta, no al arrancar.
  • Las peticiones fetch dentro de generateStaticParams se deduplican con las de la página y el layout, así que no pagas dos veces por los mismos datos.
  • generateStaticParams sustituye a getStaticPaths del Pages Router (módulo 24).

⚠️ Importante: Si generateStaticParams pide datos a la API, la API tiene que estar en marcha durante next build. Si no lo está, el build falla al recoger los datos de la página. Es un despiste muy habitual en integración continua.

7.2. Lo que dice la tabla del build

Este es un extracto real de la salida de next build de la aplicación del módulo:

Route (app)                                      Revalidate  Expire
├   /productos/[id]
│ ├ ◐ /productos/[id]
│ ├ ○ /productos/1                                      15m      1y
│ ├ ○ /productos/2                                      15m      1y
│ └ ◐ [+3 more paths]
○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

Se lee así: hay cinco productos prerenderizados (/productos/1, /productos/2 y tres más), con una caché de 15 minutos que caduca del todo al año (son los valores por defecto de 'use cache', que explica el módulo 09). La línea ◐ /productos/[id] es el armazón genérico para cualquier otro id.

7.3. ¿Y los valores que no están en la lista?

Con la configuración por defecto, un id que no estaba en la lista no da 404: Next renderiza la página la primera vez que alguien la pide y la guarda para las siguientes. Lo hemos comprobado con las cabeceras de respuesta:

GET /productos/13   (1.ª vez)   HTTP/1.1 200 OK
GET /productos/13   (2.ª vez)   HTTP/1.1 200 OK
                                x-nextjs-cache: HIT

El producto 13 no estaba entre los cinco del build. La primera petición lo genera; la segunda ya sale de la caché (HIT), igual que un producto prerenderizado.

Así puedes elegir cuánto prerenderizar según el caso:

Estrategia generateStaticParams devuelve Cuándo usarla
Todo en el build Todos los valores Pocos elementos que cambian poco: categorías, artículos de ayuda
Una parte en el build Los más visitados Catálogos grandes: los 100 productos más vendidos al compilar, el resto bajo demanda
Nada en el build Un valor de muestra Datos que solo existen después del despliegue (con Cache Components no se admite la lista vacía; apartado 7.5)

7.4. Varios segmentos: de abajo arriba o de arriba abajo

En una ruta con dos segmentos dinámicos, como /departamentos/[deptId]/empleados/[empId], tienes dos formas de generar los parámetros.

De abajo arriba: la página más profunda genera los dos segmentos de una vez:

// En empleados/[empId]/page.tsx
export async function generateStaticParams() {
  const empleados = await obtenerEmpleados()
  return empleados.map((e) => ({ deptId: String(e.deptId), empId: String(e.empId) }))
}

De arriba abajo: el layout del padre genera deptId, y la página hija recibe cada deptId y genera sus empId. Next llama a la función hija una vez por cada objeto que devuelve la del padre. Es la que usa la aplicación EMPRESA, porque el layout de [deptId] necesita sus propios parámetros para pintar la cabecera del departamento:

// app/(gestion)/departamentos/[deptId]/layout.tsx
import { notFound } from 'next/navigation'
import { obtenerDepartamento, obtenerEmpleados } from '@/app/_lib/api'

// De arriba abajo: el layout genera los valores de [deptId].
// Solo los departamentos con plantilla: con Cache Components, el
// generateStaticParams hijo no puede devolver una lista vacía.
export async function generateStaticParams() {
  const empleados = await obtenerEmpleados()
  const ids = new Set<number>()
  for (const e of empleados) if (e.deptId !== null) ids.add(e.deptId) // hay empleados sin departamento
  return [...ids].map((id) => ({ deptId: String(id) }))
}

export default async function LayoutDepartamento({ children, params }: LayoutProps<'/departamentos/[deptId]'>) {
  const { deptId } = await params
  const departamento = await obtenerDepartamento(Number(deptId))
  if (!departamento) notFound()

  return (
    <section>
      <header className="mb-4 border-b border-indigo-200 pb-2">
        <h1 className="text-2xl font-bold text-indigo-900">{departamento.nombre}</h1>
        <p className="text-sm text-slate-500">{departamento.localidad} · Presupuesto {departamento.presupuesto.toLocaleString('es-ES')} €</p>
      </header>
      {children}
    </section>
  )
}

La función hija ya la viste en el apartado 2.4: recibe { params: { deptId } } y devuelve [{ empId: '101' }, ...]. Un layout puede generar parámetros de sus segmentos y de los de encima, pero nunca de los de debajo: el layout de [deptId] no puede generar empId.

La página de la lista de empleados de un departamento no necesita su propio generateStaticParams: los valores de deptId ya los genera el layout del que cuelga.

// app/(gestion)/departamentos/[deptId]/page.tsx
import Link from 'next/link'
import { obtenerEmpleadosDe } from '@/app/_lib/api'

export default async function PaginaDepartamento({ params }: PageProps<'/departamentos/[deptId]'>) {
  const { deptId } = await params
  const empleados = await obtenerEmpleadosDe(Number(deptId))

  return (
    <table className="w-full bg-white text-sm shadow">
      <thead className="bg-indigo-100 text-left">
        <tr><th className="p-2">Empleado</th><th className="p-2">Oficio</th></tr>
      </thead>
      <tbody>
        {empleados.map((e) => (
          <tr key={e.empId} className="border-t">
            <td className="p-2">
              <Link href={`/departamentos/${deptId}/empleados/${e.empId}`} className="text-indigo-700 hover:underline">
                {e.nombre} {e.apellidos}
              </Link>
            </td>
            <td className="p-2">{e.oficio}</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

En la tabla del build se ve el resultado de combinar ambas funciones: seis departamentos y, para cada uno, sus empleados.

├   /departamentos/[deptId]/empleados/[empId]           15m      1y
│ ├ ◐ /departamentos/[deptId]/empleados/[empId]         15m      1y
│ ├ ◐ /departamentos/10/empleados/[empId]               15m      1y
│ ├ ○ /departamentos/10/empleados/100                   15m      1y
│ └ ◐ [+27 more paths]

7.5. Rutas dinámicas con Cache Components 🔄 16.0

🔄 Cambia en Next.js 16.0. Con cacheComponents: true (lo que genera create-next-app en 16), generateStaticParams no puede devolver una lista vacía, la opción dynamicParams no existe y una página que lee params sin generateStaticParams necesita <Suspense>. Si usas Next.js 15: no hay Cache Components; puedes devolver [] para generarlo todo bajo demanda, usar export const dynamicParams = false para que lo no listado dé 404, y leer params sin <Suspense>.

Cache Components cambia la filosofía: Next quiere comprobar en el build que cada ruta se puede servir con un armazón estático. Para comprobar una ruta dinámica necesita al menos un valor de ejemplo con el que ejecutarla. De ahí salen tres reglas, todas verificadas en 16.4 con su error real.

Regla 1: la lista no puede estar vacía.

export function generateStaticParams() {
  return []
}
Error: When using Cache Components, all `generateStaticParams` functions must return at least one result. This is to ensure that we can perform build-time validation that there is no other dynamic accesses that would cause a runtime error.
Learn more: https://nextjs.org/docs/messages/empty-generate-static-params
    at <unknown> (app\productos\[id]\page.tsx:3:10)

La regla se aplica a cada llamada, también a las de una función hija en el esquema de arriba abajo. La aplicación EMPRESA tropezó con esto: la primera versión del layout de [deptId] generaba los siete departamentos, pero INVESTIGACIÓN (70) no tiene empleados, así que la función hija devolvía [] para él y el build fallaba en empleados/[empId]/page.tsx. La corrección fue generar solo los departamentos que aparecen en la lista de empleados. Y entonces llegó otra sorpresa: el build seguía fallando, porque entre esos departamentos salía un deptId: 'null', porque la API tiene un empleado sin departamento. De ahí el filtro e.deptId !== null del layout. Para depurarlo bastó un console.log dentro de la función hija, que se ve en la salida del build:

DEPURA {"deptId":"10"} 1
DEPURA {"deptId":"20"} 6
...
DEPURA {"deptId":"null"} 0

INVESTIGACIÓN no se pierde: su página no está en la lista, así que se genera la primera vez que alguien la visita (/departamentos/70 responde 200).

💡 Consejo: Si de verdad no conoces ningún valor en el build, la documentación propone devolver uno ficticio ([{ id: '__muestra__' }]) y responder con notFound() cuando llegue. Funciona, pero la validación del build pierde casi todo su valor. Antes de recurrir a ello, plantéate la regla 3.

Regla 2: dynamicParams no existe. En Next.js 15, export const dynamicParams = false hace que los valores no listados den 404 directamente. Con Cache Components, el build lo rechaza:

Error: Route segment config "dynamicParams" is not compatible with `nextConfig.cacheComponents`. Please remove it.
> 1 | export const dynamicParams = false
    |              ^^^^^^^^^^^^^

La alternativa es la que ya usa toda la aplicación: comprobar el valor dentro de la página y llamar a notFound() si no es válido.

Regla 3: sin generateStaticParams, params va dentro de <Suspense>. Si no das valores de ejemplo, params es un dato de la petición, igual que searchParams. Esperarlo en el cuerpo de la página rompe el build:

Error: Route "/productos/[id]": 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
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic

El mensaje es muy bueno: te da las tres salidas. La tercera, instant = false, existe desde Next.js 16.3 y renuncia al armazón inmediato; se explica con las navegaciones instantáneas en el módulo 15. La primera es la correcta para datos que de verdad solo existen en la petición, como un pedido recién creado. La página no espera a params: se lo pasa a un hijo envuelto en <Suspense>.

// app/(gestion)/pedidos/[pedidoId]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

type Pedido = { pedidoId: number; estado: string; total: number; clienteId: number }

// Sin generateStaticParams: el id solo se conoce en la petición.
// La página no espera a params; se lo pasa a un hijo dentro de Suspense.
export default function PaginaPedido({ params }: PageProps<'/pedidos/[pedidoId]'>) {
  return (
    <div>
      <h1 className="mb-4 text-2xl font-bold text-indigo-900">Detalle del pedido</h1>
      <Suspense fallback={<p>Cargando pedido...</p>}>
        <DetallePedido params={params} />
      </Suspense>
    </div>
  )
}

async function DetallePedido({ params }: Pick<PageProps<'/pedidos/[pedidoId]'>, 'params'>) {
  const { pedidoId } = await params
  const respuesta = await fetch(`${API}/pedidos/${encodeURIComponent(pedidoId)}`)
  if (respuesta.status === 404) notFound()
  const pedido: Pedido = await respuesta.json()

  return (
    <dl className="grid grid-cols-2 gap-2 rounded-lg bg-white p-6 shadow">
      <dt className="font-semibold">Pedido</dt><dd>{pedido.pedidoId}</dd>
      <dt className="font-semibold">Cliente</dt><dd>{pedido.clienteId}</dd>
      <dt className="font-semibold">Estado</dt><dd>{pedido.estado}</dd>
      <dt className="font-semibold">Total</dt><dd>{pedido.total.toLocaleString('es-ES')} €</dd>
    </dl>
  )
}

Aquí el fetch no lleva caché a propósito: el estado de un pedido cambia y queremos verlo actualizado. Al estar dentro de <Suspense>, está permitido. En la tabla del build sale como ◐ /pedidos/[pedidoId]: el título se prerenderiza; el detalle llega en cada petición.

💡 Consejo: La documentación recomienda lo mismo para los layouts: no esperes params en el cuerpo de un layout sin generateStaticParams, porque impide prerenderizar todo lo que cuelga de él. Pasa la promesa al componente que la necesite y espérala allí.

7.6. La alternativa en Next.js 15

En Next.js 15 el modelo es el anterior a Cache Components: una página con generateStaticParams es estática (●, SSG) y una que lee params sin él es dinámica (ƒ). Esta versión de la ficha compila y funciona en 15.5:

// Next.js 15: app/productos/[id]/page.tsx
import { notFound } from 'next/navigation'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

type Producto = { productoId: number; nombre: string; precio: number }

// En Next.js 15 sí existe dynamicParams: lo que no esté en la lista da 404
export const dynamicParams = false

export async function generateStaticParams() {
  const respuesta = await fetch(`${API}/productos?tamano=100`)
  const { contenido }: { contenido: Producto[] } = await respuesta.json()
  return contenido.slice(0, 5).map((p) => ({ id: String(p.productoId) }))
}

export default async function PaginaProducto({ params }: PageProps<'/productos/[id]'>) {
  const { id } = await params
  const respuesta = await fetch(`${API}/productos/${id}`)
  if (respuesta.status === 404) notFound()
  const producto: Producto = await respuesta.json()
  return <h1>{producto.nombre}: {producto.precio} €</h1>
}

Tabla del build y respuestas reales en 15.5:

├ ● /ayuda/[...tema]                 0 B         113 kB
├ ƒ /buscar                          0 B         113 kB
├ ƒ /departamentos/[deptId]          0 B         113 kB
├ ● /productos/[id]                  0 B         113 kB
├   ├ /productos/1
├   ├ /productos/2
├   ├ /productos/3
├   └ [+2 more paths]
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand
/productos/1   -> 200
/productos/5   -> 200
/productos/7   -> 404   (dynamicParams = false)
/productos/999 -> 404

En la misma prueba, /ayuda/[...tema] usaba generateStaticParams con return [], que en 15 está permitido: no se genera nada en el build y cada artículo se genera al pedirlo (/ayuda/pagos/tarjeta respondió 200). Y /buscar hacía await searchParams sin <Suspense>: compila y queda como ƒ.

En 15, para regenerar periódicamente las páginas generadas se usa export const revalidate = 3600 en la página (ISR). Ese modelo completo se explica, junto al nuevo, en el módulo 09.


8. Cuando no existe: notFound() y not-found.tsx

8.1. Dos piezas

  • La función notFound(), de next/navigation, interrumpe el render de la página o el layout donde la llamas.
  • El fichero not-found.tsx define qué se muestra en ese caso. Next busca el más cercano subiendo por las carpetas, y si no hay ninguno usa el de la raíz (app/not-found.tsx) o, en último término, uno genérico.

El app/not-found.tsx raíz cumple un segundo papel: es la página que ve quien escribe una URL que no casa con ninguna ruta (/no-existe).

// app/not-found.tsx
import Link from 'next/link'

export default function NoEncontrada() {
  return (
    <main className="mx-auto max-w-3xl p-8">
      <h1 className="text-2xl font-bold text-indigo-900">Página no encontrada</h1>
      <p className="mt-2">La dirección que buscas no existe.</p>
      <Link href="/" className="mt-4 inline-block text-indigo-700 underline">Volver a la portada</Link>
    </main>
  )
}

Un not-found.tsx dentro de un segmento permite un mensaje específico, que además se pinta dentro de los layouts de ese segmento (en la tienda, con su cabecera):

// app/(tienda)/productos/[id]/not-found.tsx
import Link from 'next/link'

// Se muestra cuando notFound() se llama dentro de /productos/[id]
export default function ProductoNoEncontrado() {
  return (
    <div className="rounded-lg border border-orange-300 bg-orange-50 p-6">
      <h1 className="text-xl font-bold text-orange-900">Ese producto no existe</h1>
      <p className="mt-2">Puede que se haya retirado del catálogo o que el enlace esté mal escrito.</p>
      <Link href="/productos" className="mt-4 inline-block text-indigo-700 underline">Ver todos los productos</Link>
    </div>
  )
}

Mensaje Ese producto no existe dentro de la cabecera de la tienda

Figura 8. /productos/999. El not-found.tsx del segmento se pinta dentro del layout de la tienda.

⚠️ Importante: not-found.tsx no recibe params ni searchParams. Si necesitas decir «el producto 999 no existe», no puedes sacarlo de las props; usa un mensaje genérico o léelo en un Client Component con useParams. Los estados especiales (error.tsx, forbidden, unauthorized…) se ven en el módulo 14.

8.2. El código de estado: 404… casi siempre

Para un buscador, que la página diga «no existe» no basta: el código HTTP debe ser 404, o la indexará como contenido. Hemos medido los códigos reales con curl en la aplicación compilada:

/productos/1           -> 200   prerenderizado en el build
/productos/999         -> 200   1.ª petición (lleva noindex y muestra "Ese producto no existe")
/productos/999         -> 404   2.ª petición
/productos/abc         -> 200   1.ª petición
/productos/abc         -> 404   2.ª petición
/proyectos/99          -> 200   1.ª petición
/proyectos/99          -> 404   2.ª petición
/productos/12/editar   -> 404   no casa con ninguna ruta
/tienda/productos/12   -> 404   no casa con ninguna ruta
/_borradores           -> 404   carpeta privada

Hay dos grupos. Las URLs que no casan con ninguna ruta dan 404 desde el principio: Next lo sabe antes de renderizar nada. Las que casan con una ruta dinámica pero cuyo valor no existe dan 200 la primera vez y 404 después.

¿Por qué la primera petición da 200? Con Cache Components, para un valor que no estaba en generateStaticParams, Next empieza enviando el armazón estático de la ruta, y la cabecera HTTP con el 200 sale con él. Cuando la página llama a notFound(), ya es tarde para cambiar el código. Next lo compensa añadiendo <meta name="robots" content="noindex"> al HTML (lo hemos comprobado en la respuesta), para que los buscadores no la indexen, y pinta el not-found.tsx igualmente. Una vez resuelta esa primera petición, el resultado queda guardado, y las siguientes ya responden 404.

Lo mismo ocurre siempre que notFound() se llama dentro de un <Suspense>: el /pedidos/[pedidoId] del apartado 7.5 responde 200 con noindex para un pedido inexistente.

💡 Consejo: Para el usuario no hay diferencia: ve el mensaje correcto. Para los buscadores, el noindex cumple la misma función que el 404. Pero si un sistema externo comprueba el código de estado (un monitor, un enlace en un correo), tenlo presente. Las páginas en la lista de generateStaticParams y las que validan antes de cualquier <Suspense> dan 404 desde la segunda petición.


9. Grupos de rutas: (tienda) y (gestion)

9.1. El problema

La tienda y la zona de gestión necesitan aspectos distintos: la tienda lleva un aviso de envíos y contenido centrado; la gestión, un menú lateral. Con lo visto en el módulo 03, cada layout afecta a todas las rutas de su carpeta. Para que /productos y /catalogo compartan un layout, tendrían que colgar de una carpeta común, y eso añadiría un tramo a la URL: /tienda/productos. No queremos eso.

9.2. La solución: carpetas entre paréntesis

Un nombre de carpeta entre paréntesis crea un grupo de rutas: organiza los ficheros y puede tener su propio layout, pero no aparece en la URL.

app/
├── layout.tsx                    → layout raíz (barra superior)
├── (tienda)/
│   ├── layout.tsx                → aviso de envíos, ancho centrado
│   ├── productos/...             → /productos, /productos/[id]
│   ├── catalogo/...              → /catalogo/...
│   └── ayuda/...                 → /ayuda/...
└── (gestion)/
    ├── layout.tsx                → menú lateral de departamentos
    ├── departamentos/...         → /departamentos/[deptId]/...
    ├── clientes/...              → /clientes/[id]/...
    └── pedidos/...               → /pedidos/[pedidoId]
// app/(tienda)/layout.tsx
// Layout del grupo (tienda): lo comparten /productos, /catalogo y /ayuda.
// El nombre del grupo no aparece en ninguna URL.
export default function LayoutTienda({ children }: { children: React.ReactNode }) {
  return (
    <div className="mx-auto max-w-4xl p-6">
      <p className="mb-4 rounded bg-orange-100 px-3 py-1 text-sm text-orange-900">
        Tienda EMPRESA · Envío gratis a partir de 50 €
      </p>
      {children}
    </div>
  )
}

El de gestión pide los departamentos a la API para pintar el menú. Es un Server Component async, como cualquier otro:

// app/(gestion)/layout.tsx
import { Suspense } from 'react'
import { obtenerDepartamentos } from '@/app/_lib/api'
import { ListaDepartamentos, MenuDepartamentos } from './_componentes/MenuDepartamentos'

// Layout del grupo (gestion): menú lateral con los departamentos
export default async function LayoutGestion({ children }: { children: React.ReactNode }) {
  const departamentos = await obtenerDepartamentos()
  // Solo pasamos al cliente los dos campos que necesita
  const opciones = departamentos.map(({ deptId, nombre }) => ({ deptId, nombre }))

  return (
    <div className="flex min-h-[calc(100vh-44px)]">
      <aside className="w-48 bg-indigo-50 p-4 text-sm">
        <p className="mb-2 font-bold text-indigo-900">Gestión</p>
        {/* useParams depende de la URL: en rutas sin generateStaticParams
            solo se conoce en la petición, así que va dentro de Suspense */}
        <Suspense fallback={<ListaDepartamentos departamentos={opciones} />}>
          <MenuDepartamentos departamentos={opciones} />
        </Suspense>
      </aside>
      <div className="flex-1 p-6">{children}</div>
    </div>
  )
}

MenuDepartamentos es un Client Component que resalta el departamento activo con useParams; es la solución del ejercicio 6, y allí se explica por qué va dentro de <Suspense>. Compara las figuras 2 y 3: la misma barra superior (layout raíz) y, debajo, dos maquetaciones distintas (layouts de grupo), con URLs que no delatan los grupos.

Diagrama de la carpeta app con grupos, carpetas privadas y ficheros colocados, frente a las URLs que existen realmente

Figura 9. Los grupos no aparecen en la URL, y lo que está en carpetas privadas o no se llama page.tsx no genera ninguna ruta.

9.3. Usos de los grupos

  1. Compartir un layout entre rutas hermanas sin cambiar sus URLs, como acabas de ver.
  2. Dejar fuera de un layout a algunas rutas. Si /aviso-legal no debe llevar el aviso de envíos, basta con no meterla en (tienda). El ejercicio 7 crea un grupo (legal) con su propio layout.
  3. Ordenar por equipos o áreas. Aunque no tengan layout, (marketing), (panel) o (api-interna) ayudan a encontrar las cosas en un proyecto grande.
  4. Tener varios layouts raíz. Si borras app/layout.tsx y pones un layout con <html> y <body> en cada grupo, cada zona tiene su propio documento HTML. Úsalo con cuidado: navegar entre rutas con layouts raíz distintos provoca una recarga completa de la página, no una navegación de cliente. Si haces esto, la portada / debe estar dentro de uno de los grupos.

9.4. Dos grupos no pueden producir la misma URL

Como el grupo desaparece de la URL, (tienda)/acerca/page.tsx y (gestion)/acerca/page.tsx producen las dos /acerca. El build lo detecta:

> Build error occurred
Error: Turbopack build failed with 1 error:
./app/(tienda)
Error: You cannot have two parallel pages that resolve to the same path. Please check /(gestion)/acerca and /(tienda).

El mensaje habla de «páginas paralelas», y no tiene que ver con las rutas paralelas del módulo 15: significa «dos páginas para la misma URL». La solución es renombrar una de las dos o moverla a un segmento real (/gestion/acerca).


10. Carpetas privadas y colocación

10.1. Colocación: dejar los ficheros junto a la ruta que los usa

En el App Router, solo los ficheros especiales generan rutas: page.tsx (y route.ts, módulo 12). Cualquier otro fichero dentro de app/ (un componente, un fichero de datos, una utilidad, un test) se puede dejar donde quieras, porque nunca será accesible por URL. A eso se le llama colocación (colocation).

La aplicación EMPRESA lo aprovecha en varios sitios:

  • app/(tienda)/ayuda/_contenido.ts: los textos de la ayuda, junto a la ruta que los muestra.
  • app/(tienda)/productos/[id]/_componentes/CopiarEnlace.tsx: un componente que solo usa la ficha.
  • app/(gestion)/clientes/[id]/_datos.ts: las funciones de datos de clientes (ejercicio 1).

⚠️ Importante: No exportes funciones de ayuda desde page.tsx o layout.tsx para importarlas en otro sitio. Esos ficheros solo deben exportar lo que Next espera (el componente por defecto, generateStaticParams, generateMetadata, metadata y las opciones de segmento). Pon las funciones compartidas en un fichero aparte, como _datos.ts, e impórtalas desde los dos.

10.2. Carpetas privadas: _carpeta

Aunque la colocación ya es segura, prefijar una carpeta con guion bajo la convierte en privada: Next la ignora por completo al construir las rutas, a ella y a todas sus subcarpetas, aunque contenga un page.tsx. Lo hemos comprobado con una página de prueba:

// app/_borradores/page.tsx
// Esta página nunca se publica: está dentro de una carpeta privada
export default function Borrador() {
  return <p>Borrador</p>
}
/_borradores -> 404

¿Para qué sirven, si los ficheros normales ya no se publican?

  • Separar visualmente la interfaz de la lógica: _lib, _componentes, _datos saltan a la vista en el explorador de ficheros.
  • Ordenar el explorador: las carpetas con _ aparecen agrupadas.
  • Evitar choques con convenciones de Next que existan ahora o en el futuro (una carpeta opengraph-image o un fichero default.tsx tienen significado especial).
  • Desactivar una ruta temporalmente sin borrarla, como el borrador de arriba.

10.3. ¿Y si quiero una URL que empiece por guion bajo?

Si de verdad necesitas /_interno, codifica el guion bajo en el nombre de la carpeta: %5F es _ en codificación URL.

// app/%5Finterno/page.tsx
// %5F es el guion bajo codificado: esta carpeta sí genera la ruta /_interno
export default function Interno() {
  return <main className="p-8">Ruta que empieza por guion bajo</main>
}
/_interno -> 200

En la tabla del build aparece como ○ /_interno.

10.4. ¿Dentro de app/ o fuera?

Next no impone una organización. Las tres más comunes:

Estrategia Estructura Ventaja Inconveniente
Todo fuera de app/ app/ solo con rutas; lib/, components/ en la raíz app/ se lee como un mapa de URLs Lo específico de una ruta queda lejos de ella
Compartido en carpetas privadas de app/ app/_lib, app/_componentes Todo el código de la aplicación en un sitio app/ mezcla rutas y código
Por funcionalidad Cada segmento con sus _componentes y _datos Lo que se usa junto está junto; borrar una función es borrar una carpeta Hay que decidir qué es compartido y qué no

La aplicación EMPRESA mezcla la segunda y la tercera: lo que usan varias zonas (api.ts, TarjetaProducto, Migas) va en app/_lib y app/_componentes; lo que usa una sola ruta, junto a ella. Lo importante es elegir una y aplicarla siempre.

💡 Consejo: create-next-app pregunta si quieres usar una carpeta src/. Si dices que sí, app/ pasa a src/app/ y el código compartido suele ir en src/lib, src/components. No cambia nada de lo que has visto en este módulo, salvo las rutas de los ficheros. El alias @/ (que usan los import de este módulo, como @/app/_lib/api) apunta a la raíz del proyecto o a src/, según el caso.


11. Caso práctico: el mapa de rutas de EMPRESA

11.1. La estructura completa

Reunimos todo lo anterior. Esta es la carpeta app/ de la aplicación al final del módulo, con los ejercicios resueltos incluidos:

app/
├── layout.tsx                         layout raíz: <html>, barra superior
├── page.tsx                           /
├── not-found.tsx                      404 general
├── globals.css
├── _lib/                              privada: lógica compartida
│   ├── api.ts                         acceso a la API EMPRESA
│   ├── categorias.ts                  slugs y árbol de categorías
│   └── validar.ts                     leerIdNumerico()
├── _componentes/                      privada: componentes compartidos
│   ├── Migas.tsx
│   └── TarjetaProducto.tsx
├── _borradores/page.tsx               privada: no se publica
├── %5Finterno/page.tsx                /_interno
├── (tienda)/
│   ├── layout.tsx                     aviso de envíos
│   ├── productos/
│   │   ├── page.tsx                   /productos?q=&pagina=
│   │   ├── nuevo/page.tsx             /productos/nuevo (estático gana a [id])
│   │   └── [id]/
│   │       ├── page.tsx               /productos/1 ... (5 en el build)
│   │       ├── not-found.tsx
│   │       └── _componentes/CopiarEnlace.tsx
│   ├── catalogo/[[...ruta]]/page.tsx  /catalogo, /catalogo/informatica/...
│   └── ayuda/
│       ├── _contenido.ts
│       ├── page.tsx                   /ayuda
│       └── [...tema]/page.tsx         /ayuda/pagos/tarjeta ...
├── (gestion)/
│   ├── layout.tsx                     menú lateral
│   ├── _componentes/MenuDepartamentos.tsx
│   ├── departamentos/[deptId]/
│   │   ├── layout.tsx                 cabecera + generateStaticParams
│   │   ├── page.tsx                   /departamentos/20
│   │   └── empleados/[empId]/page.tsx /departamentos/20/empleados/101
│   ├── empleados/page.tsx             /empleados?orden=&pagina=
│   ├── clientes/[id]/
│   │   ├── _datos.ts
│   │   ├── layout.tsx                 cabecera + generateStaticParams
│   │   ├── page.tsx                   /clientes/1001
│   │   └── pedidos/page.tsx           /clientes/1001/pedidos
│   ├── pedidos/[pedidoId]/page.tsx    /pedidos/5001 (solo en la petición)
│   └── proyectos/[id]/
│       ├── page.tsx                   /proyectos/1
│       └── not-found.tsx
└── (legal)/
    ├── layout.tsx
    ├── aviso-legal/page.tsx           /aviso-legal
    └── privacidad/page.tsx            /privacidad

Y el layout raíz con la barra de navegación común:

// app/layout.tsx
import type { Metadata } from 'next'
import Link from 'next/link'
import './globals.css'

export const metadata: Metadata = {
  title: 'EMPRESA',
  description: 'Tienda y gestión de EMPRESA',
}

export default function LayoutRaiz({ children }: LayoutProps<'/'>) {
  return (
    <html lang="es">
      <body>
        <nav className="flex gap-4 bg-indigo-900 px-6 py-3 text-sm text-white">
          <Link href="/" className="font-bold">EMPRESA</Link>
          <Link href="/productos">Productos</Link>
          <Link href="/catalogo">Catálogo</Link>
          <Link href="/ayuda">Ayuda</Link>
          <Link href="/departamentos/20">Gestión</Link>
        </nav>
        {children}
      </body>
    </html>
  )
}

El enlace «Ayuda» apunta a /ayuda, la portada que crea el ejercicio 5. La portada app/page.tsx es una lista de enlaces de ejemplo a las rutas del módulo.

11.2. Compilar y probar

Con la API en marcha en otra terminal:

npm run build
npm run start -- -p 3001

La tabla de rutas del build es el mejor resumen de lo que has construido. Esta es la real (con las líneas largas de Turbopack y los avisos quitados):

Route (app)                                      Revalidate  Expire
┌ ○ /
├ ○ /_interno
├ ○ /_not-found
├ ○ /aviso-legal
├ ○ /ayuda
├   /ayuda/[...tema]
│ ├ ◐ /ayuda/[...tema]
│ ├ ○ /ayuda/envios
│ ├ ○ /ayuda/envios/internacionales
│ └ ◐ [+3 more paths]
├   /catalogo/[[...ruta]]
│ ├ ◐ /catalogo/[[...ruta]]
│ ├ ○ /catalogo                                         15m      1y
│ ├ ○ /catalogo/informatica                             15m      1y
│ └ ◐ [+10 more paths]
├   /clientes/[id]                                      15m      1y
│ ├ ◐ /clientes/[id]                                    15m      1y
│ ├ ○ /clientes/1001                                    15m      1y
│ ├ ○ /clientes/1002                                    15m      1y
│ └ ◐ [+12 more paths]
├   /clientes/[id]/pedidos                              15m      1y
│ ├ ◐ /clientes/[id]/pedidos                            15m      1y
│ ├ ○ /clientes/1001/pedidos                            15m      1y
│ ├ ○ /clientes/1002/pedidos                            15m      1y
│ └ ◐ [+12 more paths]
├   /departamentos/[deptId]                             15m      1y
│ ├ ◐ /departamentos/[deptId]                           15m      1y
│ ├ ○ /departamentos/10                                 15m      1y
│ ├ ○ /departamentos/20                                 15m      1y
│ └ ◐ [+4 more paths]
├   /departamentos/[deptId]/empleados/[empId]           15m      1y
│ ├ ◐ /departamentos/[deptId]/empleados/[empId]         15m      1y
│ ├ ◐ /departamentos/10/empleados/[empId]               15m      1y
│ ├ ○ /departamentos/10/empleados/100                   15m      1y
│ └ ◐ [+27 more paths]
├ ◐ /empleados                                          15m      1y
├   /pedidos/[pedidoId]                                 15m      1y
│ └ ◐ /pedidos/[pedidoId]                               15m      1y
├ ○ /privacidad
├ ◐ /productos
├   /productos/[id]
│ ├ ◐ /productos/[id]
│ ├ ○ /productos/1                                      15m      1y
│ ├ ○ /productos/2                                      15m      1y
│ └ ◐ [+3 more paths]
├ ○ /productos/nuevo
└   /proyectos/[id]                                     15m      1y
  ├ ◐ /proyectos/[id]                                   15m      1y
  ├ ○ /proyectos/1                                      15m      1y
  ├ ○ /proyectos/2                                      15m      1y
  └ ◐ [+3 more paths]
○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

Lee la tabla con lo que sabes ahora:

  • ○ son páginas completamente estáticas: la portada, las legales, cada producto y categoría de la lista de generateStaticParams.
  • ◐ son Partial Prerender: un armazón estático y una parte que llega en la petición. Están las rutas genéricas (/productos/[id], para los ids no listados), /productos y /empleados (por searchParams) y /pedidos/[pedidoId] (sin generateStaticParams).
  • [+N more paths] resume las rutas generadas que no caben.
  • No aparecen ni _borradores ni _contenido, ni los grupos en ninguna URL.

Fíjate en /departamentos/10/empleados/[empId]: es el armazón de los empleados de un departamento concreto que no estaban en la lista, fruto de combinar la función del layout con la de la página.

Y estas son las respuestas reales de la aplicación recién arrancada, pedidas con curl. Es la primera petición de cada URL, salvo donde se repite:

/productos/1                        -> 200   ficha, prerenderizada
/productos/nuevo                    -> 200   alta (estático gana a [id])
/productos/7                        -> 200   ficha, generada en esta petición
/productos/abc                      -> 200   no encontrado (1.ª petición)
/productos/abc                      -> 404   no encontrado (2.ª petición)
/productos?q=portatil               -> 200   3 resultados
/catalogo                           -> 200
/catalogo/informatica/perifericos   -> 200
/catalogo/perifericos               -> 200   no encontrado (1.ª petición)
/ayuda                              -> 200   portada (ejercicio 5)
/ayuda/pagos/tarjeta                -> 200
/ayuda/nada                         -> 200   no encontrado (1.ª petición)
/departamentos/20                   -> 200
/departamentos/70                   -> 200   INVESTIGACIÓN, generada en esta petición
/departamentos/99                   -> 200   no encontrado (1.ª petición)
/departamentos/20/empleados/101     -> 200
/departamentos/10/empleados/101     -> 200   no encontrado (1.ª petición)
/clientes/1001                      -> 200
/clientes/1001/pedidos              -> 200   3 pedidos
/clientes/abc                       -> 200   no encontrado (1.ª petición)
/empleados?orden=-salario&pagina=2  -> 200   página 2 de 3
/pedidos/5001                       -> 200
/pedidos/1                          -> 200   no encontrado, dentro de Suspense
/aviso-legal                        -> 200
/proyectos/99                       -> 200   no encontrado (1.ª petición)
/proyectos/99                       -> 404   no encontrado (2.ª petición)
/_borradores                        -> 404   carpeta privada
/_interno                           -> 200
/tienda/productos/12                -> 404   los grupos no están en la URL
/productos/12/editar                -> 404   [id] casa con un solo tramo

Repasa cada línea y explica por qué responde así: es un buen resumen de todo el módulo.


12. Errores frecuentes

Síntoma Causa Solución
Property 'id' does not exist on type 'Promise<...>' Acceso síncrono a params, como en Next.js 14 const { id } = await params (o use(params) en una página cliente)
params.id es undefined en JavaScript sin tipos El mismo acceso síncrono, sin TypeScript que avise Igual que el anterior; usa TypeScript
encountered uncached or runtime data during prerendering params sin generateStaticParams, o searchParams, leídos fuera de <Suspense> Pasa la promesa a un hijo dentro de <Suspense>, o da valores con generateStaticParams
encountered URL data useParams() in a Client Component outside of <Suspense> useParams en un layout que envuelve rutas cuyo valor solo se conoce en la petición Envuelve el componente en <Suspense> con un fallback sin el dato
all generateStaticParams functions must return at least one result Lista vacía con Cache Components, también en una sola llamada de una función hija Devuelve al menos un valor; filtra los padres sin hijos
"dynamicParams" is not compatible with nextConfig.cacheComponents Opción de Next.js 15 en un proyecto 16 con Cache Components Quítala y valida con notFound()
You cannot have two parallel pages that resolve to the same path Dos grupos con la misma ruta dentro Renombra una o dale un segmento real
You cannot use different slug names for the same dynamic path ('id' !== 'slug') al arrancar [id] y [slug] al mismo nivel; el build no lo detecta Usa el mismo nombre en todas las carpetas dinámicas de un nivel
You cannot define a route with the same specificity as an optional catch-all route page.tsx y [[...ruta]] en la misma carpeta Quita uno: o [[...ruta]] sola, o page.tsx con [...ruta]
El id llega como '7' y la comparación con 7 falla Los parámetros son siempre texto Convierte con validación (leerIdNumerico) o compara con String(...)
/productos/abc llama a la API con NaN Conversión sin validar Valida la forma antes de convertir
La misma página responde en /clientes/42 y /clientes/042 Number() acepta ceros a la izquierda Expresión regular sin ceros iniciales, o redirigir a la forma canónica
Se ve un empleado bajo un departamento que no es el suyo No se comprueba la relación entre parámetros Comprueba que el hijo pertenece al padre
?cat=2&cat=4 rompe la página El parámetro llega como array Normaliza con una función como primero()
La primera visita a un id inexistente da 200 Comportamiento de Cache Components: el armazón ya salió Es correcto (lleva noindex); las siguientes dan 404
next build falla al recoger datos de la página La API no estaba en marcha durante el build Arranca la API antes de compilar

Dos de ellos merecen una explicación más larga.

12.1. Nombres distintos para el mismo nivel

Imagina que, para la página de edición, alguien crea productos/[slug]/editar/page.tsx junto a la ficha productos/[id]/page.tsx. Al mismo nivel conviven dos segmentos dinámicos con nombres distintos. Lo hemos probado con 16.4, y el resultado es traicionero: next build termina sin errores, con las dos rutas en la tabla. Es al arrancar el servidor cuando todo se viene abajo:

▲ Next.js 16.4.3
✓ Ready in 1454ms
Error: You cannot use different slug names for the same dynamic path ('id' !== 'slug').

El servidor se detiene y ninguna página responde, ni siquiera las que no tienen nada que ver. El router construye un árbol con un hueco dinámico por nivel, y ese hueco solo puede tener un nombre. La solución es usar el mismo: productos/[id]/editar/page.tsx.

💡 Consejo: Que el build pase no garantiza que la aplicación arranque. Incluye siempre un next start y una petición de prueba en tu flujo de comprobación, sobre todo antes de desplegar (módulo 23).

12.2. El componente de cliente que bloquea el prerender

Al añadir el menú con useParams al layout de gestión (ejercicio 6), el build, que hasta entonces funcionaba, falló en una página que no se había tocado:

Error: Route "/pedidos/[pedidoId]": Next.js encountered URL data `useParams()` in a Client Component outside of `<Suspense>`.

This blocks prerendering because the value is only available at runtime.

Ways to fix this:
  - [stream] Wrap the component in `<Suspense fallback={...}>` so the hook value streams in after prerendering
  - [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-client-hook

El razonamiento es el mismo que con params. El layout de gestión envuelve también /pedidos/[pedidoId], que no tiene generateStaticParams. Para esa ruta, Next quiere prerenderizar el armazón, menú incluido, sin conocer la URL. Pero el menú pregunta por la URL con useParams. Al ser un Client Component, se renderiza también en el servidor para producir el HTML inicial, y ahí el valor no existe todavía.

Con <Suspense>, el armazón lleva el fallback (el menú sin resaltar) y el menú definitivo llega con la petición. Por eso el componente se ha partido en dos: ListaDepartamentos, que no usa hooks y sirve de fallback, y MenuDepartamentos, que añade useParams.

📘 Concepto: Que un componente sea de cliente no lo saca del prerender: los Client Components también se renderizan en el servidor para el HTML inicial. Lo que los diferencia es que además se hidratan en el navegador. Lo verás a fondo en el módulo 05.


13. Ejercicios

Todos los ejercicios resueltos forman parte de la aplicación del caso práctico y se han compilado y probado con ella. Necesitas la API EMPRESA en marcha.

Ejercicio 1: Ficha de cliente

Crea la ruta /clientes/[id] en la zona de gestión con la ficha de un cliente de la API (GET /api/clientes/:id): NIF, correo, localidad y límite de crédito. Prerenderiza todos los clientes en el build, valida el id con una función reutilizable leerIdNumerico en app/_lib/validar.ts y responde con 404 si no es válido o no existe. Pon el nombre del cliente y unas pestañas «Ficha» y «Pedidos» en un layout, porque el ejercicio 2 las reutilizará.

Ver solución

La función de validación ya la viste en el apartado 2.3 (app/_lib/validar.ts). Las funciones de datos de clientes solo las usa esta ruta, así que se colocan junto a ella, en un fichero que no es page.tsx ni layout.tsx:

// app/(gestion)/clientes/[id]/_datos.ts
// Datos de clientes, colocados junto a las rutas que los usan
const API = process.env.API_URL ?? 'http://localhost:3000/api'

export type Cliente = {
  clienteId: number
  nombre: string
  nif: string
  email: string
  ciudad: string
  provincia: string
  limiteCredito: number
}

export async function obtenerCliente(id: number) {
  'use cache'
  const respuesta = await fetch(`${API}/clientes/${id}`)
  if (respuesta.status === 404) return null
  return (await respuesta.json()) as Cliente
}

export async function obtenerClientes() {
  'use cache'
  const respuesta = await fetch(`${API}/clientes?tamano=100`)
  const { contenido }: { contenido: Cliente[] } = await respuesta.json()
  return contenido
}

El generateStaticParams va en el layout, no en la página. Así sirve para todas las páginas que cuelgan de [id]: la ficha y la de pedidos del ejercicio 2.

// app/(gestion)/clientes/[id]/layout.tsx
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { leerIdNumerico } from '@/app/_lib/validar'
import { obtenerCliente, obtenerClientes } from './_datos'

// En el layout: sirve para la ficha y para /pedidos, que cuelgan de él
export async function generateStaticParams() {
  const clientes = await obtenerClientes()
  return clientes.map((c) => ({ id: String(c.clienteId) }))
}

export default async function LayoutCliente({ children, params }: LayoutProps<'/clientes/[id]'>) {
  const { id } = await params
  const cliente = await obtenerCliente(leerIdNumerico(id))
  if (!cliente) notFound()

  return (
    <section>
      <h1 className="text-2xl font-bold text-indigo-900">{cliente.nombre}</h1>
      <nav className="my-3 flex gap-4 text-sm">
        <Link href={`/clientes/${id}`} className="text-indigo-700 hover:underline">Ficha</Link>
        <Link href={`/clientes/${id}/pedidos`} className="text-indigo-700 hover:underline">Pedidos</Link>
      </nav>
      {children}
    </section>
  )
}
// app/(gestion)/clientes/[id]/page.tsx
import { leerIdNumerico } from '@/app/_lib/validar'
import { obtenerCliente } from './_datos'

export default async function FichaCliente({ params }: PageProps<'/clientes/[id]'>) {
  const { id } = await params
  // El layout ya ha comprobado que existe; la llamada repetida sale de la caché
  const cliente = (await obtenerCliente(leerIdNumerico(id)))!

  return (
    <dl className="grid grid-cols-2 gap-2 rounded-lg bg-white p-6 shadow">
      <dt className="font-semibold">NIF</dt><dd>{cliente.nif}</dd>
      <dt className="font-semibold">Correo</dt><dd>{cliente.email}</dd>
      <dt className="font-semibold">Localidad</dt><dd>{cliente.ciudad} ({cliente.provincia})</dd>
      <dt className="font-semibold">Límite de crédito</dt><dd>{cliente.limiteCredito.toLocaleString('es-ES')} €</dd>
    </dl>
  )
}

Resultado comprobado: /clientes/1001 muestra la ficha de Talleres Martín S.L. con el NIF B12345678. /clientes/1015 (no existe; los clientes van del 1001 al 1014), /clientes/abc y /clientes/0042 muestran la página de no encontrado. En la tabla del build, los catorce clientes aparecen como ○.

El layout y la página piden el mismo cliente, pero la segunda llamada a obtenerCliente sale de la caché de 'use cache', no de la API. El ! final le dice a TypeScript que no será null: si lo fuera, el layout ya habría llamado a notFound().

Ejercicio 2: Pedidos de un cliente

Añade /clientes/[id]/pedidos con una tabla de los pedidos del cliente (GET /api/clientes/:id/pedidos): número, fecha, estado y total. Cada número debe enlazar a /pedidos/[pedidoId]. Si el cliente no tiene pedidos, muestra un mensaje. ¿Necesita esta página su propio generateStaticParams?

Ver solución

No lo necesita: los valores de [id] ya los genera el layout de la carpeta padre, y se aplican a todo lo que cuelga de ella. Tampoco hace falta volver a comprobar que el cliente existe: si no existiera, el layout habría cortado con notFound() antes.

// app/(gestion)/clientes/[id]/pedidos/page.tsx
import Link from 'next/link'
import { leerIdNumerico } from '@/app/_lib/validar'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

type PedidoResumen = { pedidoId: number; fechaPedido: string; estado: string; total: number }

async function obtenerPedidosDe(clienteId: number) {
  'use cache'
  const respuesta = await fetch(`${API}/clientes/${clienteId}/pedidos?tamano=100`)
  if (!respuesta.ok) return []
  const { contenido }: { contenido: PedidoResumen[] } = await respuesta.json()
  return contenido
}

// Sin generateStaticParams propio: usa los valores que genera el layout
export default async function PedidosCliente({ params }: PageProps<'/clientes/[id]/pedidos'>) {
  const { id } = await params
  const pedidos = await obtenerPedidosDe(leerIdNumerico(id))

  if (pedidos.length === 0) return <p>Este cliente no tiene pedidos.</p>

  return (
    <table className="w-full bg-white text-sm shadow">
      <thead className="bg-indigo-100 text-left">
        <tr><th className="p-2">Pedido</th><th className="p-2">Fecha</th><th className="p-2">Estado</th><th className="p-2 text-right">Total</th></tr>
      </thead>
      <tbody>
        {pedidos.map((p) => (
          <tr key={p.pedidoId} className="border-t">
            <td className="p-2"><Link href={`/pedidos/${p.pedidoId}`} className="text-indigo-700 hover:underline">{p.pedidoId}</Link></td>
            <td className="p-2">{p.fechaPedido}</td>
            <td className="p-2">{p.estado}</td>
            <td className="p-2 text-right">{p.total.toLocaleString('es-ES')} €</td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

En la tabla del build, la ruta aparece con los catorce clientes generados:

├   /clientes/[id]/pedidos                              15m      1y
│ ├ ◐ /clientes/[id]/pedidos                            15m      1y
│ ├ ○ /clientes/1001/pedidos                            15m      1y
│ ├ ○ /clientes/1002/pedidos                            15m      1y
│ └ ◐ [+12 more paths]

/clientes/1001/pedidos muestra una tabla con tres pedidos.

Ejercicio 3: ¿Qué URL, qué carpeta?

Sin ejecutar nada, indica qué fichero responde a cada URL y qué params recibe, con esta estructura:

app/
├── (tienda)/productos/[id]/page.tsx
├── (tienda)/productos/nuevo/page.tsx
├── (tienda)/ayuda/page.tsx
├── (tienda)/ayuda/[...tema]/page.tsx
├── (tienda)/catalogo/[[...ruta]]/page.tsx
└── _borradores/page.tsx

URLs: /productos/nuevo, /productos/12, /productos/12/editar, /ayuda, /ayuda/pagos/tarjeta, /catalogo, /catalogo/oficina/mobiliario, /_borradores, /tienda/productos/12.

Ver solución
URL Fichero params
/productos/nuevo productos/nuevo/page.tsx (el estático gana) {}
/productos/12 productos/[id]/page.tsx { id: '12' }
/productos/12/editar Ninguno: [id] casa con un solo tramo 404
/ayuda ayuda/page.tsx {}
/ayuda/pagos/tarjeta ayuda/[...tema]/page.tsx { tema: ['pagos', 'tarjeta'] }
/catalogo catalogo/[[...ruta]]/page.tsx { ruta: undefined }
/catalogo/oficina/mobiliario catalogo/[[...ruta]]/page.tsx { ruta: ['oficina', 'mobiliario'] }
/_borradores Ninguno: carpeta privada 404
/tienda/productos/12 Ninguno: los grupos no forman parte de la URL 404

Todas las respuestas de la columna «Fichero» que se pueden pedir a la aplicación del módulo se han comprobado en el caso práctico.

Ejercicio 4: Lista de empleados con orden y paginación

Crea /empleados con una tabla paginada (8 por página) de todos los empleados. La URL controla la página (?pagina=2) y el orden (?orden=-salario). Solo se admiten cuatro órdenes: nombre, apellidos, salario ascendente y salario descendente; cualquier otro valor debe tratarse como «apellidos». Añade enlaces para cambiar de orden (volviendo a la página 1) y para pasar de página (conservando el orden).

Ver solución
// app/(gestion)/empleados/page.tsx
import { Suspense } from 'react'
import Link from 'next/link'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

type Empleado = { empId: number; nombre: string; apellidos: string; oficio: string; salario: number }
type Pagina = { contenido: Empleado[]; pagina: number; paginas: number; total: number }

// Lista blanca: solo estos órdenes llegan a la API
const ORDENES = { nombre: 'Nombre', apellidos: 'Apellidos', salario: 'Salario (menor primero)', '-salario': 'Salario (mayor primero)' } as const
type Orden = keyof typeof ORDENES

function esOrden(valor: unknown): valor is Orden {
  return typeof valor === 'string' && valor in ORDENES
}

async function obtenerEmpleados(pagina: number, orden: Orden) {
  'use cache'
  const params = new URLSearchParams({ pagina: String(pagina), orden, tamano: '8' })
  const respuesta = await fetch(`${API}/empleados?${params}`)
  return (await respuesta.json()) as Pagina
}

export default function PaginaEmpleados({ searchParams }: PageProps<'/empleados'>) {
  return (
    <main>
      <h1 className="mb-4 text-2xl font-bold text-indigo-900">Empleados</h1>
      <Suspense fallback={<p>Cargando empleados...</p>}>
        <Listado searchParams={searchParams} />
      </Suspense>
    </main>
  )
}

async function Listado({ searchParams }: Pick<PageProps<'/empleados'>, 'searchParams'>) {
  const { pagina: textoPagina, orden: textoOrden } = await searchParams
  const orden: Orden = esOrden(textoOrden) ? textoOrden : 'apellidos'
  const pagina = Math.max(1, Number(textoPagina) || 1)
  const datos = await obtenerEmpleados(pagina, orden)

  const enlace = (cambios: { pagina?: number; orden?: Orden }) =>
    `/empleados?${new URLSearchParams({ orden: cambios.orden ?? orden, pagina: String(cambios.pagina ?? pagina) })}`

  return (
    <>
      <p className="mb-2 flex gap-3 text-sm">
        Ordenar por:
        {(Object.keys(ORDENES) as Orden[]).map((o) => (
          <Link key={o} href={enlace({ orden: o, pagina: 1 })}
            className={o === orden ? 'font-bold text-slate-900' : 'text-indigo-700 hover:underline'}>
            {ORDENES[o]}
          </Link>
        ))}
      </p>
      <table className="w-full bg-white text-sm shadow">
        <tbody>
          {datos.contenido.map((e) => (
            <tr key={e.empId} className="border-t">
              <td className="p-2">{e.apellidos}, {e.nombre}</td>
              <td className="p-2">{e.oficio}</td>
              <td className="p-2 text-right">{e.salario.toLocaleString('es-ES')} €</td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="mt-3 flex gap-4 text-sm">
        {pagina > 1 && <Link href={enlace({ pagina: pagina - 1 })} className="text-indigo-700">← Anterior</Link>}
        <span>Página {datos.pagina} de {datos.paginas}</span>
        {pagina < datos.paginas && <Link href={enlace({ pagina: pagina + 1 })} className="text-indigo-700">Siguiente →</Link>}
      </p>
    </>
  )
}

Claves de la solución:

  • esOrden es un predicado de tipo: si devuelve true, TypeScript sabe que el valor es un Orden. valor in ORDENES comprueba que es una de las claves permitidas.
  • Una función 'use cache' puede recibir argumentos: la caché guarda un resultado por cada combinación de página y orden.
  • enlace parte de los valores actuales y aplica solo los cambios, así que el orden se conserva al paginar.

Resultado comprobado: /empleados?orden=-salario&pagina=2 muestra «Página 2 de 3» con los salarios de mayor a menor, y /empleados?orden=drop no rompe nada: se ordena por apellidos, que aparece en negrita como orden activo.

Lista de empleados ordenada por salario de mayor a menor, página 2 de 3

Figura 10. /empleados?orden=-salario&pagina=2. El orden activo aparece en negrita.

Ejercicio 5: Portada de la ayuda

Ahora /ayuda da 404, porque [...tema] exige al menos un tramo. Añade una portada en /ayuda con la lista de los artículos principales (los que no tienen / en su clave), sin convertir el segmento en opcional. Después, cambia el enlace «Ayuda» de la barra superior y la primera miga de pan para que apunten a /ayuda.

Ver solución

Un page.tsx en la carpeta ayuda convive con ayuda/[...tema]: el primero responde en /ayuda y el segundo a partir de /ayuda/algo. No hay conflicto, al contrario que con [[...ruta]] (apartado 6.3).

// app/(tienda)/ayuda/page.tsx
import Link from 'next/link'
import { articulos } from './_contenido'

// /ayuda: portada del centro de ayuda. Convive con ayuda/[...tema]/page.tsx,
// que solo casa a partir de un tramo (/ayuda/algo).
export default function PortadaAyuda() {
  const principales = Object.entries(articulos).filter(([clave]) => !clave.includes('/'))
  return (
    <main className="rounded-lg bg-white p-6 shadow">
      <h1 className="text-2xl font-bold text-indigo-900">Centro de ayuda</h1>
      <ul className="mt-3 list-disc pl-6">
        {principales.map(([clave, articulo]) => (
          <li key={clave}><Link href={`/ayuda/${clave}`} className="text-indigo-700 hover:underline">{articulo.titulo}</Link></li>
        ))}
      </ul>
    </main>
  )
}

Después, en app/layout.tsx, href="/ayuda/envios" pasa a href="/ayuda", y en ayuda/[...tema]/page.tsx la primera miga queda como { texto: 'Ayuda', href: '/ayuda' }.

Resultado comprobado: /ayuda responde 200 y en la tabla del build aparece como ○ /ayuda, estática, porque no lee ni params ni searchParams.

Ejercicio 6: Resaltar el departamento activo

El menú lateral de gestión debe resaltar el departamento que estás viendo. El problema: el layout de (gestion) no recibe los params de las rutas hijas (deptId es de departamentos/[deptId], que está por debajo). Resuélvelo con un Client Component y useParams. Comprueba después que next build sigue funcionando para todas las rutas del grupo.

Ver solución

useParams devuelve los parámetros de la ruta actual completa, esté donde esté el componente. En /departamentos/20 devuelve { deptId: '20' }; en /clientes/1001, { id: '1001' }, sin deptId, y por eso se tipa como opcional.

// app/(gestion)/_componentes/MenuDepartamentos.tsx
'use client'

import Link from 'next/link'
import { useParams } from 'next/navigation'

type Opcion = { deptId: number; nombre: string }

// Lista sin hooks: sirve también de fallback mientras no se conoce la URL
export function ListaDepartamentos({ departamentos, activo }: { departamentos: Opcion[]; activo?: string }) {
  return (
    <ul className="space-y-1">
      {departamentos.map((d) => {
        const esActivo = String(d.deptId) === activo
        return (
          <li key={d.deptId}>
            <Link href={`/departamentos/${d.deptId}`} aria-current={esActivo ? 'page' : undefined}
              className={esActivo ? 'block rounded bg-indigo-700 px-2 text-white' : 'block px-2 text-indigo-700 hover:underline'}>
              {d.nombre}
            </Link>
          </li>
        )
      })}
    </ul>
  )
}

// El layout no recibe los params de sus hijos: este componente
// los lee en el cliente con useParams para resaltar el activo.
export function MenuDepartamentos({ departamentos }: { departamentos: Opcion[] }) {
  const { deptId } = useParams<{ deptId?: string }>()
  return <ListaDepartamentos departamentos={departamentos} activo={deptId} />
}

El layout de gestión (apartado 9.2) lo usa dentro de <Suspense>. La primera versión de esta solución no lo hacía, y el build falló en /pedidos/[pedidoId], como se explica en el apartado 12.2. Era justo lo que pedía la última frase del enunciado: un cambio en un layout afecta a todas las rutas que cuelgan de él, y la que rompe puede no ser la que estás mirando.

Otros detalles:

  • El layout pasa al cliente solo deptId y nombre, no los objetos completos. Todo lo que se pasa a un Client Component viaja al navegador (módulo 05).
  • aria-current="page" indica a los lectores de pantalla cuál es el enlace activo; no es solo decoración.

Zona de gestión con VENTAS resaltado en el menú lateral y la tabla de empleados del departamento

Figura 11. /departamentos/20 con el departamento activo resaltado.

Ejercicio 7: Grupo para los textos legales

Crea /aviso-legal y /privacidad con un layout propio, más estrecho y con un índice de los dos textos, que no lleve el aviso de envíos de la tienda ni el menú de gestión. Las URLs no deben llevar ningún prefijo.

Ver solución

Un grupo (legal) al mismo nivel que (tienda) y (gestion):

// app/(legal)/layout.tsx
import Link from 'next/link'

// Grupo (legal): textos legales con un layout sobrio y su propio índice
export default function LayoutLegal({ children }: { children: React.ReactNode }) {
  return (
    <div className="mx-auto max-w-2xl p-8">
      <nav className="mb-6 flex gap-4 border-b pb-2 text-sm">
        <Link href="/aviso-legal" className="text-indigo-700 hover:underline">Aviso legal</Link>
        <Link href="/privacidad" className="text-indigo-700 hover:underline">Privacidad</Link>
      </nav>
      <article className="space-y-3 leading-relaxed">{children}</article>
    </div>
  )
}
// app/(legal)/aviso-legal/page.tsx
export default function AvisoLegal() {
  return (
    <>
      <h1 className="text-2xl font-bold text-indigo-900">Aviso legal</h1>
      <p>EMPRESA S.L. es la titular de este sitio web.</p>
    </>
  )
}
// app/(legal)/privacidad/page.tsx
export default function Privacidad() {
  return (
    <>
      <h1 className="text-2xl font-bold text-indigo-900">Política de privacidad</h1>
      <p>Solo tratamos los datos necesarios para gestionar tus pedidos.</p>
    </>
  )
}

Las dos páginas heredan el layout raíz (la barra superior) y el de (legal), pero no los de los otros grupos. Ambas responden 200 y son estáticas (○) en la tabla del build.

Ejercicio 8: Proyectos con su propio 404

Crea /proyectos/[id] con el nombre, las fechas y el presupuesto de un proyecto (GET /api/proyectos/:id), prerenderizando todos. Si el proyecto no existe, debe mostrarse un mensaje propio «Proyecto no encontrado» dentro del layout de gestión, no el 404 general. Comprueba el código de estado de dos peticiones seguidas a un proyecto que no existe y explica el resultado.

Ver solución
// app/(gestion)/proyectos/[id]/page.tsx
import { notFound } from 'next/navigation'
import { leerIdNumerico } from '@/app/_lib/validar'

const API = process.env.API_URL ?? 'http://localhost:3000/api'

type Proyecto = { proyectoId: number; nombre: string; fechaInicio: string; fechaFin: string | null; presupuesto: number | null }

async function obtenerProyecto(id: number) {
  'use cache'
  const respuesta = await fetch(`${API}/proyectos/${id}`)
  if (respuesta.status === 404) return null
  return (await respuesta.json()) as Proyecto
}

async function obtenerProyectos() {
  'use cache'
  const respuesta = await fetch(`${API}/proyectos?tamano=100`)
  const { contenido }: { contenido: Proyecto[] } = await respuesta.json()
  return contenido
}

export async function generateStaticParams() {
  const proyectos = await obtenerProyectos()
  return proyectos.map((p) => ({ id: String(p.proyectoId) }))
}

export default async function PaginaProyecto({ params }: PageProps<'/proyectos/[id]'>) {
  const { id } = await params
  const proyecto = await obtenerProyecto(leerIdNumerico(id))
  if (!proyecto) notFound()

  return (
    <div className="rounded-lg bg-white p-6 shadow">
      <h1 className="text-2xl font-bold text-indigo-900">{proyecto.nombre}</h1>
      <p>Del {proyecto.fechaInicio} al {proyecto.fechaFin ?? '(sin fecha de fin)'}</p>
      <p>Presupuesto: {proyecto.presupuesto === null ? 'sin asignar' : `${proyecto.presupuesto.toLocaleString('es-ES')} €`}</p>
    </div>
  )
}

La primera versión tipaba presupuesto como number y hacía proyecto.presupuesto.toLocaleString(...) sin más. El build falló al prerenderizar uno de los proyectos:

Error occurred prerendering page "/proyectos/5". Read more: https://nextjs.org/docs/messages/prerender-error
TypeError: Cannot read properties of null (reading 'toLocaleString')
> 36 |       <p>Presupuesto: {proyecto.presupuesto.toLocaleString('es-ES')} €</p>
     |                                             ^
Export encountered an error on /(gestion)/proyectos/[id]/page: /proyectos/5, exiting the build.

El proyecto 5 no tiene presupuesto asignado. Es una ventaja poco comentada de generateStaticParams: al renderizar todas las páginas en el build, los datos raros aparecen antes de que los vea un usuario. Un tipo escrito a mano solo es una promesa; los datos reales mandan.

// app/(gestion)/proyectos/[id]/not-found.tsx
import Link from 'next/link'

export default function ProyectoNoEncontrado() {
  return (
    <div className="rounded-lg border border-orange-300 bg-orange-50 p-6">
      <h1 className="text-xl font-bold text-orange-900">Proyecto no encontrado</h1>
      <p className="mt-2">Revisa el número de proyecto.</p>
      <Link href="/departamentos/30" className="mt-4 inline-block text-indigo-700 underline">Volver a gestión</Link>
    </div>
  )
}

Resultado comprobado:

/proyectos/1   -> 200
/proyectos/5   -> 200   "Presupuesto: sin asignar"
/proyectos/99  -> 200   "Proyecto no encontrado", con noindex
/proyectos/99  -> 404

La primera petición a /proyectos/99 da 200 y la segunda 404, por lo explicado en el apartado 8.2: el 99 no estaba en la lista de generateStaticParams, así que la primera vez Next envía el armazón (con el 200) antes de que la página llame a notFound(). La respuesta lleva noindex y el mensaje correcto. Una vez guardado el resultado, las siguientes peticiones ya responden 404.

Ejercicio 9 (propuesto): Producto por categoría

Crea una ruta alternativa /tienda/[categoria]/[producto], donde categoria es el slug de la categoría (portatiles) y producto el id. Genera todos los pares de abajo arriba, desde la página. Comprueba que /tienda/monitores/1 da 404 aunque el producto 1 exista, porque no es un monitor. Pista: reutiliza aSlug de app/_lib/categorias.ts.

Ejercicio 10 (propuesto): Dos layouts raíz

Convierte la tienda y la gestión en dos aplicaciones con documentos HTML distintos: borra app/layout.tsx, crea un layout raíz con <html> y <body> en (tienda) y otro en (gestion) (con lang="es" y un <title> distinto en cada uno), y mueve la portada dentro de (tienda). Abre las herramientas de desarrollo del navegador, pestaña Red, y compara qué pasa al navegar entre dos páginas de la tienda y al saltar de la tienda a la gestión.

Ejercicio 11 (propuesto): Next.js 15

Copia la ficha de producto y el catálogo a un proyecto de Next.js 15. Quita las directivas 'use cache', añade export const dynamicParams = false al catálogo y compara la tabla del build y las respuestas de /catalogo/xx con las de este módulo.


Resumen

Concepto API Idea clave
Segmento dinámico [id] Casa con un tramo; el valor llega como string
Catch-all [...tema] Uno o más tramos; llega un string[]
Catch-all opcional [[...ruta]] Cero o más tramos; string[] \| undefined; no admite un page.tsx hermano
Parámetros de ruta 🔄 16.0 await params Siempre promesa en 16; en 15 aún se podía leer de forma síncrona
Parámetros de búsqueda await searchParams Solo en páginas; cada valor es string, string[] o undefined
Parámetros en el cliente useParams(), use(params) useParams ve toda la ruta actual; en un layout con rutas dinámicas, dentro de <Suspense>
Tipos generados PageProps<'/ruta'>, LayoutProps<'/ruta'> Globales, sin grupos en la ruta; se generan con dev, build o next typegen
Prerender generateStaticParams() Devuelve objetos con texto; lo no listado se genera en la primera visita
Varios segmentos generateStaticParams({ params }) De arriba abajo, la función hija recibe los params del padre como objeto normal
Cache Components 🔄 16.0 cacheComponents: true Lista no vacía, sin dynamicParams, params sin lista dentro de <Suspense>
No encontrado notFound(), not-found.tsx Interrumpe el render; se usa el not-found más cercano; sin props
Código 404   200 con noindex la primera vez si el armazón ya salió; 404 después
Grupos (tienda) Organizan y comparten layout sin aparecer en la URL; no pueden repetir ruta
Carpetas privadas _lib Fuera del enrutado, aunque tengan page.tsx; %5F para URLs con _
Colocación   Solo page y route publican; el resto puede vivir junto a su ruta