Skip to content

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:

ArchivoPara qué sirve
page.tsxLa interfaz de la ruta. Sin él, la carpeta no es una ruta pública
layout.tsxEnvoltura compartida que persiste al navegar entre páginas hijas
loading.tsxInterfaz de carga mientras la página obtiene datos (usa Suspense)
error.tsxInterfaz si algo falla al renderizar (Error Boundary)
not-found.tsxInterfaz para 404
template.tsxComo layout, pero se vuelve a montar en cada navegación
route.tsEndpoint de API (lo verás en Route Handlers)
default.tsxRespaldo 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/clientes
tsx
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.tsx atrapa errores de su page y de los segmentos hijos, pero no de su propio layout.
  • Para errores del layout raíz existe app/global-error.tsx (debe incluir sus propios html y body).
  • En producción, el mensaje real del error se oculta al usuario por seguridad; usa error.digest para 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  → /registro

INFO

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

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 ​

  1. Crea un grupo (auth) con un layout que centre el contenido en una tarjeta, y las rutas /login y /registro.
  2. Crea app/blog/page.tsx que cargue posts de https://jsonplaceholder.typicode.com/posts?_limit=10 con un retraso artificial de 2 s, junto con su loading.tsx y su error.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.tsx hace pública una ruta.
  • layout.tsx persiste entre navegaciones y se anida; template.tsx se vuelve a montar.
  • loading.tsx (Suspense), error.tsx (Error Boundary, con 'use client') y not-found.tsx (404 o notFound()).
  • Los grupos (carpeta) organizan y permiten layouts distintos sin cambiar la URL.

Academia Web — aprende, copia y construye.