Dynamic Routes

Las rutas dinámicas te permiten crear páginas que responden a valores variables en la URL, como IDs de productos, slugs de artículos, o nombres de usuario. En lugar de crear una página para cada producto, creas una sola que maneja cualquier ID.

¿Qué son las rutas dinámicas?

Una ruta dinámica es una ruta que incluye segmentos variables. Por ejemplo:

/productos/camisa-azul
/productos/pantalon-negro
/productos/zapatos-rojos

En lugar de crear tres archivos diferentes, creas uno solo que maneja cualquier valor:

app/
└── productos/
    └── [slug]/
        └── page.tsx       → Maneja /productos/*cualquier-valor*

Conceptos clave:

  • Los corchetes [] indican que un segmento es dinámico
  • El nombre dentro de los corchetes es el parámetro que recibirás
  • Una sola página puede manejar infinitas URLs

Sintaxis básica

Creando tu primera ruta dinámica

app/
└── productos/
    ├── page.tsx           → /productos (lista)
    └── [id]/
        └── page.tsx       → /productos/123, /productos/abc, etc.

El archivo dentro de [id]/ recibe el valor de la URL como parámetro:

// app/productos/[id]/page.tsx
export default function ProductoPage({
  params,
}: {
  params: { id: string }
}) {
  return (
    <div>
      <h1>Producto ID: {params.id}</h1>
      <p>Estás viendo el producto con ID: {params.id}</p>
    </div>
  )
}

URLs que coinciden:

/productos/123        → params.id = "123"
/productos/abc        → params.id = "abc"
/productos/camisa-1   → params.id = "camisa-1"
ℹ️
El nombre del parámetro

El nombre dentro de los corchetes ([id], [slug], [userId]) es lo que usarás para acceder al valor en params. Usa nombres descriptivos que reflejen qué representa ese segmento.

Anatomía del objeto params

El objeto params es una prop automática que NextJS pasa a tu página:

type PageProps = {
  params: { [key: string]: string }
  searchParams: { [key: string]: string | string[] | undefined }
}

export default function Page({ params, searchParams }: PageProps) {
  // params: segmentos dinámicos de la URL
  // searchParams: query parameters (?foo=bar)
  return <div>...</div>
}

Características importantes:

  1. params siempre es un objeto
  2. Las claves coinciden con los nombres de tus carpetas dinámicas
  3. Los valores siempre son strings (aunque la URL sea /productos/123, recibes "123")
  4. NextJS hace decode automático de la URL (espacios, caracteres especiales)

Ejemplos prácticos con e-commerce

Producto individual

// app/productos/[slug]/page.tsx
interface ProductoPageProps {
  params: { slug: string }
}

export default async function ProductoPage({ params }: ProductoPageProps) {
  // Obtener producto de la base de datos
  const producto = await obtenerProducto(params.slug)
  
  if (!producto) {
    // Puedes lanzar notFound() para mostrar 404
    notFound()
  }

  return (
    <div>
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
      <p>${producto.precio}</p>
      <button>Añadir al carrito</button>
    </div>
  )
}

// Función de ejemplo
async function obtenerProducto(slug: string) {
  // Aquí irías a tu base de datos
  const res = await fetch(`https://api.ejemplo.com/productos/${slug}`)
  return res.json()
}

Categoría de productos

// app/categorias/[categoria]/page.tsx
interface CategoriaPageProps {
  params: { categoria: string }
}

export default async function CategoriaPage({ params }: CategoriaPageProps) {
  const productos = await obtenerProductosPorCategoria(params.categoria)

  return (
    <div>
      <h1>Categoría: {params.categoria}</h1>
      <div className="grid">
        {productos.map(producto => (
          <ProductCard key={producto.id} producto={producto} />
        ))}
      </div>
    </div>
  )
}

Múltiples segmentos dinámicos

Puedes tener varios segmentos dinámicos en una misma ruta:

app/
└── tienda/
    └── [categoria]/
        └── [subcategoria]/
            └── [producto]/
                └── page.tsx

Esto crea URLs como:

/tienda/ropa/camisas/manga-larga
/tienda/electronica/computadoras/laptop-gaming

Accediendo a los parámetros:

// app/tienda/[categoria]/[subcategoria]/[producto]/page.tsx
interface PageProps {
  params: {
    categoria: string
    subcategoria: string
    producto: string
  }
}

export default function ProductoPage({ params }: PageProps) {
  return (
    <div>
      <nav>
        <a href="/">Inicio</a>
        <span> / </span>
        <a href={`/tienda/${params.categoria}`}>{params.categoria}</a>
        <span> / </span>
        <a href={`/tienda/${params.categoria}/${params.subcategoria}`}>
          {params.subcategoria}
        </a>
        <span> / </span>
        <span>{params.producto}</span>
      </nav>
      
      <h1>{params.producto}</h1>
      <p>Categoría: {params.categoria}</p>
      <p>Subcategoría: {params.subcategoria}</p>
    </div>
  )
}
⚠️
Evita rutas muy anidadas

URLs muy largas como /tienda/categoria/subcategoria/marca/producto/color/talla son difíciles de mantener y afectan la experiencia del usuario. Considera usar query parameters (?color=azul&talla=M) para algunos filtros.

Catch-All Segments

Los catch-all segments capturan múltiples segmentos de ruta en un array. Usan tres puntos [...param]:

app/
└── docs/
    └── [...slug]/
        └── page.tsx

URLs que coinciden:

/docs/introduccion              → slug = ["introduccion"]
/docs/guias/primeros-pasos      → slug = ["guias", "primeros-pasos"]
/docs/api/referencia/hooks      → slug = ["api", "referencia", "hooks"]

Pero NO coincide con /docs (sin segmentos adicionales).

Ejemplo: Documentación

// app/docs/[...slug]/page.tsx
interface DocsPageProps {
  params: { slug: string[] }
}

export default async function DocsPage({ params }: DocsPageProps) {
  // slug es un array de segmentos
  const ruta = params.slug.join('/')
  const contenido = await obtenerContenido(ruta)

  return (
    <article>
      <h1>{contenido.titulo}</h1>
      {/* Breadcrumbs dinámicos */}
      <nav>
        <a href="/docs">Docs</a>
        {params.slug.map((segmento, i) => {
          const rutaParcial = params.slug.slice(0, i + 1).join('/')
          return (
            <span key={i}>
              <span> / </span>
              <a href={`/docs/${rutaParcial}`}>{segmento}</a>
            </span>
          )
        })}
      </nav>
      <div dangerouslySetInnerHTML={{ __html: contenido.html }} />
    </article>
  )
}

Ejemplo: Breadcrumbs automáticos

// components/Breadcrumbs.tsx
interface BreadcrumbsProps {
  segments: string[]
  basePath: string
}

export function Breadcrumbs({ segments, basePath }: BreadcrumbsProps) {
  return (
    <nav aria-label="breadcrumb">
      <a href="/">Inicio</a>
      <span> / </span>
      <a href={basePath}>{basePath.slice(1)}</a>
      {segments.map((segment, index) => {
        const href = `${basePath}/${segments.slice(0, index + 1).join('/')}`
        const isLast = index === segments.length - 1
        
        return (
          <span key={index}>
            <span> / </span>
            {isLast ? (
              <span>{segment}</span>
            ) : (
              <a href={href}>{segment}</a>
            )}
          </span>
        )
      })}
    </nav>
  )
}

// Uso en la página
export default function Page({ params }: { params: { slug: string[] } }) {
  return (
    <div>
      <Breadcrumbs segments={params.slug} basePath="/docs" />
      {/* resto del contenido */}
    </div>
  )
}

Optional Catch-All Segments

Los optional catch-all segments usan doble corchetes [[...param]] y SÍ coinciden con la ruta base:

app/
└── tienda/
    └── [[...filtros]]/
        └── page.tsx

URLs que coinciden:

/tienda                         → filtros = undefined
/tienda/ropa                    → filtros = ["ropa"]
/tienda/ropa/camisas            → filtros = ["ropa", "camisas"]
/tienda/ropa/camisas/manga-larga → filtros = ["ropa", "camisas", "manga-larga"]

Ejemplo: Tienda con filtros

// app/tienda/[[...filtros]]/page.tsx
interface TiendaPageProps {
  params: { filtros?: string[] }
}

export default async function TiendaPage({ params }: TiendaPageProps) {
  // Si no hay filtros, mostrar todo
  const filtrosActivos = params.filtros || []
  
  // Obtener productos según filtros
  const productos = await obtenerProductos(filtrosActivos)

  return (
    <div>
      <h1>Tienda</h1>
      
      {/* Mostrar filtros activos */}
      {filtrosActivos.length > 0 && (
        <div>
          <p>Filtros activos:</p>
          <ul>
            {filtrosActivos.map((filtro, i) => (
              <li key={i}>{filtro}</li>
            ))}
          </ul>
        </div>
      )}

      {/* Grid de productos */}
      <div className="grid">
        {productos.map(producto => (
          <ProductCard key={producto.id} producto={producto} />
        ))}
      </div>
    </div>
  )
}

async function obtenerProductos(filtros: string[]) {
  // Construir query según filtros
  let query = 'SELECT * FROM productos'
  
  if (filtros.length > 0) {
    // Primer filtro podría ser categoría
    // Segundo podría ser subcategoría
    // etc.
    query += ` WHERE categoria = '${filtros[0]}'`
    if (filtros[1]) {
      query += ` AND subcategoria = '${filtros[1]}'`
    }
  }
  
  // Ejecutar query y retornar
  return ejecutarQuery(query)
}
💡
¿Cuándo usar opcional vs requerido?
  • Usa [...slug] cuando necesites al menos un segmento (como docs que siempre necesitan una ruta)
  • Usa [[...slug]] cuando la ruta base también deba funcionar (como una tienda que puede mostrar todo sin filtros)

Generando páginas estáticas

generateStaticParams

Para generar páginas estáticas en build time (SSG), exporta generateStaticParams:

// app/productos/[id]/page.tsx

// Genera las páginas en build time
export async function generateStaticParams() {
  const productos = await obtenerTodosLosProductos()
  
  return productos.map(producto => ({
    id: producto.id.toString(), // Debe ser string
  }))
}

// La página
export default async function ProductoPage({
  params,
}: {
  params: { id: string }
}) {
  const producto = await obtenerProducto(params.id)
  
  return (
    <div>
      <h1>{producto.nombre}</h1>
      <p>{producto.descripcion}</p>
    </div>
  )
}

Qué hace generateStaticParams:

  1. Se ejecuta en build time (cuando haces npm run build)
  2. NextJS genera HTML estático para cada params que retornes
  3. Esas páginas se sirven instantáneamente sin servidor

Ejemplo con múltiples parámetros:

// app/blog/[categoria]/[slug]/page.tsx

export async function generateStaticParams() {
  const posts = await obtenerTodosPosts()
  
  return posts.map(post => ({
    categoria: post.categoria,
    slug: post.slug,
  }))
}

// NextJS generará:
// /blog/tecnologia/nextjs-15
// /blog/tecnologia/react-19
// /blog/diseno/ui-trends-2024
// etc.
ℹ️
Dynamic vs Static
  • Sin generateStaticParams: las páginas se generan on-demand (la primera vez que alguien visita)
  • Con generateStaticParams: las páginas se pre-generan en build time
  • Usa static para contenido que no cambia frecuentemente (productos, blog posts)
  • Usa dynamic para contenido personalizado por usuario (perfiles, dashboards)

Combinando con catch-all

// app/docs/[...slug]/page.tsx

export async function generateStaticParams() {
  const rutas = [
    { slug: ['introduccion'] },
    { slug: ['guias', 'primeros-pasos'] },
    { slug: ['guias', 'deployment'] },
    { slug: ['api', 'referencia'] },
  ]
  
  return rutas
}

export default function DocsPage({ params }: { params: { slug: string[] } }) {
  const ruta = params.slug.join('/')
  // ...
}

Metadata dinámica

Genera metadata SEO basada en el contenido dinámico:

// app/productos/[slug]/page.tsx
import { Metadata } from 'next'

// Genera metadata dinámica
export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const producto = await obtenerProducto(params.slug)
  
  return {
    title: `${producto.nombre} - Mi Tienda`,
    description: producto.descripcion,
    openGraph: {
      title: producto.nombre,
      description: producto.descripcion,
      images: [producto.imagen],
    },
  }
}

// La página
export default async function ProductoPage({
  params,
}: {
  params: { slug: string }
}) {
  const producto = await obtenerProducto(params.slug)
  return <div>{/* ... */}</div>
}

Beneficios:

  • Cada producto tiene su propio título y descripción
  • SEO mejorado (Google ve títulos específicos)
  • Mejor apariencia al compartir en redes sociales

Validación de parámetros

Es importante validar que los parámetros sean válidos:

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

export default async function ProductoPage({
  params,
}: {
  params: { id: string }
}) {
  // Validar formato
  if (!/^\d+$/.test(params.id)) {
    notFound() // Muestra 404
  }

  // Validar que existe
  const producto = await obtenerProducto(params.id)
  if (!producto) {
    notFound()
  }

  return <div>{/* ... */}</div>
}

Ejemplo: Validación con Zod

import { z } from 'zod'
import { notFound } from 'next/navigation'

// Schema de validación
const ParamsSchema = z.object({
  id: z.string().regex(/^\d+$/, 'ID debe ser numérico'),
})

export default async function ProductoPage({
  params,
}: {
  params: { id: string }
}) {
  // Validar con Zod
  const resultado = ParamsSchema.safeParse(params)
  
  if (!resultado.success) {
    notFound()
  }

  const { id } = resultado.data
  const producto = await obtenerProducto(id)
  
  if (!producto) {
    notFound()
  }

  return <div>{/* ... */}</div>
}

Casos de uso comunes

1. Blog con categorías

app/
└── blog/
    ├── page.tsx                    → /blog (todos los posts)
    ├── [categoria]/
    │   └── page.tsx                → /blog/tecnologia
    └── [categoria]/
        └── [slug]/
            └── page.tsx            → /blog/tecnologia/nextjs-15
// app/blog/[categoria]/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await obtenerPosts()
  
  return posts.map(post => ({
    categoria: post.categoria,
    slug: post.slug,
  }))
}

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await obtenerPost(params.slug)
  
  return {
    title: post.titulo,
    description: post.extracto,
    authors: [{ name: post.autor }],
    publishedTime: post.fecha,
  }
}

export default async function BlogPostPage({
  params,
}: {
  params: { categoria: string; slug: string }
}) {
  const post = await obtenerPost(params.slug)
  
  return (
    <article>
      <header>
        <span>{params.categoria}</span>
        <h1>{post.titulo}</h1>
        <time>{post.fecha}</time>
      </header>
      <div dangerouslySetInnerHTML={{ __html: post.contenido }} />
    </article>
  )
}

2. Perfil de usuario

app/
└── usuarios/
    └── [username]/
        ├── page.tsx                → /usuarios/rod
        ├── posts/
        │   └── page.tsx            → /usuarios/rod/posts
        └── seguidores/
            └── page.tsx            → /usuarios/rod/seguidores
// app/usuarios/[username]/page.tsx
export default async function PerfilPage({
  params,
}: {
  params: { username: string }
}) {
  const usuario = await obtenerUsuario(params.username)
  
  if (!usuario) {
    notFound()
  }

  return (
    <div>
      <img src={usuario.avatar} alt={usuario.nombre} />
      <h1>{usuario.nombre}</h1>
      <p>@{params.username}</p>
      
      <nav>
        <a href={`/usuarios/${params.username}`}>Posts</a>
        <a href={`/usuarios/${params.username}/seguidores`}>Seguidores</a>
      </nav>
    </div>
  )
}

3. Dashboard con secciones

app/
└── dashboard/
    └── [[...seccion]]/
        └── page.tsx
// app/dashboard/[[...seccion]]/page.tsx
export default function DashboardPage({
  params,
}: {
  params: { seccion?: string[] }
}) {
  // /dashboard → seccion = undefined → mostrar overview
  // /dashboard/ventas → seccion = ["ventas"]
  // /dashboard/ventas/2024 → seccion = ["ventas", "2024"]
  
  if (!params.seccion) {
    return <DashboardOverview />
  }

  const [vista, ...filtros] = params.seccion

  switch (vista) {
    case 'ventas':
      return <SeccionVentas filtros={filtros} />
    case 'productos':
      return <SeccionProductos filtros={filtros} />
    case 'clientes':
      return <SeccionClientes filtros={filtros} />
    default:
      notFound()
  }
}

Diferencia entre params y searchParams

Es importante entender la diferencia:

URL: /productos/camisa-azul?talla=M&color=azul

Segmentos dinámicos (params):
  slug: "camisa-azul"

Query parameters (searchParams):
  talla: "M"
  color: "azul"

Cuándo usar cada uno:

AspectoParams [id]SearchParams ?foo=bar
SEOExcelenteNo indexado por Google
PermanenciaURL única por recursoFiltros temporales
Uso típicoIDs, slugs, categoríasFiltros, ordenamiento, paginación
Generación estáticaSoportadoNo soportado

Ejemplo combinado:

// app/productos/[categoria]/page.tsx
interface PageProps {
  params: { categoria: string }
  searchParams: { orden?: string; precio?: string }
}

export default async function ProductosPage({ params, searchParams }: PageProps) {
  // params.categoria: ropa, electronica, etc.
  // searchParams.orden: precio-asc, precio-desc, etc.
  // searchParams.precio: 0-100, 100-500, etc.
  
  const productos = await obtenerProductos({
    categoria: params.categoria,
    orden: searchParams.orden,
    rangoPrecios: searchParams.precio,
  })

  return (
    <div>
      <h1>Categoría: {params.categoria}</h1>
      
      {/* Filtros que modifican searchParams */}
      <Filtros />
      
      {/* Productos filtrados */}
      <ProductGrid productos={productos} />
    </div>
  )
}

Errores comunes y soluciones

1. Olvidar que params son strings

// ❌ Incorrecto
const id = params.id // "123" (string)
if (id > 100) { // Comparación incorrecta

// ✓ Correcto
const id = parseInt(params.id, 10)
if (id > 100) {

2. No manejar parámetros undefined

// ❌ Incorrecto (con optional catch-all)
const ruta = params.slug.join('/') // Error si slug es undefined

// ✓ Correcto
const ruta = params.slug?.join('/') || 'inicio'

3. Mutar params directamente

// ❌ Incorrecto
params.id = 'nuevo-valor' // params es readonly

// ✓ Correcto
const nuevoId = 'nuevo-valor'

4. No validar la existencia del recurso

// ❌ Incorrecto
const producto = await obtenerProducto(params.id)
return <h1>{producto.nombre}</h1> // Error si producto es null

// ✓ Correcto
const producto = await obtenerProducto(params.id)
if (!producto) notFound()
return <h1>{producto.nombre}</h1>

5. Confundir [...] con [[...]]

// [...slug] NO coincide con la ruta base
app/docs/[...slug]/page.tsx
// ✓ /docs/intro
// ✗ /docs

// [[...slug]] SÍ coincide con la ruta base
app/docs/[[...slug]]/page.tsx
// ✓ /docs/intro
// ✓ /docs

Mejores prácticas

1. Nombres descriptivos de parámetros

✓ Buenos nombres:
[id]          → claro que es un ID
[slug]        → claro que es un slug
[username]    → claro que es nombre de usuario
[productId]   → muy específico

✗ Evita:
[param]       → muy genérico
[data]        → poco claro
[value]       → ¿qué valor?

2. Validación temprana

export default async function Page({ params }: { params: { id: string } }) {
  // Validar PRIMERO
  if (!/^\d+$/.test(params.id)) {
    notFound()
  }

  // Luego obtener datos
  const data = await obtenerDatos(params.id)
  
  // ...
}

3. TypeScript estricto

// Define interfaces claras
interface ProductoPageProps {
  params: { id: string }
  searchParams: { [key: string]: string | string[] | undefined }
}

export default async function ProductoPage({ params, searchParams }: ProductoPageProps) {
  // TypeScript ayuda con autocompletado
}

4. Usa generateStaticParams para contenido estático

// Si tus productos no cambian frecuentemente
export async function generateStaticParams() {
  return await obtenerProductos()
}

// Las páginas se pre-generan en build time

5. Combina params y searchParams apropiadamente

// params: lo esencial (recurso específico)
// searchParams: lo opcional (filtros, ordenamiento)

// ✓ Bueno
/productos/[id]?vista=detalles&variante=azul

// ✗ Malo
/productos?id=123&vista=detalles&variante=azul

6. Manejo consistente de 404

import { notFound } from 'next/navigation'

export default async function Page({ params }: { params: { id: string } }) {
  const data = await obtenerDatos(params.id)
  
  // Siempre usa notFound() para recursos no encontrados
  if (!data) {
    notFound()
  }

  return <div>{/* ... */}</div>
}

Ejemplo completo: E-commerce

Estructura completa con rutas dinámicas:

app/
├── page.tsx                              → /
│
├── productos/
│   ├── page.tsx                          → /productos (todos)
│   │
│   ├── [slug]/
│   │   ├── page.tsx                      → /productos/camisa-azul
│   │   ├── loading.tsx
│   │   ├── error.tsx
│   │   └── not-found.tsx
│   │
│   └── categoria/
│       └── [categoria]/
│           └── page.tsx                  → /productos/categoria/ropa
│
├── blog/
│   ├── page.tsx                          → /blog
│   └── [slug]/
│       └── page.tsx                      → /blog/mi-articulo
│
├── usuarios/
│   └── [username]/
│       ├── page.tsx                      → /usuarios/rod
│       ├── posts/
│       │   └── page.tsx                  → /usuarios/rod/posts
│       └── configuracion/
│           └── page.tsx                  → /usuarios/rod/configuracion
│
└── docs/
    └── [[...slug]]/
        └── page.tsx                      → /docs, /docs/intro, /docs/api/referencia

Código del producto:

// app/productos/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { obtenerProducto, obtenerTodosLosProductos } from '@/lib/productos'

// Generar páginas estáticas en build
export async function generateStaticParams() {
  const productos = await obtenerTodosLosProductos()
  
  return productos.map(producto => ({
    slug: producto.slug,
  }))
}

// Metadata dinámica para SEO
export async function generateMetadata({
  params,
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const producto = await obtenerProducto(params.slug)
  
  if (!producto) {
    return {
      title: 'Producto no encontrado',
    }
  }

  return {
    title: `${producto.nombre} - Mi Tienda`,
    description: producto.descripcion,
    openGraph: {
      title: producto.nombre,
      description: producto.descripcion,
      images: [producto.imagenPrincipal],
      type: 'product',
    },
  }
}

// La página
export default async function ProductoPage({
  params,
}: {
  params: { slug: string }
}) {
  const producto = await obtenerProducto(params.slug)
  
  if (!producto) {
    notFound()
  }

  return (
    <div>
      <div className="grid grid-cols-2 gap-8">
        {/* Galería de imágenes */}
        <div>
          <img
            src={producto.imagenPrincipal}
            alt={producto.nombre}
            className="w-full rounded-lg"
          />
        </div>

        {/* Info del producto */}
        <div>
          <h1 className="text-3xl font-bold">{producto.nombre}</h1>
          <p className="text-2xl mt-4">${producto.precio}</p>
          <p className="mt-4 text-gray-600">{producto.descripcion}</p>
          
          {/* Opciones */}
          <div className="mt-6">
            <label>Talla:</label>
            <select>
              {producto.tallas.map(talla => (
                <option key={talla}>{talla}</option>
              ))}
            </select>
          </div>

          <button className="mt-6 w-full bg-blue-600 text-white py-3 rounded-lg">
            Añadir al carrito
          </button>
        </div>
      </div>

      {/* Productos relacionados */}
      <div className="mt-12">
        <h2 className="text-2xl font-bold">Productos relacionados</h2>
        {/* ... */}
      </div>
    </div>
  )
}

Resumen

Puntos clave sobre Dynamic Routes:

  1. Los corchetes [] crean segmentos dinámicos que capturan valores de la URL
  2. Accedes a los valores mediante el objeto params que NextJS pasa automáticamente
  3. Puedes tener múltiples parámetros en una misma ruta
  4. [...slug] captura múltiples segmentos (pero no la ruta base)
  5. [[...slug]] es opcional y captura la ruta base también
  6. generateStaticParams pre-genera páginas en build time (SSG)
  7. generateMetadata crea metadata dinámica para SEO
  8. Los valores de params siempre son strings
  9. Valida params y maneja casos de error con notFound()
  10. Usa params para recursos específicos y searchParams para filtros

Tabla de referencia rápida:

SintaxisCoincide conparams
[id]/productos/123{ id: "123" }
[id]/[sub]/blog/tech/nextjs{ id: "tech", sub: "nextjs" }
[...slug]/docs/a/b/c{ slug: ["a", "b", "c"] }
[...slug]/docs❌ No coincide
[[...slug]]/docs{ slug: undefined }
[[...slug]]/docs/a/b{ slug: ["a", "b"] }