Tema
Rutas dinámicas
Un blog con 200 artículos o una tienda con 5,000 productos no puede tener una carpeta por cada página. Las rutas dinámicas usan un segmento variable ([slug], [id]) para generar todas esas páginas con un solo archivo. Aprenderás a leer los parámetros, a usar segmentos catch-all, searchParams y a pre-generar páginas con generateStaticParams.
Concepto
Una carpeta con el nombre entre corchetes es un segmento dinámico:
txt
app/
└── blog/
├── page.tsx → /blog
└── [slug]/
└── page.tsx → /blog/mi-primer-post, /blog/aprende-nextjs, ...Analogía: es como un molde de pastel. Un solo molde ([slug]/page.tsx), muchos pasteles distintos según el ingrediente (slug).
Leer parámetros: params
La página recibe params como prop. En Next.js 15+ es una Promise, así que se usa con await:
tsx
// app/blog/[slug]/page.tsx
type Props = { params: Promise<{ slug: string }> }
export default async function Post({ params }: Props) {
const { slug } = await params
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-3xl font-bold">Post: {slug}</h1>
</main>
)
}Visita /blog/hola-mundo y verás "Post: hola-mundo".
Cambio importante en Next.js 15
En versiones anteriores, params y searchParams eran objetos normales. Desde Next.js 15 son Promises. Si ves código como params.slug sin await, es de una versión anterior. En Client Components puedes usar el hook useParams() o use(params).
Ejemplo completo: blog con datos
ts
// Simula una "base de datos" de posts. En la vida real: Prisma, un CMS o una API.
export type Post = {
slug: string
titulo: string
resumen: string
contenido: string
categoria: 'nextjs' | 'react' | 'carrera'
fecha: string
}
const posts: Post[] = [
{
slug: 'que-es-nextjs',
titulo: '¿Qué es Next.js?',
resumen: 'El framework de React para producción.',
contenido: 'Next.js agrega rutas, renderizado en servidor y optimizaciones a React...',
categoria: 'nextjs',
fecha: '2026-01-15'
},
{
slug: 'hooks-de-react',
titulo: 'Los hooks esenciales de React',
resumen: 'useState, useEffect y compañía.',
contenido: 'Los hooks permiten usar estado y efectos en componentes de función...',
categoria: 'react',
fecha: '2026-02-03'
},
{
slug: 'primer-cliente',
titulo: 'Cómo conseguir tu primer cliente',
resumen: 'Consejos prácticos para freelancers.',
contenido: 'Empieza con tu red cercana, arma un portafolio con proyectos reales...',
categoria: 'carrera',
fecha: '2026-02-20'
}
]
export async function obtenerPosts(categoria?: string) {
return categoria ? posts.filter((p) => p.categoria === categoria) : posts
}
export async function obtenerPost(slug: string) {
return posts.find((p) => p.slug === slug) ?? null
}tsx
import Link from 'next/link'
import { obtenerPosts } from '@/lib/posts'
export const metadata = { title: 'Blog' }
export default async function Blog() {
const posts = await obtenerPosts()
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="mb-6 text-3xl font-bold">Blog</h1>
<ul className="space-y-6">
{posts.map((p) => (
<li key={p.slug}>
<Link href={`/blog/${p.slug}`} className="text-xl font-semibold hover:text-violet-700">
{p.titulo}
</Link>
<p className="text-gray-600">{p.resumen}</p>
<Link href={`/blog/categoria/${p.categoria}`} className="text-sm text-violet-700">
#{p.categoria}
</Link>
</li>
))}
</ul>
</main>
)
}tsx
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { obtenerPost, obtenerPosts } from '@/lib/posts'
type Props = { params: Promise<{ slug: string }> }
// Pre-genera en el build una página por cada post (SSG)
export async function generateStaticParams() {
const posts = await obtenerPosts()
return posts.map((p) => ({ slug: p.slug }))
}
// Título dinámico para SEO
export async function generateMetadata({ params }: Props) {
const { slug } = await params
const post = await obtenerPost(slug)
return post ? { title: post.titulo, description: post.resumen } : { title: 'Post no encontrado' }
}
export default async function Post({ params }: Props) {
const { slug } = await params
const post = await obtenerPost(slug)
if (!post) notFound() // muestra not-found.tsx con status 404
return (
<article className="mx-auto max-w-2xl p-8">
<Link href="/blog" className="text-sm text-violet-700">← Blog</Link>
<h1 className="mt-2 text-4xl font-bold">{post.titulo}</h1>
<time className="text-gray-500" dateTime={post.fecha}>
{new Date(post.fecha).toLocaleDateString('es-MX', { dateStyle: 'long' })}
</time>
<p className="mt-6 leading-relaxed">{post.contenido}</p>
</article>
)
}tsx
import Link from 'next/link'
export default function PostNoEncontrado() {
return (
<main className="p-16 text-center">
<h1 className="text-2xl font-bold">Este artículo no existe</h1>
<Link href="/blog" className="mt-4 inline-block underline">Ver todos los artículos</Link>
</main>
)
}generateStaticParams: pre-generar páginas
Sin generateStaticParams, cada página dinámica se renderiza bajo demanda. Con él, Next.js genera el HTML de cada una en el build (SSG): carga instantánea y mejor SEO.
tsx
export async function generateStaticParams() {
return [{ slug: 'que-es-nextjs' }, { slug: 'hooks-de-react' }]
}¿Y si llega un slug que no se pre-generó?
tsx
// Por defecto (true): se genera bajo demanda en la primera visita y se guarda
export const dynamicParams = true
// false: cualquier slug no listado devuelve 404
export const dynamicParams = falseEstrategia para catálogos grandes: pre-genera los 100 productos más vistos y deja que el resto se generen bajo demanda.
tsx
export async function generateStaticParams() {
const res = await fetch('https://dummyjson.com/products?limit=100&select=id')
const { products } = await res.json()
return products.map((p: { id: number }) => ({ id: String(p.id) })) // los params siempre son strings
}Varios segmentos dinámicos
txt
app/tienda/[categoria]/[producto]/page.tsx → /tienda/ropa/playera-negratsx
type Props = { params: Promise<{ categoria: string; producto: string }> }
export default async function Producto({ params }: Props) {
const { categoria, producto } = await params
return <h1>{producto} en {categoria}</h1>
}
export async function generateStaticParams() {
return [
{ categoria: 'ropa', producto: 'playera-negra' },
{ categoria: 'ropa', producto: 'sudadera-gris' },
{ categoria: 'accesorios', producto: 'gorra' }
]
}Catch-all: [...slug] y [[...slug]]
Captura varios segmentos en un arreglo. Ideal para documentación o categorías anidadas:
| Carpeta | Coincide con | params.slug |
|---|---|---|
docs/[...slug] | /docs/a, /docs/a/b/c | ['a'], ['a','b','c'] |
docs/[[...slug]] (opcional) | también /docs | undefined |
tsx
// app/docs/[[...slug]]/page.tsx
type Props = { params: Promise<{ slug?: string[] }> }
export default async function Docs({ params }: Props) {
const { slug = [] } = await params
const migas = ['docs', ...slug]
return (
<main className="p-8">
<nav className="text-sm text-gray-500">{migas.join(' / ')}</nav>
<h1 className="text-2xl font-bold">{slug.length ? slug.at(-1) : 'Documentación'}</h1>
</main>
)
}Query string: searchParams
Las páginas también reciben searchParams (la parte ?q=...&pagina=2), también como Promise. Usarlo hace la página dinámica (se renderiza en cada petición).
tsx
// app/buscar/page.tsx
import Link from 'next/link'
type Props = { searchParams: Promise<{ q?: string; pagina?: string }> }
export default async function Buscar({ searchParams }: Props) {
const { q = '', pagina = '1' } = await searchParams
const numPagina = Math.max(1, Number(pagina) || 1)
const limite = 10
const res = await fetch(
`https://dummyjson.com/products/search?q=${encodeURIComponent(q)}&limit=${limite}&skip=${(numPagina - 1) * limite}&select=title,price`
)
const { products, total } = await res.json()
const paginas = Math.ceil(total / limite)
return (
<main className="mx-auto max-w-2xl p-8">
{/* Un formulario GET actualiza la URL sin JavaScript */}
<form className="mb-6 flex gap-2">
<input name="q" defaultValue={q} placeholder="Buscar productos…" className="flex-1 rounded border px-3 py-2" />
<button className="rounded bg-violet-600 px-4 text-white">Buscar</button>
</form>
<p className="mb-4 text-sm text-gray-500">{total} resultados {q && `para "${q}"`}</p>
<ul className="space-y-2">
{products.map((p: { id: number; title: string; price: number }) => (
<li key={p.id}>{p.title} — ${p.price}</li>
))}
</ul>
<nav className="mt-6 flex justify-between">
{numPagina > 1 ? <Link href={`/buscar?q=${q}&pagina=${numPagina - 1}`}>← Anterior</Link> : <span />}
{numPagina < paginas && <Link href={`/buscar?q=${q}&pagina=${numPagina + 1}`}>Siguiente →</Link>}
</nav>
</main>
)
}Para actualizar la URL desde un Client Component (por ejemplo, un buscador que filtra al escribir), usa useRouter y useSearchParams:
tsx
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
export default function FiltroCategoria() {
const router = useRouter()
const pathname = usePathname()
const params = useSearchParams()
function cambiar(categoria: string) {
const nuevos = new URLSearchParams(params)
if (categoria) nuevos.set('categoria', categoria)
else nuevos.delete('categoria')
nuevos.delete('pagina')
router.replace(`${pathname}?${nuevos}`, { scroll: false })
}
return (
<select value={params.get('categoria') ?? ''} onChange={(e) => cambiar(e.target.value)}>
<option value="">Todas</option>
<option value="laptops">Laptops</option>
<option value="smartphones">Celulares</option>
</select>
)
}Buena práctica
Usa slugs legibles en las URLs (/blog/como-conseguir-clientes) en lugar de ids (/blog/42): son mejores para el SEO y para quien comparte el enlace. Guarda el slug como campo único en tu base de datos.
Error común
Olvidar que los params siempre son strings. Si tu id es numérico, conviértelo: Number(id), y valida que sea válido antes de consultar la base de datos.
Ejercicios
- Crea
app/blog/categoria/[categoria]/page.tsxque liste los posts de esa categoría, congenerateStaticParamspara las tres categorías ydynamicParams = false. - Crea
app/productos/[id]/page.tsxque muestre un producto de DummyJSON, pre-generando los primeros 20 y mostrando 404 si el id no existe.
Ver solución
tsx
// 1. app/blog/categoria/[categoria]/page.tsx
import Link from 'next/link'
import { obtenerPosts } from '@/lib/posts'
type Props = { params: Promise<{ categoria: string }> }
export const dynamicParams = false
export function generateStaticParams() {
return ['nextjs', 'react', 'carrera'].map((categoria) => ({ categoria }))
}
export async function generateMetadata({ params }: Props) {
const { categoria } = await params
return { title: `Artículos de ${categoria}` }
}
export default async function Categoria({ params }: Props) {
const { categoria } = await params
const posts = await obtenerPosts(categoria)
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="mb-4 text-3xl font-bold">#{categoria}</h1>
<ul>
{posts.map((p) => (
<li key={p.slug}><Link href={`/blog/${p.slug}`}>{p.titulo}</Link></li>
))}
</ul>
</main>
)
}tsx
// 2. app/productos/[id]/page.tsx
import { notFound } from 'next/navigation'
type Props = { params: Promise<{ id: string }> }
export async function generateStaticParams() {
return Array.from({ length: 20 }, (_, i) => ({ id: String(i + 1) }))
}
export default async function Producto({ params }: Props) {
const { id } = await params
if (!/^\d+$/.test(id)) notFound()
const res = await fetch(`https://dummyjson.com/products/${id}`)
if (!res.ok) notFound()
const p = await res.json()
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-3xl font-bold">{p.title}</h1>
<p className="my-4">{p.description}</p>
<p className="text-2xl font-bold">${p.price}</p>
</main>
)
}Resumen
[param]crea segmentos dinámicos;paramses una Promise:const { slug } = await params.generateStaticParamspre-genera páginas en el build;dynamicParamscontrola qué pasa con los no listados.[...slug]y[[...slug]]capturan varios segmentos.searchParamslee la query string y hace la página dinámica; usanotFound()cuando el recurso no existe.