Cambiar tema

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

Easton editorial illustration: server-client bridge

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-worldslug = "hello-world"
  • /blog/nextjs-guideslug = "nextjs-guide"
  • /blog/123slug = "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:

  1. ❌ Usar [slug].tsx como nombre de archivo (en App Router hace falta carpeta)
  2. ❌ Acceder a props.slug directamente (hay que usar el objeto params)
  3. ❌ 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ísticaPages RouterApp Router
Ubicaciónpages/blog/[slug].tsxapp/blog/[slug]/page.tsx
Obtener parámetrosrouter.query.slug o getStaticPropsparams.slug
TiposDefinición manualInferencia por tipos de props
Generación estáticagetStaticPathsgenerateStaticParams

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:

  1. El componente es async porque los Server Components lo permiten
  2. Primero obtienes datos, luego decides qué renderizar
  3. 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-startedslug = ["getting-started"]
  • /docs/api/authenticationslug = ["api", "authentication"]
  • /docs/guides/deployment/vercelslug = ["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:

  1. slugArray.join('/') convierte el array en string de ruta
  2. Breadcrumb con slice para prefijos de ruta
  3. 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:

  • /docsslug = undefined
  • /docs/getting-startedslug = ["getting-started"]
  • /docs/api/authslug = ["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

TipoNombre de carpetaAlcanceTipo de parámetro
Dinámica[slug]/blog/123string
Catch-All[...slug]/docs/a/b/c (sin /docs)string[]
Catch-All opcional[[...slug]]/docs y /docs/a/b/cstring[] | 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:

  1. Consultar la base de datos
  2. Renderizar HTML en servidor
  3. 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?

  1. generateStaticParams corre en build y devuelve todos los slugs
  2. Next.js pre-renderiza un HTML estático por slug
  3. 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 = true para actualizar bajo demanda
  • revalidate para 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:

  1. Locale limita a "zh" | "en" | "ja"; un error de escritura falla en compilación
  2. generateStaticParams retorna PageParams[] con estructura correcta
  3. Zod en runtime contra peticiones inválidas
  4. 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:

  1. tsconfig.json con modo estricto
  2. Importación correcta de tipos de Next.js
  3. 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 generateStaticParams coincide con params
  • 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:

Consulta rápida:

ProblemaQué revisarSolución
404 en ruta dinámicaNombre de carpeta, generateStaticParamsCorchetes correctos, config de generación estática
params es anyConfig TypeScriptModo estricto, tipos de parámetros
Build muy largoCantidad en generateStaticParamsMenos pre-render, generación bajo demanda
Datos no actualizanEstrategia 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:

  1. Documentación oficial, sección Troubleshooting
  2. Issues en el repo de Next.js en GitHub
  3. 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. 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. 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. 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. 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. 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. 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?
En App Router se usan con el objeto params.

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?
Posibles causas:
• 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?
Ruta catch-all [...slug]:
• 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?
Pasos:
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?
Sirve para generar estáticamente todas las páginas posibles.

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?
Cambios principales:
• 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?
Crea carpetas anidadas:
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

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog