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]]

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-post params.slug: string
  • [...slug] - catch-all obligatorio (hace falta al menos un segmento). Ejemplo: /docs/getting-started/install params.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-shirts params.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: /docs como í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.slug será undefined. Conviértalo enseguida en [].
  • La ruta encaja tanto con /docs como 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 encodeURIComponent al 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 revalidate razonable 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.slug vale undefined, 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 a params.slug.length.
  • Rutas estáticas. Añada { slug: [] } si quiere compilar la raíz de antemano. Con dynamicParams = false, cualquier ruta que generateStaticParams no 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(). Adapte app/not-found.tsx a 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

  • /docs abre la página principal de la sección.
  • Las rutas profundas, como /docs/a/b/c, se renderizan correctamente.
  • params.slug en la raíz está normalizado a [].
  • Los breadcrumbs generan enlaces correctos y codificados.
  • generateMetadata devuelve 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 revalidate y dynamic se 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.slug y 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().