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:
params
siempre es un objeto- Las claves coinciden con los nombres de tus carpetas dinámicas
- Los valores siempre son strings (aunque la URL sea
/productos/123
, recibes"123"
) - 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
:
- Se ejecuta en build time (cuando haces
npm run build
) - NextJS genera HTML estático para cada params que retornes
- 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:
Aspecto | Params [id] | SearchParams ?foo=bar |
---|---|---|
SEO | Excelente | No indexado por Google |
Permanencia | URL única por recurso | Filtros temporales |
Uso típico | IDs, slugs, categorías | Filtros, ordenamiento, paginación |
Generación estática | Soportado | No 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:
- Los corchetes
[]
crean segmentos dinámicos que capturan valores de la URL - Accedes a los valores mediante el objeto
params
que NextJS pasa automáticamente - Puedes tener múltiples parámetros en una misma ruta
[...slug]
captura múltiples segmentos (pero no la ruta base)[[...slug]]
es opcional y captura la ruta base tambiéngenerateStaticParams
pre-genera páginas en build time (SSG)generateMetadata
crea metadata dinámica para SEO- Los valores de params siempre son strings
- Valida params y maneja casos de error con
notFound()
- Usa params para recursos específicos y searchParams para filtros
Tabla de referencia rápida:
Sintaxis | Coincide con | params |
---|---|---|
[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"] } |