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
paramsysearchParamscomo 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
PagePropsyLayoutProps - Prerenderizar rutas dinámicas con
generateStaticParamsy 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 ficherosnot-found.tsxpor segmento - Organizar el proyecto con grupos de rutas
(grupo), carpetas privadas_carpetay 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 carpetaapp/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/101hay cuatro:departamentos,20,empleadosy101. 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 enhttp://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:
- El componente es
async. Es un Server Component, así que puede serlo (lo viste en el módulo 02). paramsno es un objeto, sino una promesa de un objeto. Hay que esperarla conawaitantes de leerid. El apartado 3 explica por qué.ides unstring, aunque en la URL ponga7. La URL es texto; convertirlo a número es cosa tuya.
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 generacreate-next-appen 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 confetchygenerateStaticParamsse 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).generateStaticParamsle dice a Next qué valores deiddebe prerenderizar al compilar (apartado 7).generateMetadatatambién recibeparams, 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 elnot-found.tsxmás cercano (apartado 8).

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.

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
paramsysearchParamsya 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: escribeawait paramsexactamente 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 paramsen el componente de página «marca» ese componente como dependiente de la URL. Si el valor está en la lista degenerateStaticParams, 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:
useParamsdevuelve 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 generacreate-next-appen 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:
searchParamses un objeto JavaScript normal, no una instancia deURLSearchParams. No tiene.get(); se lee con la notación de punto o de corchetes. Para construir URLs, en cambio,URLSearchParamses 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
searchParamsen 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 hacerawait searchParamsdirectamente 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
asyncy 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
actionni JavaScript: es un formulario HTML conmethod="get"implícito. Al enviarlo, el navegador navega a/productos?q=.... Funciona incluso antes de que cargue React. paginase sanea:Number('abc') || 1da 1, yMath.max(1, ...)evita páginas negativas.- Los enlaces de paginación conservan
qgracias aURLSearchParams, 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».

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
useSearchParamsy 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>
)
}

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

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 unpage.tsxpropio 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:
- Next llama a
generateStaticParamsuna vez. - Por cada objeto devuelto, renderiza la página con esos
paramsy guarda el HTML y el payload RSC. - Además guarda un armazón genérico para los valores que no estaban en la lista.
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,generateStaticParamsse ejecuta al navegar a la ruta, no al arrancar. - Las peticiones
fetchdentro degenerateStaticParamsse deduplican con las de la página y el layout, así que no pagas dos veces por los mismos datos. generateStaticParamssustituye agetStaticPathsdel Pages Router (módulo 24).
⚠️ Importante: Si
generateStaticParamspide datos a la API, la API tiene que estar en marcha durantenext 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 generacreate-next-appen 16),generateStaticParamsno puede devolver una lista vacía, la opcióndynamicParamsno existe y una página que leeparamssingenerateStaticParamsnecesita<Suspense>. Si usas Next.js 15: no hay Cache Components; puedes devolver[]para generarlo todo bajo demanda, usarexport const dynamicParams = falsepara que lo no listado dé 404, y leerparamssin<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 connotFound()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
paramsen el cuerpo de un layout singenerateStaticParams, 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(), denext/navigation, interrumpe el render de la página o el layout donde la llamas. - El fichero
not-found.tsxdefine 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>
)
}

Figura 8. /productos/999. El not-found.tsx del segmento se pinta dentro del layout de la tienda.
⚠️ Importante:
not-found.tsxno recibeparamsnisearchParams. 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 conuseParams. 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
noindexcumple 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 degenerateStaticParamsy 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.
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
- Compartir un layout entre rutas hermanas sin cambiar sus URLs, como acabas de ver.
- Dejar fuera de un layout a algunas rutas. Si
/aviso-legalno debe llevar el aviso de envíos, basta con no meterla en(tienda). El ejercicio 7 crea un grupo(legal)con su propio layout. - Ordenar por equipos o áreas. Aunque no tengan layout,
(marketing),(panel)o(api-interna)ayudan a encontrar las cosas en un proyecto grande. - Tener varios layouts raíz. Si borras
app/layout.tsxy 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.tsxolayout.tsxpara importarlas en otro sitio. Esos ficheros solo deben exportar lo que Next espera (el componente por defecto,generateStaticParams,generateMetadata,metadatay 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,_datossaltan 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-imageo un ficherodefault.tsxtienen 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-apppregunta si quieres usar una carpetasrc/. Si dices que sí,app/pasa asrc/app/y el código compartido suele ir ensrc/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 losimportde este módulo, como@/app/_lib/api) apunta a la raíz del proyecto o asrc/, 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 degenerateStaticParams.◐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),/productosy/empleados(porsearchParams) y/pedidos/[pedidoId](singenerateStaticParams).[+N more paths]resume las rutas generadas que no caben.- No aparecen ni
_borradoresni_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 starty 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:
esOrdenes un predicado de tipo: si devuelvetrue, TypeScript sabe que el valor es unOrden.valor in ORDENEScomprueba 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. enlaceparte 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.

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
deptIdynombre, 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.

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 |