Rutas dinámicas y parámetros en Next.js: guía completa de principiante a tipado seguro

La semana pasada, al refactorizar un proyecto Next.js, me topé con un problema frustrante: la ruta dinámica estaba escrita según la documentación, pero al entrar daba 404. La consola en silencio, sin ningún error. Al final descubrí que Next.js 14 en App Router cambió cómo se obtienen los parámetros de ruta, y yo seguía con el estilo antiguo de Pages Router.
No es la primera vez que tropiezo con el enrutamiento de Next.js. De getStaticPaths en Pages Router a generateStaticParams en App Router, cada actualización obliga a reaprender. ¿Cuándo usar rutas dinámicas? ¿Cuándo catch-all? ¿Y los parámetros opcionales? Mezclar estos conceptos confunde de verdad.
Si tú también te pierdes con las rutas dinámicas de Next.js, o estás migrando de Pages Router a App Router, este artículo es para ti. Empezaré por lo más básico y llegaré hasta prácticas de tipado seguro, con muchos ejemplos de código para aclarar ideas.
¿Qué te llevas al terminar? Un sistema completo de rutas dinámicas: qué tipo usar en cada escenario, cómo obtener parámetros correctamente y cómo conseguir autocompletado de TypeScript en los parámetros de ruta. Sin humo: código y soluciones concretas. Empecemos.
Capítulo 1: Fundamentos de rutas dinámicas (desde lo más simple)
¿Qué es una ruta dinámica?
El escenario más común: tienes un blog y cada artículo tiene URL /blog/ID-del-artículo. Con rutas estáticas tendrías que crear un archivo por artículo, lo cual no escala. Ahí entran las rutas dinámicas: un solo archivo de página para todos los detalles.
En Next.js App Router, las rutas dinámicas se implementan con carpetas nombradas entre corchetes. Suena enrevesado; mejor ver un ejemplo:
app/
├── blog/
│ └── [slug]/
│ └── page.tsx ← Esta es la ruta dinámica
Esta estructura coincide con todas las rutas /blog/*, por ejemplo:
/blog/hello-world→slug = "hello-world"/blog/nextjs-guide→slug = "nextjs-guide"/blog/123→slug = "123"
La implementación más simple
Crea app/blog/[slug]/page.tsx con este código:
// app/blog/[slug]/page.tsx
export default function BlogPost({
params
}: {
params: { slug: string }
}) {
return (
<div>
<h1>Detalle del artículo</h1>
<p>Slug actual: {params.slug}</p>
</div>
)
}
¡Así de simple! Cuando el usuario visita /blog/hello-world, params.slug es "hello-world".
Errores frecuentes de principiantes:
- ❌ Usar
[slug].tsxcomo nombre de archivo (en App Router hace falta carpeta) - ❌ Acceder a
props.slugdirectamente (hay que usar el objetoparams) - ❌ Olvidar los corchetes en el nombre de carpeta (sin ellos es ruta estática)
Pages Router vs App Router
Si antes usaste Pages Router, puede parecer raro: «¿No era en pages/blog/[slug].tsx?» Sí, App Router cambió bastante:
| Característica | Pages Router | App Router |
|---|---|---|
| Ubicación | pages/blog/[slug].tsx | app/blog/[slug]/page.tsx |
| Obtener parámetros | router.query.slug o getStaticProps | params.slug |
| Tipos | Definición manual | Inferencia por tipos de props |
| Generación estática | getStaticPaths | generateStaticParams |
Al migrar, lo que más me costó fue cómo obtener parámetros. En Pages Router podías usar el hook useRouter; en Server Components de App Router no hay hooks, solo la prop params. Los Server Components se renderizan en el servidor por defecto, sin objeto router en el cliente.
Caso práctico: ficha de producto en e-commerce
Supón un e-commerce con URL /products/ID-del-producto. La implementación completa:
// app/products/[id]/page.tsx
interface Product {
id: string
name: string
price: number
description: string
}
// Simulación de consulta a base de datos
async function getProduct(id: string): Promise<Product | null> {
// En un proyecto real: consulta a BD o llamada API
const products: Product[] = [
{ id: '1', name: 'Libro introductorio de TypeScript', price: 99, description: 'Ideal para principiantes' },
{ id: '2', name: 'Guía práctica de React', price: 129, description: 'De cero al despliegue' }
]
return products.find(p => p.id === id) || null
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product = await getProduct(params.id)
if (!product) {
return <div>Producto no encontrado</div>
}
return (
<div>
<h1>{product.name}</h1>
<p className="price">¥{product.price}</p>
<p>{product.description}</p>
</div>
)
}
Detalles a tener en cuenta:
- El componente es
asyncporque los Server Components lo permiten - Primero obtienes datos, luego decides qué renderizar
- Gestionas el caso de producto inexistente (escenario 404)
Para una página 404 real, usa notFound de Next.js:
import { notFound } from 'next/navigation'
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product = await getProduct(params.id)
if (!product) {
notFound() // Devuelve página 404
}
return (
<div>
<h1>{product.name}</h1>
{/* ... */}
</div>
)
}
Así, al visitar un producto inexistente, se muestra tu not-found.tsx personalizado.
Hasta aquí, lo básico de rutas dinámicas. Pero es la punta del iceberg; a continuación, escenarios más complejos cuando necesitas coincidir con rutas de varios niveles.
Capítulo 2: Rutas Catch-All y parámetros opcionales (rutas complejas)
¿Cuándo necesitas Catch-All?
Imagina un sitio de documentación con URLs como:
/docs/getting-started/docs/api/authentication/docs/api/database/queries/docs/guides/deployment/vercel
La profundidad varía: 2, 3 o más niveles. Una ruta dinámica simple no basta; necesitas rutas Catch-All.
Catch-All: [...slug]
Nombra la carpeta [...slug] (tres puntos) para coincidir con cualquier profundidad:
app/
├── docs/
│ └── [...slug]/
│ └── page.tsx ← Coincide con todo bajo /docs/*
Coincide con:
/docs/getting-started→slug = ["getting-started"]/docs/api/authentication→slug = ["api", "authentication"]/docs/guides/deployment/vercel→slug = ["guides", "deployment", "vercel"]
Importante: slug es un array, no un string.
Implementación: sistema de documentación
// app/docs/[...slug]/page.tsx
interface Doc {
title: string
content: string
}
// Obtener documento según array de ruta
async function getDoc(slugArray: string[]): Promise<Doc | null> {
// Unir el array: ["api", "auth"] → "api/auth"
const path = slugArray.join('/')
// En un proyecto real: sistema de archivos o base de datos
const docs: Record<string, Doc> = {
'getting-started': {
title: 'Primeros pasos',
content: 'Bienvenido a nuestro producto...'
},
'api/authentication': {
title: 'Autenticación API',
content: 'Usamos JWT para autenticación...'
},
'api/database/queries': {
title: 'Consultas a la base de datos',
content: 'Consulta la base de datos con Prisma...'
}
}
return docs[path] || null
}
export default async function DocsPage({
params
}: {
params: { slug: string[] }
}) {
const doc = await getDoc(params.slug)
if (!doc) {
return <div>Documento no encontrado</div>
}
return (
<article>
<h1>{doc.title}</h1>
<div dangerouslySetInnerHTML={{ __html: doc.content }} />
{/* Navegación breadcrumb */}
<nav>
<a href="/docs">Documentación</a>
{params.slug.map((segment, i) => {
const href = `/docs/${params.slug.slice(0, i + 1).join('/')}`
return (
<span key={i}>
{' / '}
<a href={href}>{segment}</a>
</span>
)
})}
</nav>
</article>
)
}
Puntos fuertes de este código:
slugArray.join('/')convierte el array en string de ruta- Breadcrumb con
slicepara prefijos de ruta - Tipo
params: { slug: string[] }para que TypeScript valide
Catch-All opcional: [[...slug]]
A veces quieres coincidir tanto con /docs como con /docs/*. El Catch-All normal no coincide con /docs (sin parámetros); usa Catch-All opcional:
app/
├── docs/
│ └── [[...slug]]/
│ └── page.tsx ← Doble corchete
Coincide con:
/docs→slug = undefined/docs/getting-started→slug = ["getting-started"]/docs/api/auth→slug = ["api", "auth"]
En código, gestiona que slug puede ser undefined:
// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
params
}: {
params: { slug?: string[] } // slug es opcional
}) {
// Si es la portada /docs
if (!params.slug) {
return <div>Bienvenido al centro de documentación</div>
}
// Subrutas
const doc = await getDoc(params.slug)
// ...
}
Trampas habituales
Trampa 1: olvidar que slug es array
// ❌ Incorrecto
<h1>Ruta actual: {params.slug}</h1> // Muestra "api,authentication"
// ✅ Correcto
<h1>Ruta actual: {params.slug.join('/')}</h1> // Muestra "api/authentication"
Trampa 2: estructura incorrecta en generación estática
// ❌ Incorrecto
export function generateStaticParams() {
return [
{ slug: 'api/auth' } // Es string, no array
]
}
// ✅ Correcto
export function generateStaticParams() {
return [
{ slug: ['api', 'auth'] } // Forma de array
]
}
Trampa 3: mezclar rutas dinámicas y Catch-All
| Tipo | Nombre de carpeta | Alcance | Tipo de parámetro |
|---|---|---|---|
| Dinámica | [slug] | /blog/123 | string |
| Catch-All | [...slug] | /docs/a/b/c (sin /docs) | string[] |
| Catch-All opcional | [[...slug]] | /docs y /docs/a/b/c | string[] | undefined |
Yo mezclé los tres y las rutas funcionaban a ratos; al final era un error en el nombre de carpeta.
Truco práctico: caracteres especiales
Si la URL lleva caracteres especiales o no ASCII, codifica y decodifica:
export default async function Page({
params
}: {
params: { slug: string[] }
}) {
// La URL se codifica automáticamente; decodifica para mostrar
const decodedSlug = params.slug.map(s => decodeURIComponent(s))
console.log(params.slug) // ["api", "%61%75%74%65%6E%74%69%63%61%63%69%C3%B3%6E"]
console.log(decodedSlug) // ["api", "autenticación"]
// ...
}
Ya puedes manejar rutas complejas. Falta decidir cuándo generar esas páginas dinámicas: ¿en cada petición o en build? Eso es generateStaticParams, en el siguiente capítulo.
Capítulo 3: generateStaticParams en profundidad (cuándo y cómo)
¿Por qué generateStaticParams?
Supón un blog con 100 artículos en /blog/[slug]. Sin optimizar, cada visita implica:
- Consultar la base de datos
- Renderizar HTML en servidor
- Devolver al usuario
Lento y con carga en el servidor. Next.js ofrece pre-renderizar en build todas las páginas de artículos como HTML estático. Eso hace generateStaticParams.
Uso básico: artículos de blog estáticos
// app/blog/[slug]/page.tsx
interface Post {
slug: string
title: string
content: string
}
// Obtener todos los slugs
export async function generateStaticParams() {
// Desde BD o CMS
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
// Todas las combinaciones posibles
return posts.map((post: Post) => ({
slug: post.slug
}))
}
// Renderizar detalle
export default async function BlogPost({
params
}: {
params: { slug: string }
}) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`)
.then(r => r.json())
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
¿Qué hace?
generateStaticParamscorre en build y devuelve todos los slugs- Next.js pre-renderiza un HTML estático por slug
- La visita sirve el archivo estático, muy rápido
Artefactos tras el build:
.next/server/app/blog/
├── hello-world.html
├── nextjs-guide.html
└── typescript-tips.html
¿Cuándo usar generateStaticParams?
La pregunta que más recibo. Regla simple:
✅ Adecuado:
- Artículos de blog, noticias (contenido relativamente fijo)
- Fichas de producto (cantidad limitada, p. ej. < 10000)
- Documentación, centro de ayuda
- Perfiles de usuario (si el volumen no es enorme)
❌ No adecuado:
- Páginas de búsqueda (combinaciones infinitas)
- Datos en tiempo real (bolsa, deportes)
- Plataformas UGC masivas (no puedes pre-renderizar todo)
- Contenido distinto según sesión de login
Avanzado 1: generación estática en Catch-All
En [...slug], el parámetro devuelto debe ser array:
// app/docs/[...slug]/page.tsx
export async function generateStaticParams() {
const docPaths = [
['getting-started'],
['api', 'authentication'],
['api', 'database', 'queries'],
['guides', 'deployment', 'vercel']
]
return docPaths.map(slug => ({ slug }))
}
export default async function DocsPage({
params
}: {
params: { slug: string[] }
}) {
// ...
}
Nota: el formato es { slug: ['api', 'auth'] }, no { slug: 'api/auth' }.
Avanzado 2: varios parámetros
Ruta con varios dinámicos, p. ej. /shop/[category]/[productId]:
app/
├── shop/
│ └── [category]/
│ └── [productId]/
│ └── page.tsx
generateStaticParams así:
// app/shop/[category]/[productId]/page.tsx
export async function generateStaticParams() {
const products = [
{ category: 'electronics', productId: 'iphone-15' },
{ category: 'electronics', productId: 'macbook-pro' },
{ category: 'books', productId: 'clean-code' },
{ category: 'books', productId: 'refactoring' }
]
return products.map(p => ({
category: p.category,
productId: p.productId
}))
}
export default async function ProductPage({
params
}: {
params: { category: string; productId: string }
}) {
return (
<div>
<h1>Categoría: {params.category}</h1>
<p>ID del producto: {params.productId}</p>
</div>
)
}
Avanzado 3: generación bajo demanda (modo fallback)
Con 100 000 artículos, pre-renderizar todo no es viable. Genera lo popular y el resto bajo demanda:
// app/blog/[slug]/page.tsx
export const dynamicParams = true // Permite generar páginas no pre-renderizadas
export async function generateStaticParams() {
// Solo los 100 artículos más visitados
const topPosts = await fetchTopPosts(100)
return topPosts.map(post => ({
slug: post.slug
}))
}
export default async function BlogPost({
params
}: {
params: { slug: string }
}) {
// Aunque no esté pre-renderizado, la primera visita genera y cachea
const post = await fetchPost(params.slug)
if (!post) {
notFound()
}
return <article>{/* ... */}</article>
}
Con dynamicParams = true:
- Pre-renderizadas: respuesta inmediata (más rápido)
- No pre-renderizadas: se generan en la primera petición y se cachean
- Inexistentes: 404
Donde suelen atascarse los principiantes
Pregunta 1: ¿cuándo se ejecuta generateStaticParams?
Solo en build (npm run build), no en cada petición. En desarrollo (npm run dev) no verás el efecto hasta construir.
Pregunta 2: ¿y si los datos cambian?
Tras la generación estática, el contenido queda fijo. Si actualizas datos, hay que reconstruir y desplegar. Opciones:
- ISR (regeneración incremental programada)
dynamicParams = truepara actualizar bajo demandarevalidatepara caducidad de caché
// Regenerar cada 60 segundos
export const revalidate = 60
export default async function Page() {
// ...
}
Pregunta 3: ¿por qué el build tarda más?
Cuantos más paths devuelve generateStaticParams, más largo el build. Si hace timeout:
- Reduce páginas pre-renderizadas (solo populares)
- Build incremental (Vercel/Netlify)
- Generación bajo demanda (
dynamicParams = true)
Ya dominas el núcleo de rutas dinámicas en Next.js. En el último capítulo: tipado seguro para parámetros de ruta y adiós al any.
Capítulo 4: Tipado seguro de parámetros de ruta (adiós any)
¿Por qué tipado seguro?
¿Ves el problema aquí?
export default async function Page({
params
}: {
params: { slug: string }
}) {
// Supón que necesitas ID numérico, pero el tipo es string
const id = parseInt(params.slug)
if (isNaN(id)) {
// ¡Solo en runtime descubres el error de tipo!
return <div>ID no válido</div>
}
// ...
}
params.slug es string pero quizá necesitas número. El compilador no lo detecta; falla en ejecución.
Restricciones básicas de tipo
En Next.js, params trae por defecto string o string[]. Puedes reforzar con tipos propios:
// app/blog/[slug]/page.tsx
interface BlogParams {
slug: string
}
export default async function BlogPost({
params
}: {
params: BlogParams
}) {
// TypeScript sabe que params.slug es string
const post = await fetchPost(params.slug)
// ...
}
Con pocos parámetros parece poco; con varios ayuda mucho:
// app/shop/[category]/[productId]/page.tsx
interface ShopParams {
category: 'electronics' | 'books' | 'clothing' // Valores permitidos
productId: string
}
export default async function ProductPage({
params
}: {
params: ShopParams
}) {
// TypeScript comprueba category
if (params.category === 'toys') { // ❌ Error de compilación
// ...
}
}
Validación en runtime: con Zod
Los tipos solo ayudan en compilación; en runtime pueden llegar valores inválidos. Zod añade validación:
npm install zod
// app/products/[id]/page.tsx
import { z } from 'zod'
import { notFound } from 'next/navigation'
const paramsSchema = z.object({
id: z.string().regex(/^\d+$/, 'Debe ser un ID numérico')
})
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const result = paramsSchema.safeParse(params)
if (!result.success) {
notFound() // Parámetro inválido → 404
}
const { id } = result.data
const product = await fetchProduct(parseInt(id))
// ...
}
Ventajas:
- Comprobación en compilación
- Validación de formato en runtime
- Peticiones inválidas → 404 sin tocar la base de datos
Truco avanzado: generateStaticParams tipado
El retorno de generateStaticParams también puede tiparse:
// app/blog/[slug]/page.tsx
interface BlogParams {
slug: string
}
export async function generateStaticParams(): Promise<BlogParams[]> {
const posts = await fetchAllPosts()
return posts.map(post => ({
slug: post.slug
// Si escribes slug: post.id (tipo incorrecto), TypeScript avisa
}))
}
export default async function BlogPost({
params
}: {
params: BlogParams
}) {
// ...
}
Caso práctico: blog multilingüe
URL /[locale]/blog/[slug], por ejemplo:
/zh/blog/hello-world/en/blog/hello-world
Implementación con tipado seguro:
// app/[locale]/blog/[slug]/page.tsx
import { z } from 'zod'
import { notFound } from 'next/navigation'
const locales = ['zh', 'en', 'ja'] as const
type Locale = typeof locales[number] // "zh" | "en" | "ja"
interface PageParams {
locale: Locale
slug: string
}
const paramsSchema = z.object({
locale: z.enum(locales),
slug: z.string().min(1)
})
export async function generateStaticParams(): Promise<PageParams[]> {
const posts = await fetchAllPosts()
return locales.flatMap(locale =>
posts.map(post => ({
locale,
slug: post.slug
}))
)
}
export default async function BlogPost({
params
}: {
params: PageParams
}) {
const result = paramsSchema.safeParse(params)
if (!result.success) {
notFound()
}
const { locale, slug } = result.data
const post = await fetchPost(slug, locale)
if (!post) {
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
)
}
Ventajas:
Localelimita a"zh" | "en" | "ja"; un error de escritura falla en compilacióngenerateStaticParamsretornaPageParams[]con estructura correcta- Zod en runtime contra peticiones inválidas
- Flujo estricto de tipos a validación
Depuración de problemas de tipos
Problema 1: params es Promise<...>
Desde Next.js 15, params puede ser asíncrono:
export default async function Page({
params
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params // await primero
// ...
}
O versión síncrona si confirmas Next.js 14:
export default async function Page({
params
}: {
params: { slug: string }
}) {
// Uso directo
}
Problema 2: autocompletado incorrecto
Si TypeScript muestra params como any, revisa:
tsconfig.jsoncon modo estricto- Importación correcta de tipos de Next.js
- Nombre de archivo correcto (
page.tsx)
Problema 3: Zod falla y quieres ver el detalle
const result = paramsSchema.safeParse(params)
if (!result.success) {
console.error('Validación de parámetros fallida:', result.error.format())
notFound()
}
Lista de verificación de tipado seguro
En tu proyecto, comprueba:
- Todas las páginas dinámicas definen tipo para
params - El retorno de
generateStaticParamscoincide conparams - Rutas críticas usan validación runtime (Zod)
- TypeScript en modo estricto activado
- Parámetros complejos usan uniones o tipos literales
Con esto, los bugs de tipos en rutas deberían ser raros.
Conclusión
Si llegaste hasta aquí, ¡bien! Ya dominas el sistema completo de rutas dinámicas en Next.js. Resumen:
✅ Rutas dinámicas básicas: [slug] para un nivel, obtención vía params
✅ Catch-All: [...slug] para varios niveles y parámetros opcionales
✅ generateStaticParams: cuándo usarlo, cómo y generación bajo demanda
✅ Tipado seguro: de restricciones en compilación a validación en runtime
Además, entiendes la diferencia entre App Router y Pages Router y cuándo pre-renderizar frente a generar bajo demanda.
¿Qué hacer ahora?
Practica ya:
- Crea una ruta dinámica y prueba
params - Si necesitas varios niveles, prueba Catch-All
- Añade tipos TypeScript y validación Zod
Siguiente nivel:
- Rutas paralelas: varias rutas en una página (
@folder) - Rutas interceptadas: mostrar otra ruta sin salir (
(.)folder) - Grupos de rutas:
(folder)sin afectar la URL - Middleware: permisos y redirecciones a nivel de ruta
Recursos oficiales:
- Next.js - Fundamentos de enrutamiento
- Next.js - Rutas dinámicas
- Next.js - generateStaticParams
- TypeScript Deep Dive
Consulta rápida:
| Problema | Qué revisar | Solución |
|---|---|---|
| 404 en ruta dinámica | Nombre de carpeta, generateStaticParams | Corchetes correctos, config de generación estática |
params es any | Config TypeScript | Modo estricto, tipos de parámetros |
| Build muy largo | Cantidad en generateStaticParams | Menos pre-render, generación bajo demanda |
| Datos no actualizan | Estrategia de caché | revalidate o dynamicParams |
Para cerrar
El enrutamiento de Next.js cambió mucho de Pages Router a App Router; muchos (yo incluido) sentimos el dolor de migrar. Pero con la mentalidad de App Router, resulta más claro y potente.
Las rutas dinámicas son la base de la app. Dominarlas facilita datos, caché y middleware.
Si te atascas:
- Documentación oficial, sección Troubleshooting
- Issues en el repo de Next.js en GitHub
- Comunidad Discord de Next.js (en inglés, respuesta rápida)
No temas probar; yo también tardé varios proyectos en entender App Router. Con esta guía deberías evitar muchos rodeos.
Abre el editor y construye tus rutas dinámicas. 🚀
Flujo completo de configuración de rutas dinámicas en Next.js
Pasos completos desde crear rutas dinámicas hasta prácticas de tipado seguro
⏱️ Estimated time: 2 hr
- 1
Step 1: Crear carpetas de rutas dinámicas
Elige el tipo de ruta según tu necesidad:
• Un parámetro: app/posts/[id]/page.tsx
• Varios parámetros: app/posts/[category]/[id]/page.tsx
• Catch-all: app/posts/[...slug]/page.tsx
• Catch-all opcional: app/posts/[[...slug]]/page.tsx
Reglas de nomenclatura:
• [id]: parámetro obligatorio
• [...slug]: captura todos los segmentos de ruta
• [[...slug]]: captura opcional de todos los segmentos - 2
Step 2: Obtener parámetros de ruta
Obtén parámetros en page.tsx:
• App Router usa el objeto params
• params es una Promise, hay que hacer await
• Usa desestructuración para obtener parámetros concretos
Ejemplo:
export default async function Page({ params }) {
const { id } = await params
return <div>Post {id}</div>
}
Nota: params debe hacer await, si no dará error - 3
Step 3: Configurar tipado seguro
Define tipos con TypeScript:
• Define una interfaz para params
• Usa el tipo Promise<{ params }>
• Tipa el valor de retorno de generateStaticParams
Ejemplo:
interface PageProps {
params: Promise<{ id: string }>
}
export default async function Page({ params }: PageProps) {
const { id } = await params
// ...
} - 4
Step 4: Implementar generación estática (opcional)
Usa generateStaticParams:
• Devuelve todas las combinaciones posibles de parámetros
• Soporta funciones async para obtener datos
• Sirve para generar estáticamente todas las páginas
Ejemplo:
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map(post => ({ id: post.id }))
}
Nota: solo para generación estática, no hace falta en rutas puramente dinámicas - 5
Step 5: Gestionar parámetros opcionales
Rutas catch-all opcionales:
• Usa la sintaxis [[...slug]]
• params.slug puede ser undefined
• Comprueba si el parámetro existe
Ejemplo:
export default async function Page({ params }) {
const { slug } = await params
if (!slug) {
return <div>All posts</div>
}
return <div>Category: {slug.join('/')}</div>
} - 6
Step 6: Probar y validar
Puntos de prueba:
• Comprueba que todas las rutas funcionan
• Verifica que los parámetros se obtienen bien
• Revisa que las sugerencias de tipo funcionan
• Confirma que la generación estática tiene éxito
Lista de verificación:
• Todas las rutas dinámicas son accesibles
• Los tipos de parámetros están bien definidos
• generateStaticParams devuelve datos correctos
• Los errores 404 están gestionados
FAQ
¿Cómo se obtienen los parámetros de rutas dinámicas?
Puntos clave:
• params es una Promise, hay que hacer await
• Usa desestructuración para parámetros concretos
• Hay que definir los tipos
Ejemplo:
export default async function Page({ params }) {
const { id } = await params
return <div>{id}</div>
}
¿Por qué una ruta dinámica devuelve 404?
• Nombre de carpeta incorrecto (debe ser [id], no {id})
• Ruta que no coincide (revisa URL y estructura de carpetas)
• Datos incompletos en generateStaticParams
• Falta el archivo page.tsx
Soluciones:
• Comprueba el nombre de las carpetas
• Confirma que la URL coincide con la estructura
• Revisa el valor de retorno de generateStaticParams
¿Qué diferencia hay entre catch-all y catch-all opcional?
• Debe coincidir con al menos un segmento
• /posts/[...slug] coincide con /posts/a, pero no con /posts
Ruta catch-all opcional [[...slug]]:
• Puede coincidir con 0 o más segmentos
• /posts/[[...slug]] coincide con /posts y /posts/a/b
Cuándo usar cada una:
• catch-all: necesitas al menos un parámetro
• catch-all opcional: el parámetro es opcional
¿Cómo implementar rutas dinámicas con tipado seguro?
1) Define una interfaz para params
2) Usa el tipo Promise<{ params }>
3) Tipa el retorno de generateStaticParams
Ejemplo:
interface PageProps {
params: Promise<{ id: string }>
}
export default async function Page({ params }: PageProps) {
const { id } = await params
// ...
}
¿Cuándo usar generateStaticParams?
Casos adecuados:
• Conoces todos los valores posibles de parámetros
• Necesitas generar estáticamente todas las páginas
• Mejorar rendimiento y SEO
Casos no adecuados:
• Valores de parámetros que cambian dinámicamente
• Demasiados valores para enumerar
• Necesitas datos en tiempo real
Nota: solo para generación estática, no hace falta en rutas puramente dinámicas
¿Cómo migrar rutas dinámicas desde Pages Router?
• getStaticPaths → generateStaticParams
• context.params → params (requiere await)
• El formato de retorno pasa de { paths, fallback } a un array
Pasos de migración:
1) Cambia getStaticPaths por generateStaticParams
2) Modifica cómo obtienes parámetros (usa await params)
3) Actualiza las definiciones de tipos
4) Prueba todas las rutas
¿Cómo gestionar rutas dinámicas con varios parámetros?
app/posts/[category]/[id]/page.tsx
Obtener parámetros:
export default async function Page({ params }) {
const { category, id } = await params
return <div>{category} - {id}</div>
}
generateStaticParams devuelve todas las combinaciones:
export async function generateStaticParams() {
return [
{ category: 'tech', id: '1' },
{ category: 'tech', id: '2' },
// ...
]
}
15 min de lectura · Publicado el: 25 dic 2025 · Actualizado el: 21 ago 2026
Guía completa de Next.js
Si llegaste desde búsqueda, lo más rápido es ir al artículo anterior o siguiente de esta misma serie.
Anterior
Next.js App Router en la práctica: grupos de rutas y layouts anidados para proyectos grandes
Con grupos de rutas, layouts anidados, rutas paralelas e interceptadas, resuelve el caos de directorios, los conflictos de rutas y los problemas de colaboración en proyectos Next.js grandes, con una estructura de carpetas lista para usar.
Parte 5 de 51
Siguiente
Errores comunes de Next.js App Router y cómo solucionarlos: 8 lecciones prácticas para evitar trampas
Desde la obtención de datos hasta el manejo de errores: recopilación de los 14 errores más frecuentes al desarrollar con Next.js App Router y sus soluciones. Incluye experiencia real sobre Server Components, confusión con Client Components, caché y migración para evitar el 80 % de los fallos habituales.
Parte 7 de 51



Comentarios
Inicia sesión con GitHub para dejar un comentario