App Router en Next.js: Layout, Page y Route

1. Descripción general

App Router es el mecanismo de enrutamiento predeterminado de Next.js a partir de la versión 13, reemplazando gradualmente Pages Router (pages/). Se basa en la convención de archivos en la carpeta app/: page.tsx crea una ruta, layout.tsx crea interfaz compartida, route.ts crea un endpoint de API. Después de leer este artículo, sabrás cómo organizar un proyecto Next.js con App Router, escribir layouts anidados, crear rutas dinámicas y route handlers, así como cómo probar estos componentes.

2. Estructura de carpetas básica

App Router usa carpeta = segmento de URL. Cada carpeta dentro de app/ puede contener archivos especiales que determinan el comportamiento de la ruta.

app/
├── layout.tsx          # layout raíz, obligatorio tener <html>, <body>
├── page.tsx             # ruta "/"
├── about/
│   └── page.tsx         # ruta "/about"
├── products/
│   ├── layout.tsx       # layout específico para /products/*
│   ├── page.tsx         # ruta "/products"
│   └── [id]/
│       └── page.tsx     # ruta "/products/123"
└── api/
    └── orders/
        └── route.ts     # API "/api/orders"
  • layout.tsx envuelve todos los page.tsx al mismo nivel o más profundos.
  • page.tsx es el punto final que realmente renderiza contenido para una URL.
  • route.ts no renderiza interfaz, devuelve una Response como un endpoint REST.

Nota: Los nombres de carpeta que no comienzan con minúscula (por ejemplo, Products) siguen funcionando, pero Next.js recomienda usar kebab-case o camelCase consistente para evitar confusiones en sistemas operativos sensibles a mayúsculas.

3. Page: definir rutas

El archivo page.tsx exporta un componente React que es la interfaz principal para la ruta. Por defecto es un Server Component.

// app/products/page.tsx
import { ProductService } from "@/services/product-service";

export default async function ProductsPage() {
  const products = await ProductService.findAll();

  return (
    <main>
      <h1>Lista de productos</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>{product.name} - {product.price}đ</li>
        ))}
      </ul>
    </main>
  );
}
  • async function ProductsPage permite await de datos directamente en el componente porque es un Server Component, sin necesidad de useEffect o getServerSideProps.
  • Next.js renderiza automáticamente HTML en el servidor y lo envía al navegador con el contenido ya presente — excelente para SEO y tiempo de primera carga.
  • La ruta correspondiente es /products.

3.1. Rutas dinámicas con params

// app/products/[id]/page.tsx
import { ProductService } from "@/services/product-service";
import { notFound } from "next/navigation";

type ProductPageProps = {
  params: { id: string };
};

export default async function ProductDetailPage({ params }: ProductPageProps) {
  const product = await ProductService.findById(params.id);

  if (!product) {
    notFound();
  }

  return <h1>{product.name}</h1>;
}
  • La carpeta [id] crea un segmento dinámico, Next.js inyecta el valor real en params.id.
  • notFound() renderiza el archivo not-found.tsx más cercano en lugar de crashes — esta es la forma estándar de manejar 404 en App Router.

4. Layout: interfaz compartida

Un layout recibe la prop children que es la ruta hijo, manteniendo el estado intacto al navegar entre páginas dentro del mismo layout (por ejemplo, la barra lateral no se re-renderiza).

// app/products/layout.tsx
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="products-layout">
      <aside>Filtros de productos</aside>
      <section>{children}</section>
    </div>
  );
}
  • children aquí es el contenido de app/products/page.tsx o app/products/[id]/page.tsx.
  • Cuando navegas desde /products a /products/123, la parte <aside> no se desmonta/remonta — esta es la principal ventaja del layout sobre importar manualmente un componente compartido en cada página.

4.1. Root layout es obligatorio

// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Tienda Nón Lá",
  description: "Tienda de demostración de App Router",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="es">
      <body>
        <header>Tienda Nón Lá</header>
        {children}
      </body>
    </html>
  );
}
  • El root layout debe contener <html> y <body> porque es el layout más alto, que envuelve toda la aplicación.
  • export const metadata es la forma declarativa de establecer <title>, <meta>, Next.js los inyecta automáticamente en el <head> — sin necesidad de manipulación manual del DOM.

Nota: Si olvidas crear app/layout.tsx, Next.js reportará un error de build inmediatamente porque toda ruta necesita al menos un root layout.

5. Grupos de rutas y segmentos paralelos

A veces quieres agrupar rutas para organizar el código pero no deseas que el grupo aparezca en la URL. Usa route group con paréntesis (name).

app/
├── (marketing)/
│   ├── layout.tsx       # layout específico para páginas de marketing
│   ├── page.tsx         # ruta "/"
│   └── pricing/
│       └── page.tsx     # ruta "/pricing"
└── (dashboard)/
    ├── layout.tsx       # layout específico para dashboard
    └── settings/
        └── page.tsx     # ruta "/settings"
  • (marketing) y (dashboard) no aparecen en la URL, solo ayudan a aplicar layouts diferentes para cada grupo de páginas.
  • /pricing y /settings usan layouts diferentes aunque estén bajo app/.

6. Route Handlers: construir APIs en App Router

El archivo route.ts reemplaza pages/api/*.ts de Pages Router, exportando funciones según el método HTTP.

// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";

export async function GET() {
  const orders = await OrderService.findAll();
  return NextResponse.json(orders);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const order = await OrderService.create(body);
  return NextResponse.json(order, { status: 201 });
}
  • GET y POST son exportaciones separadas, Next.js enruta automáticamente el request según el método HTTP a la función correcta.
  • NextResponse.json(...) establece Content-Type: application/json automáticamente, no necesitas JSON.stringify manualmente.
  • Este endpoint se sirve en /api/orders, completamente independiente de la interfaz — no renderiza React.

6.1. Route handler dinámico

// app/api/orders/[id]/route.ts
import { NextResponse } from "next/server";
import { OrderService } from "@/services/order-service";

export async function GET(_: Request, { params }: { params: { id: string } }) {
  const order = await OrderService.findById(params.id);

  if (!order) {
    return NextResponse.json({ message: "Pedido no encontrado" }, { status: 404 });
  }

  return NextResponse.json(order);
}
  • Como en páginas dinámicas, la carpeta [id] permite extraer el valor de la URL vía params.id.
  • Devuelve status: 404 explícitamente en lugar de dejar que el cliente adivine el error.

7. Archivos especiales: loading, error, not-found

App Router usa React Suspense y Error Boundary implícitamente a través de archivos por convención, sin necesidad de escribir boundaries manualmente.

// app/products/loading.tsx
export default function ProductsLoading() {
  return <p>Cargando lista de productos...</p>;
}
// app/products/error.tsx
"use client";

export default function ProductsError({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <p>Error: {error.message}</p>
      <button onClick={reset}>Reintentar</button>
    </div>
  );
}
  • loading.tsx se muestra automáticamente mientras page.tsx del mismo segmento está esperando datos — sin necesidad de useState(isLoading).
  • error.tsx debe ser un Client Component ("use client") porque usa reset — una función interactiva del lado del cliente para re-renderizar el segmento con error.
  • Next.js envuelve automáticamente el segmento con <Suspense> y error boundary correspondiente basándose en estos archivos.

8. Metadata y layouts anidados

Cada segmento puede exportar su propio metadata, Next.js los fusiona en orden desde el layout raíz hasta la página.

// app/products/[id]/page.tsx
import type { Metadata } from "next";
import { ProductService } from "@/services/product-service";

type Props = { params: { id: string } };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const product = await ProductService.findById(params.id);
  return { title: product ? `${product.name} - Tienda Nón Lá` : "No encontrado" };
}

export default async function ProductDetailPage({ params }: Props) {
  const product = await ProductService.findById(params.id);
  return <h1>{product?.name}</h1>;
}
  • generateMetadata es una función dinámica que permite establecer <title> basado en datos reales del producto — útil para SEO de páginas de detalle.
  • El metadata de la página hijo sobrescribe campos del mismo nombre del layout padre, otros campos se mantienen.

9. Probar componentes de página

Con Server Components asincronos, la forma más simple de hacer pruebas es llamar directamente la función componente y verificar el JSX devuelto, o usar React Testing Library después de renderizar.

{
  "devDependencies": {
    "vitest": "^2.0.0",
    "@testing-library/react": "^16.0.0"
  }
}
// services/product-service.test.ts
import { describe, it, expect, vi } from "vitest";
import { ProductService } from "@/services/product-service";
import ProductsPage from "@/app/products/page";
import { render, screen } from "@testing-library/react";

vi.mock("@/services/product-service", () => ({
  ProductService: {
    findAll: vi.fn(),
  },
}));

describe("ProductsPage", () => {
  it("muestra la lista de productos devuelta por el servicio", async () => {
    (ProductService.findAll as any).mockResolvedValue([
      { id: "1", name: "Sombrero Nón Lá A", price: 50000 },
    ]);

    const jsx = await ProductsPage();
    render(jsx);

    expect(screen.getByText("Sombrero Nón Lá A - 50000đ")).toBeInTheDocument();
  });
});
  • ProductsPage() se llama como una función async pura porque básicamente solo retorna JSX — sin necesidad de framework especial de test para Server Components.
  • vi.mock reemplaza ProductService.findAll con un stub que devuelve datos falsos, ayudando a hacer pruebas sin dependencia de base de datos real.
  • render(jsx) de Testing Library verifica que el JSX renderizado contiene el contenido esperado en el DOM.

10. Errores comunes y cuándo no usar App Router

  • Olvidar "use client" al usar hooks como useState/useEffect: Server Components no soportan estos hooks, Next.js reportará un error de build claramente.
  • Poner lógica de fetch sensible (claves API) en Client Components: el código se incluirá en el bundle del navegador. Siempre prioriza fetch en Server Components o Route Handlers.
  • Abusar de route groups solo “para que se vea bonito”: si no hay una necesidad real de layouts diferentes, los route groups solo hacen el árbol de carpetas más confuso.
  • Mezclar pages/ y app/ para la misma ruta: los dos routers no pueden manejar la misma URL juntos, Next.js priorizará app/ y las rutas en pages/ se ignorarán — fácil de confundir durante migración gradual.

Nota: Si tu proyecto funciona bien con Pages Router y no necesitas Server Components, Streaming o Server Actions, migrar completamente a App Router no siempre es necesario. Evalúa los beneficios antes de hacer un cambio total de enrutamiento.

11. Conclusión

App Router organiza una aplicación Next.js mediante tres tipos principales de archivos: page.tsx define rutas, layout.tsx comparte interfaz entre múltiples rutas, y route.ts construye APIs sin renderizar HTML. Combinado con archivos por convención como loading.tsx, error.tsx, not-found.tsx, puedes crear experiencias de carga/error fluidas sin escribir boundaries manualmente. Al diseñar el árbol de carpetas, prioriza agrupar por layouts que realmente necesitan compartirse, evita route groups innecesarios y siempre verifica los límites entre Server y Client Components para evitar exponer lógica sensible al navegador.

Next.jsApp RouterReactServer ComponentsRouting