Tema
App Router y archivos especiales
El App Router organiza tu app con carpetas y archivos con nombres especiales: layout, page, loading, error, not-found y más. Cada uno tiene un papel concreto y, combinados, te dan layouts anidados, estados de carga y manejo de errores casi sin código.
Concepto
En app/, cada carpeta es un segmento de la URL, y los archivos especiales dentro de ella definen qué se muestra:
| Archivo | Para qué sirve |
|---|---|
page.tsx | La interfaz de la ruta. Sin él, la carpeta no es una ruta pública |
layout.tsx | Envoltura compartida que persiste al navegar entre páginas hijas |
loading.tsx | Interfaz de carga mientras la página obtiene datos (usa Suspense) |
error.tsx | Interfaz si algo falla al renderizar (Error Boundary) |
not-found.tsx | Interfaz para 404 |
template.tsx | Como layout, pero se vuelve a montar en cada navegación |
route.ts | Endpoint de API (lo verás en Route Handlers) |
default.tsx | Respaldo para rutas paralelas (avanzado) |
Next.js anida estos archivos así:
txt
<Layout>
<Template>
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loading />}>
<ErrorBoundary fallback={<NotFound />}>
<Page />Analogía: el layout es el marco de un cuadro que nunca cambias; page es la pintura que cambias; loading es el letrero "pintura en camino"; error es el aviso "esta pintura se dañó, intenta de nuevo".
Layouts anidados
Cada carpeta puede tener su propio layout, que se anida dentro del layout padre.
txt
app/
├── layout.tsx ← header y footer de TODO el sitio
├── page.tsx → /
└── dashboard/
├── layout.tsx ← sidebar sólo para /dashboard/*
├── page.tsx → /dashboard
├── ventas/page.tsx → /dashboard/ventas
└── clientes/page.tsx → /dashboard/clientestsx
import type { Metadata } from 'next'
import Link from 'next/link'
import './globals.css'
export const metadata: Metadata = {
title: { default: 'MiEmpresa', template: '%s | MiEmpresa' }
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es">
<body className="flex min-h-screen flex-col">
<header className="border-b p-4">
<nav className="mx-auto flex max-w-6xl gap-4">
<Link href="/" className="font-bold">MiEmpresa</Link>
<Link href="/dashboard">Dashboard</Link>
</nav>
</header>
<div className="flex-1">{children}</div>
<footer className="border-t p-4 text-center text-sm text-gray-500">© 2026 MiEmpresa</footer>
</body>
</html>
)
}tsx
import Link from 'next/link'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto flex max-w-6xl gap-6 p-4">
<aside className="w-48 shrink-0 space-y-2">
<Link href="/dashboard" className="block rounded px-3 py-2 hover:bg-gray-100">Resumen</Link>
<Link href="/dashboard/ventas" className="block rounded px-3 py-2 hover:bg-gray-100">Ventas</Link>
<Link href="/dashboard/clientes" className="block rounded px-3 py-2 hover:bg-gray-100">Clientes</Link>
</aside>
<main className="flex-1">{children}</main>
</div>
)
}tsx
export const metadata = { title: 'Resumen' } // → "Resumen | MiEmpresa"
export default function Dashboard() {
return <h1 className="text-2xl font-bold">Resumen del negocio</h1>
}tsx
export const metadata = { title: 'Ventas' }
export default function Ventas() {
return <h1 className="text-2xl font-bold">Ventas del mes</h1>
}Al navegar entre /dashboard y /dashboard/ventas, el sidebar no se vuelve a renderizar y conserva su estado: sólo cambia el contenido.
loading.tsx: estados de carga
Si una página es async y tarda en obtener datos, Next.js muestra loading.tsx de inmediato y reemplaza por la página cuando está lista (streaming).
tsx
type Usuario = { id: number; firstName: string; lastName: string; email: string }
export const metadata = { title: 'Clientes' }
export default async function Clientes() {
// Simulamos una consulta lenta
await new Promise((r) => setTimeout(r, 1500))
const res = await fetch('https://dummyjson.com/users?limit=10&select=firstName,lastName,email')
const { users }: { users: Usuario[] } = await res.json()
return (
<>
<h1 className="mb-4 text-2xl font-bold">Clientes</h1>
<ul className="divide-y rounded-lg border">
{users.map((u) => (
<li key={u.id} className="p-3">
<strong>{u.firstName} {u.lastName}</strong> — {u.email}
</li>
))}
</ul>
</>
)
}tsx
// Se muestra al instante mientras la página carga
export default function Cargando() {
return (
<div className="space-y-3" aria-busy="true" aria-label="Cargando clientes">
<div className="h-8 w-40 animate-pulse rounded bg-gray-200" />
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded bg-gray-100" />
))}
</div>
)
}El layout (sidebar) se muestra de inmediato; sólo el área del contenido muestra el skeleton.
error.tsx: manejo de errores
Si la página lanza un error, se muestra error.tsx en lugar de romper todo el sitio. Debe ser un Client Component ('use client').
tsx
// app/dashboard/error.tsx
'use client'
import { useEffect } from 'react'
export default function ErrorDashboard({
error,
reset
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Envía el error a tu servicio de monitoreo (Sentry, etc.)
console.error(error)
}, [error])
return (
<div role="alert" className="rounded-lg border border-red-200 bg-red-50 p-6 text-red-800">
<h2 className="text-lg font-bold">Algo salió mal al cargar esta sección</h2>
<p className="mt-1 text-sm">Intenta de nuevo. Si el problema continúa, contacta a soporte.</p>
{/* reset vuelve a intentar renderizar el segmento */}
<button onClick={() => reset()} className="mt-4 rounded bg-red-600 px-4 py-2 text-white">
Reintentar
</button>
</div>
)
}error.tsxatrapa errores de supagey de los segmentos hijos, pero no de su propiolayout.- Para errores del layout raíz existe
app/global-error.tsx(debe incluir sus propioshtmlybody). - En producción, el mensaje real del error se oculta al usuario por seguridad; usa
error.digestpara buscarlo en tus logs.
not-found.tsx: páginas 404
tsx
// app/not-found.tsx — para cualquier URL que no exista
import Link from 'next/link'
export default function NoEncontrado() {
return (
<main className="mx-auto max-w-md p-16 text-center">
<p className="text-6xl">🧭</p>
<h1 className="mt-4 text-2xl font-bold">Página no encontrada</h1>
<p className="mt-2 text-gray-600">La página que buscas no existe o se movió.</p>
<Link href="/" className="mt-6 inline-block rounded bg-violet-600 px-4 py-2 text-white">
Ir al inicio
</Link>
</main>
)
}También puedes provocar un 404 desde tu código con notFound(), por ejemplo cuando un producto no existe en la base de datos:
tsx
import { notFound } from 'next/navigation'
export default async function Producto({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const res = await fetch(`https://dummyjson.com/products/${id}`)
if (!res.ok) notFound() // muestra el not-found.tsx más cercano
const producto = await res.json()
return <h1>{producto.title}</h1>
}Grupos de rutas: (carpeta)
Una carpeta entre paréntesis organiza sin afectar la URL. Útil para tener layouts distintos en distintas secciones:
txt
app/
├── (marketing)/
│ ├── layout.tsx ← header con menú público y footer grande
│ ├── page.tsx → /
│ ├── precios/page.tsx → /precios
│ └── blog/page.tsx → /blog
├── (app)/
│ ├── layout.tsx ← sidebar de la aplicación
│ ├── panel/page.tsx → /panel
│ └── ajustes/page.tsx → /ajustes
└── (auth)/
├── layout.tsx ← tarjeta centrada, sin menú
├── login/page.tsx → /login
└── registro/page.tsx → /registroINFO
Si usas grupos con layouts distintos y ninguno en la raíz, cada grupo puede tener su propio layout raíz con html y body. Lo habitual es dejar app/layout.tsx con lo común (fuentes, html, body) y que cada grupo agregue su estructura.
template.tsx
Igual que un layout, pero se vuelve a montar en cada navegación (su estado se reinicia y los efectos se ejecutan de nuevo). Útil para animaciones de entrada por página o registrar vistas:
tsx
// app/(marketing)/template.tsx
export default function Template({ children }: { children: React.ReactNode }) {
return <div className="animate-[aparecer_0.3s_ease-out]">{children}</div>
}Navegación programática
En Client Components usa los hooks de next/navigation:
tsx
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
export default function Controles() {
const router = useRouter()
const pathname = usePathname() // "/dashboard/ventas"
const searchParams = useSearchParams() // ?mes=marzo
return (
<div>
<p>Estás en {pathname} (mes: {searchParams.get('mes') ?? 'actual'})</p>
<button onClick={() => router.push('/dashboard')}>Ir al resumen</button>
<button onClick={() => router.back()}>Atrás</button>
<button onClick={() => router.refresh()}>Recargar datos del servidor</button>
</div>
)
}En Server Components y Server Actions usa redirect('/ruta') de next/navigation.
Enlace activo
tsx
// components/NavLink.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
export default function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const pathname = usePathname()
const activo = pathname === href || (href !== '/' && pathname.startsWith(href))
return (
<Link
href={href}
aria-current={activo ? 'page' : undefined}
className={activo ? 'font-semibold text-violet-700' : 'text-gray-600 hover:text-gray-900'}
>
{children}
</Link>
)
}Buena práctica
Coloca loading.tsx y error.tsx en cada sección importante (no sólo en la raíz). Así, si una parte falla o tarda, el resto de la página sigue funcionando.
Error común
Olvidar 'use client' en error.tsx. Next.js mostrará un error porque los Error Boundaries necesitan ejecutarse en el navegador.
Ejercicios
- Crea un grupo
(auth)con un layout que centre el contenido en una tarjeta, y las rutas/loginy/registro. - Crea
app/blog/page.tsxque cargue posts dehttps://jsonplaceholder.typicode.com/posts?_limit=10con un retraso artificial de 2 s, junto con suloading.tsxy suerror.tsx. Provoca un error cambiando la URL para probarlo.
Ver solución
tsx
// app/(auth)/layout.tsx
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<main className="grid min-h-[80vh] place-items-center bg-gray-50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-8 shadow-lg">{children}</div>
</main>
)
}
// app/(auth)/login/page.tsx
export const metadata = { title: 'Iniciar sesión' }
export default function Login() {
return <h1 className="text-2xl font-bold">Iniciar sesión</h1>
}
// app/(auth)/registro/page.tsx
export const metadata = { title: 'Crear cuenta' }
export default function Registro() {
return <h1 className="text-2xl font-bold">Crear cuenta</h1>
}tsx
// app/blog/page.tsx
type Post = { id: number; title: string }
export default async function Blog() {
await new Promise((r) => setTimeout(r, 2000))
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10')
if (!res.ok) throw new Error('No se pudieron cargar los posts')
const posts: Post[] = await res.json()
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="mb-4 text-3xl font-bold">Blog</h1>
<ul className="space-y-2">{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
</main>
)
}
// app/blog/loading.tsx
export default function Loading() {
return <p className="p-8">Cargando artículos…</p>
}
// app/blog/error.tsx
'use client'
export default function Error({ reset }: { error: Error; reset: () => void }) {
return (
<div className="p-8">
<p>No pudimos cargar el blog.</p>
<button onClick={reset} className="underline">Reintentar</button>
</div>
)
}Para probar el error, cambia la URL del fetch a https://jsonplaceholder.typicode.com/no-existe.
Resumen
- Carpetas = segmentos de URL;
page.tsxhace pública una ruta. layout.tsxpersiste entre navegaciones y se anida;template.tsxse vuelve a montar.loading.tsx(Suspense),error.tsx(Error Boundary, con'use client') ynot-found.tsx(404 onotFound()).- Los grupos
(carpeta)organizan y permiten layouts distintos sin cambiar la URL.