Diario / Desarrollo de producto web
Cómo configurar rutas dinámicas en Next.js: guía completa de [[...slug]]
![Cómo configurar rutas dinámicas en Next.js: guía completa de [[...slug]]](/_next/image?url=%2Fimages%2Fblogs%2Fnext-routing-slug.webp&w=3840&q=75)
El enrutamiento dinámico del App Router da flexibilidad para construir un blog, un catálogo, una tienda online o un sitio de documentación. Los casos básicos se resuelven con [id] y [...slug], pero para secciones con anidamiento opcional la arquitectura más cómoda es [[...slug]].
En este artículo repasamos los tres patrones, aunque nos centraremos sobre todo en [[...slug]]: cómo encaja con las URL, qué llega en params, cómo declararlo de forma segura, cómo generar rutas estáticas, cómo construir breadcrumbs (migas de pan) y cómo tratar el SEO.
Mapa rápido
[slug]- un solo segmento. Ejemplo:/blog/my-postparams.slug: string[...slug]- catch-all obligatorio (hace falta al menos un segmento). Ejemplo:/docs/getting-started/installparams.slug: string[][[...slug]]- catch-all opcional (encaja tanto con la raíz como con cualquier número de segmentos). Ejemplos:/shop,/shop/men,/shop/men/t-shirtsparams.slug: string[] | undefined
Cuándo usar [[...slug]]
- Un solo componente y un solo layout tanto para la raíz como para todos los niveles anidados de una sección. Ejemplo: una documentación o un catálogo donde la página principal de la sección y las subpáginas tienen la misma estructura.
- Necesita una página raíz sin segmentos y, debajo, un anidamiento ilimitado. Ejemplo:
/docscomo índice y rutas más profundas como/docs/guides/setup/cloud. - Los breadcrumbs y la navegación se construyen a partir del array de segmentos, pero la página raíz también tiene que funcionar.
Implementación básica de [[...slug]]
Estructura de carpetas:
app/
docs/
[[...slug]]/
page.tsx
layout.tsx
page.tsx:
import { notFound } from 'next/navigation';
type PageProps = {
params: { slug?: string[] };
};
async function getNodeByPath(slug: string[]) {
const path = '/' + slug.join('/');
const res = await fetch(`${process.env.API_URL}/docs?path=${encodeURIComponent(path)}`, {
cache: 'force-cache'
});
if (!res.ok) return null;
return res.json() as Promise<{ title: string; html: string } | null>;
}
export default async function DocsPage({ params }: PageProps) {
const segments = params.slug ?? []; // At the root, it's undefined, so convert to []
const node = await getNodeByPath(segments);
if (!node) notFound();
return (
<article>
<h1>{node.title}</h1>
<div dangerouslySetInnerHTML={{ __html: node.html }} />
</article>
);
}
Puntos clave:
- En la raíz,
params.slugseráundefined. Conviértalo enseguida en[]. - La ruta encaja tanto con
/docscomo con cualquier anidamiento.
Breadcrumbs a partir de [[...slug]]
import Link from 'next/link';
type CrumbsProps = { segments: string[] };
export function Breadcrumbs({ segments }: CrumbsProps) {
const items = [
{ label: 'Docs', href: '/docs' },
...segments.map((s, i) => ({
label: decodeURIComponent(s),
href: '/docs/' + segments.slice(0, i + 1).map(encodeURIComponent).join('/')
}))
];
return (
<nav aria-label="breadcrumb">
<ol>
{items.map(item => (
<li key={item.href}>
<Link href={item.href}>{item.label}</Link>
</li>
))}
</ol>
</nav>
);
}
Uso:
export default function DocsPage({ params }: { params: { slug?: string[] } }) {
const segments = params.slug ?? [];
return (
<>
<Breadcrumbs segments={segments} />
{/* content */}
</>
);
}
Buenas prácticas:
- Utilice siempre
encodeURIComponental generar los enlaces. - Para mostrar el texto puede decodificarlo y enseñar nombres legibles para las personas.
Generación de rutas estáticas para [[...slug]]
Si conoce algunas rutas de antemano y quiere compilarlas con antelación, use generateStaticParams. Con el catch-all opcional puede generar tanto las rutas anidadas como la raíz.
export const revalidate = 300;
export async function generateStaticParams() {
return [
{ slug: [] }, // corresponds to /docs
{ slug: ['getting-started'] }, // corresponds to /docs/getting-started
{ slug: ['guides', 'install'] } // corresponds to /docs/guides/install
];
}
Notas:
- Con
[[...slug]]conviene describir la ruta raíz como{ slug: [] }. Así queda claro que hay que generar una ruta sin segmentos. - Para limitar estrictamente las rutas estáticas válidas, use:
SEO y enlaces canónicos para [[...slug]]
generateMetadata recibe params y permite generar los metadatos a partir de los segmentos.
import type { Metadata } from 'next';
type Props = { params: { slug?: string[] } };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const segments = params.slug ?? [];
const path = '/docs' + (segments.length ? '/' + segments.join('/') : '');
const res = await fetch(`${process.env.API_URL}/docs/meta?path=${encodeURIComponent(path)}`, {
cache: 'force-cache'
});
if (!res.ok) return { title: 'Documentation' };
const meta = await res.json() as { title: string; description?: string; canonical?: string };
return {
title: meta.title,
description: meta.description,
alternates: { canonical: meta.canonical ?? path },
openGraph: { title: meta.title, description: meta.description }
};
}
Recomendaciones:
- Tenga cuidado con las URL duplicadas. Si usa
trailingSlash, asegúrese de que la canónica coincide con la configuración real. - En páginas basadas en datos, fije un
revalidaterazonable para que los metadatos estén siempre actualizados.
Control de la caché y del renderizado
- Estático por defecto con ISR: export const revalidate = 300;
- Respuesta totalmente dinámica:
await fetch(url, { cache: 'no-store' });
// o a nivel de ruta: export const dynamic = 'force-dynamic';
- Enfoque mixto: cachee algunos datos y obtenga otros en el momento. Para bloques que se actualizan con frecuencia, considere Route Handlers con su propia caché.
[[...slug]] frente a [...slug] y trampas habituales
- Gestión de la raíz.
[[...slug]]captura tanto la raíz como las rutas anidadas. En la raíz,params.slugvaleundefined, así que normalícelo a[].[...slug]nunca captura la raíz: exige al menos un segmento. - Tipado. Tipe siempre de forma explícita:
params: { slug?: string[] }. Evitará errores al acceder aparams.slug.length. - Rutas estáticas. Añada
{ slug: [] }si quiere compilar la raíz de antemano. CondynamicParams = false, cualquier ruta quegenerateStaticParamsno devuelva dará 404. - Enlaces. No concatene los segmentos directamente. Al construir el href, use
encodeURIComponent. - Conflictos de rutas. Los grupos de rutas (por ejemplo
(marketing)) no aparecen en la URL, pero influyen en la jerarquía de layouts. Mantenga[[...slug]]aislado en su sección si tiene patrones parecidos. - 404. Para las rutas no válidas, llame siempre a
notFound(). Adapteapp/not-found.tsxa su UX.
Ejemplo completo de una sección de documentación con [[...slug]]
app/
docs/
layout.tsx
[[...slug]]/
page.tsx
loading.tsx
not-found.tsx
layout.tsx:
export default function DocsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="docs">
<aside>{/* section menu */}</aside>
<main>{children}</main>
</div>
);
}
page.tsx:
import { notFound } from 'next/navigation';
import { Breadcrumbs } from './_components/Breadcrumbs';
type PageProps = { params: { slug?: string[] } };
async function getDoc(path: string) {
const res = await fetch(`${process.env.API_URL}/docs?path=${encodeURIComponent(path)}`, {
next: { revalidate: 300 }
});
if (!res.ok) return null;
return res.json() as Promise<{ title: string; html: string } | null>;
}
export default async function DocsPage({ params }: PageProps) {
const segments = params.slug ?? [];
const path = '/docs' + (segments.length ? '/' + segments.join('/') : '');
const doc = await getDoc(path);
if (!doc) notFound();
return (
<article>
<Breadcrumbs segments={segments} />
<h1>{doc.title}</h1>
<div dangerouslySetInnerHTML={{ __html: doc.html }} />
</article>
);
}
not-found.tsx:
export default function NotFound() {
return <div>Página no encontrada</div>;
}
Lista de comprobación antes del despliegue
/docsabre la página principal de la sección.- Las rutas profundas, como
/docs/a/b/c, se renderizan correctamente. params.slugen la raíz está normalizado a[].- Los breadcrumbs generan enlaces correctos y codificados.
generateMetadatadevuelve el título y el canonical adecuados tanto para la raíz como para las páginas anidadas.notFound()funciona para los nodos no válidos.- Los ajustes de
revalidateydynamicse corresponden con la frescura de datos que necesita.
Conclusiones principales
[[...slug]] da una arquitectura limpia para secciones en las que la raíz y las páginas anidadas comparten una misma página y un mismo layout. Simplifica la navegación, los breadcrumbs, el SEO y el mantenimiento posterior.
Lo más importante:
- Tipe correctamente
params.slugy normalícelo a[]en la raíz. - Construya los enlaces de forma segura con
encodeURIComponent. - Si hace falta, compile las rutas de antemano con
{ slug: [] }. - Gestione el SEO con
generateMetadata. - Para las rutas no válidas, devuelva siempre
notFound().