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.tsxenvuelve todos lospage.tsxal mismo nivel o más profundos.page.tsxes el punto final que realmente renderiza contenido para una URL.route.tsno renderiza interfaz, devuelve unaResponsecomo 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 ProductsPagepermiteawaitde datos directamente en el componente porque es un Server Component, sin necesidad deuseEffectogetServerSideProps.- 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 enparams.id. notFound()renderiza el archivonot-found.tsxmá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>
);
}
childrenaquí es el contenido deapp/products/page.tsxoapp/products/[id]/page.tsx.- Cuando navegas desde
/productsa/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 metadataes 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./pricingy/settingsusan layouts diferentes aunque estén bajoapp/.
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 });
}
GETyPOSTson exportaciones separadas, Next.js enruta automáticamente el request según el método HTTP a la función correcta.NextResponse.json(...)estableceContent-Type: application/jsonautomáticamente, no necesitasJSON.stringifymanualmente.- 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íaparams.id. - Devuelve
status: 404explí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.tsxse muestra automáticamente mientraspage.tsxdel mismo segmento está esperando datos — sin necesidad deuseState(isLoading).error.tsxdebe ser un Client Component ("use client") porque usareset— 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>;
}
generateMetadataes 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.mockreemplazaProductService.findAllcon 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 comouseState/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/yapp/para la misma ruta: los dos routers no pueden manejar la misma URL juntos, Next.js priorizaráapp/y las rutas enpages/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.