App Router

Por: Artiko
vinextapp-routerlayoutsrouting

Estructura del App Router

El App Router usa el directorio app/ con convenciones de archivos especiales:

ArchivoPropósito
layout.tsxLayout compartido (persiste entre navegaciones)
page.tsxUI única de la ruta
loading.tsxUI de carga (Suspense boundary)
error.tsxError boundary
not-found.tsxUI para 404

Layouts anidados

Los layouts envuelven a sus hijos y persisten entre navegaciones:

// app/layout.tsx — Layout raíz
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="es">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </head>
      <body>
        <nav>
          <a href="/">Inicio</a>
          <a href="/blog">Blog</a>
        </nav>
        <main>{children}</main>
      </body>
    </html>
  );
}
// app/blog/layout.tsx — Layout del blog
export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="blog-container">
      <aside>Sidebar del blog</aside>
      <section>{children}</section>
    </div>
  );
}

Al navegar entre páginas del blog, el layout raíz y el layout del blog se mantienen montados — solo cambia el page.tsx.

Pages

Cada page.tsx define la UI de una ruta específica:

// app/page.tsx
export default function HomePage() {
  return <h1>Página principal</h1>;
}

// app/blog/page.tsx
export default function BlogPage() {
  return <h1>Listado del blog</h1>;
}

Rutas dinámicas

// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return (
    <article>
      <h1>Post: {slug}</h1>
      <a href="/blog">Volver al blog</a>
    </article>
  );
}

En Vinext (como en Next.js 15+), params es una Promise. Puedes usar await para obtener los valores.

generateStaticParams

Pre-renderiza rutas dinámicas en build time:

export const dynamicParams = true;

export async function generateStaticParams() {
  return [
    { slug: "primer-post" },
    { slug: "segundo-post" },
  ];
}

Loading States

loading.tsx crea un Suspense boundary automático:

// app/blog/loading.tsx
export default function BlogLoading() {
  return (
    <div className="loading">
      <p>Cargando artículos...</p>
    </div>
  );
}

Mientras el page.tsx del blog resuelve sus datos asíncronos, el usuario ve el loading state. Vinext soporta streaming SSR: el HTML del loading se envía inmediatamente y el contenido real se inyecta cuando está listo.

Error Boundaries

error.tsx captura errores en tiempo de ejecución:

// app/blog/error.tsx
"use client";

export default function BlogError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Error en el blog</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Reintentar</button>
    </div>
  );
}

error.tsx debe ser un Client Component ("use client") porque usa onClick.

Not Found

// app/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>404 - Página no encontrada</h2>
      <a href="/">Volver al inicio</a>
    </div>
  );
}

Route Groups

Los route groups (nombre) organizan rutas sin afectar la URL:

app/
  (marketing)/
    layout.tsx          # Layout para marketing
    blog/page.tsx       # /blog
    about/page.tsx      # /about
  (shop)/
    layout.tsx          # Layout para tienda
    products/page.tsx   # /products
    cart/page.tsx       # /cart

Ambos grupos comparten el layout raíz pero tienen layouts internos diferentes. Las URLs no incluyen el nombre del grupo.

Parallel Routes

Los parallel routes renderizan múltiples páginas simultáneamente en el mismo layout:

app/dashboard/
  layout.tsx            # Recibe { children, metrics, activity }
  page.tsx              # children
  @metrics/
    page.tsx            # Slot de métricas
    default.tsx         # Fallback
  @activity/
    page.tsx            # Slot de actividad
    default.tsx         # Fallback
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  metrics,
  activity,
}: {
  children: React.ReactNode;
  metrics: React.ReactNode;
  activity: React.ReactNode;
}) {
  return (
    <div className="dashboard-grid">
      <div className="main">{children}</div>
      <div className="sidebar">
        {metrics}
        {activity}
      </div>
    </div>
  );
}

Cada slot (@metrics, @activity) tiene su propio loading.tsx, error.tsx y navegación independiente.

Catch-All y Optional Catch-All

app/docs/[...slug]/page.tsx       # /docs/a, /docs/a/b, /docs/a/b/c
app/shop/[[...slug]]/page.tsx     # /shop, /shop/a, /shop/a/b
// app/docs/[...slug]/page.tsx
export default async function DocsPage({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;
  return <h1>Docs: {slug.join("/")}</h1>;
}

Siguiente paso

En el siguiente capítulo profundizaremos en SSR y React Server Components: streaming, directivas y la arquitectura multi-ambiente de Vinext.