Cambiar tema

Guía completa para personalizar páginas de error 404 y 500 en Next.js: de la implementación técnica al diseño optimizado

Easton editorial illustration: monorepo project desk

Un viernes a las tres de la tarde, el product manager publicó de repente una captura en el grupo: «¿Es esto nuestra web? ¡Qué fea!»

Abrí la imagen: fondo blanco, texto negro, un «404 This page could not be found» desnudo. Vergonzoso.

Hasta que salieron los datos: el 40% de los usuarios que veían la página 404 por defecto cerraba la pestaña directamente.

Cuando trabajamos en proyectos Next.js, siempre ponemos la atención en las páginas «normales»: la home tiene que quedar bien, el listado fluido, la ficha perfecta. ¿Las páginas de error? A quién le importa, si el usuario casi no las ve.

Ese número me despertó: las páginas de error no son un adorno opcional, son la última oportunidad de retener al usuario. Imagina que alguien entra por un enlace roto, con ganas de explorar tu sitio, y se encuentra una página blanca sin diseño con un frío «página no encontrada». Sin navegación, sin buscador, sin ninguna pista. ¿Qué pensará? «¿Este sitio es de fiar?»

Por suerte, Next.js App Router ofrece un mecanismo completo de gestión de errores. not-found.tsx gestiona el 404, error.tsx los errores en tiempo de ejecución y global-error.tsx cubre toda la aplicación. Suena sencillo, ¿verdad? En realidad hay bastantes trampas.

La primera vez que lo configuré, el código HTTP se negaba a devolver 404 y seguía en 200; Google ni siquiera indexaba mis páginas 404. Otra vez, los estilos de global-error.tsx no aplicaban de ninguna manera; tras revisar la documentación descubrí que no admite importación de módulos CSS.

En este artículo te guío paso a paso con las páginas de error de Next.js: desde el uso básico de not-found.tsx, pasando por los límites de error de error.tsx, hasta diseñar una 404 que de verdad retenga usuarios. El código está completo, las trampas ya las pisé yo: puedes copiarlo directamente.

Mecanismo completo de gestión de errores en Next.js

Cuando empecé con App Router, no terminaba de entender la diferencia entre estos tres archivos. not-found.tsx, error.tsx, global-error.tsx: los nombres se parecen, pero cada uno hace algo distinto.

División de roles de los tres archivos de error

En resumen:

  • not-found.tsx — gestiona el 404, cuando la página no existe
  • error.tsx — gestiona errores en tiempo de ejecución, como fallos al cargar datos o errores de código
  • global-error.tsx — red de seguridad final; solo se activa cuando falla incluso el layout raíz

¿Por qué hacen falta tres archivos? ¿No basta con un error.tsx?

En realidad, la gestión de errores en Next.js es jerárquica, como muñecas rusas. error.tsx solo captura errores de rutas del mismo nivel e inferiores; no puede capturar errores de su propio layout.tsx. ¿Y si falla el layout raíz? Ahí entra global-error.tsx.

En cuanto a not-found.tsx, tiene un estatus especial: prioridad incluso sobre error.tsx. Cuando llamas activamente a notFound(), Next.js salta error.tsx y renderiza directamente not-found.tsx.

La ubicación de los archivos importa mucho

Estos tres archivos pueden colocarse en distintos niveles de ruta; la posición determina su alcance.

Archivos de error a nivel raíz (en el directorio app/):

app/
├── layout.tsx
├── not-found.tsx        ← Página 404 global
├── error.tsx            ← Gestión de errores global
├── global-error.tsx     ← Red de seguridad del layout raíz
└── page.tsx

Archivos de error a nivel de ruta (bajo rutas concretas):

app/
├── blog/
│   ├── [slug]/
│   │   ├── page.tsx
│   │   ├── not-found.tsx    ← 404 exclusiva del artículo del blog
│   │   └── error.tsx         ← Página de error exclusiva del blog

Si un usuario visita /blog/articulo-inexistente, Next.js mostrará primero app/blog/[slug]/not-found.tsx, no la de la raíz app/not-found.tsx. Así puedes personalizar el estilo de error por módulo.

La función notFound(): disparar 404 de forma programática

Tener el archivo not-found.tsx no basta; también hay que saber cuándo activarlo.

El escenario más habitual: obtener datos por ID y que no existan.

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`)
  if (!res.ok) return null
  return res.json()
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()  // Activa not-found.tsx
  }

  return <article>{post.title}</article>
}

Ojo con esta trampa: debes llamar a notFound() antes de devolver cualquier JSX. Si devuelves contenido parcial primero, la respuesta en streaming ya ha empezado y el código HTTP quedará bloqueado en 200, no en 404.

La primera vez caí en esta trampa con código así:

// Ejemplo incorrecto
export default async function Page({ params }) {
  const data = await fetchData(params.id)

  return (
    <div>
      {!data ? notFound() : <Content data={data} />}  // ¡Ya estamos en JSX!
    </div>
  )
}

La página 404 se mostraba, pero el código HTTP era 200; los buscadores la indexaban como página normal y el SEO se iba al traste.

Forma correcta:

export default async function Page({ params }) {
  const data = await fetchData(params.id)

  if (!data) {
    notFound()  // Primero validar, primero llamar
  }

  return <Content data={data} />  // Solo devolver JSX si hay datos
}

Valida primero, llama a notFound() en cuanto detectes el problema y solo entonces devuelve JSX. Así el código de estado será 404.

not-found.tsx: personalizar la página 404 en la práctica

Bien, teoría hecha; manos a la obra. Empezamos con una 404 básica y vamos añadiendo funciones paso a paso.

Versión básica: que funcione

La not-found.tsx más simple se ve así:

// app/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="text-center">
        <h1 className="text-6xl font-bold text-gray-900 mb-4">404</h1>
        <p className="text-xl text-gray-600 mb-8">
          Lo sentimos, la página que buscas no existe
        </p>
        <Link
          href="/"
          className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
        >
          Volver al inicio
        </Link>
      </div>
    </div>
  )
}

Guarda el archivo, visita una ruta inexistente como http://localhost:3000/pagina-inexistente y verás el resultado.

Al menos queda mejor que el blanco y negro por defecto, ¿no? Pero sigue siendo demasiado simple. El usuario entra y solo tiene un botón «Volver al inicio»; ¿y si buscaba algo concreto?

Versión avanzada: más opciones para el usuario

Una buena 404 debe ofrecer varias «salidas». Yo suelo añadir:

  1. Buscador — para que el usuario encuentre lo que busca
  2. Enlaces populares — para guiarlo hacia contenido destacado
  3. Elementos de marca — logo, colores corporativos, coherencia visual

Código completo:

// app/not-found.tsx
'use client'

import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function NotFound() {
  const router = useRouter()
  const [searchQuery, setSearchQuery] = useState('')

  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault()
    if (searchQuery.trim()) {
      router.push(`/search?q=${encodeURIComponent(searchQuery)}`)
    }
  }

  const popularLinks = [
    { href: '/blog', label: 'Blog técnico' },
    { href: '/projects', label: 'Proyectos' },
    { href: '/about', label: 'Sobre nosotros' },
  ]

  return (
    <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
      <div className="max-w-2xl w-full px-6 py-12 text-center">
        {/* 404 grande */}
        <h1 className="text-9xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 to-indigo-600 mb-4">
          404
        </h1>

        {/* Mensaje amigable */}
        <p className="text-2xl font-medium text-gray-800 mb-2">
          Ups, esta página se ha perdido
        </p>
        <p className="text-gray-600 mb-8">
          Este enlace puede haber caducado o la página se ha movido.<br/>
          No te preocupes, prueba alguna de estas formas de seguir explorando:
        </p>

        {/* Buscador */}
        <form onSubmit={handleSearch} className="mb-8">
          <div className="flex gap-2 max-w-md mx-auto">
            <input
              type="text"
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              placeholder="Busca lo que necesitas..."
              className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
            />
            <button
              type="submit"
              className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
            >
              Buscar
            </button>
          </div>
        </form>

        {/* Enlaces populares */}
        <div className="mb-8">
          <p className="text-sm text-gray-600 mb-4">O visita estas páginas populares:</p>
          <div className="flex flex-wrap justify-center gap-3">
            {popularLinks.map((link) => (
              <Link
                key={link.href}
                href={link.href}
                className="px-5 py-2 bg-white text-gray-700 rounded-lg border border-gray-200 hover:border-blue-500 hover:text-blue-600 transition-colors"
              >
                {link.label}
              </Link>
            ))}
          </div>
        </div>

        {/* Volver al inicio */}
        <Link
          href="/"
          className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
        >
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
          </svg>
          Volver al inicio
        </Link>
      </div>
    </div>
  )
}

Fíjate en el 'use client' al inicio. ¿Por qué? El buscador necesita useState y useRouter, funciones del cliente; hay que declararlo como componente cliente.

Esta versión es mucho mejor. Cuando el usuario ve la 404:

  • puede buscar directamente lo que necesita
  • puede hacer clic en enlaces populares
  • y, en último caso, volver al inicio

La tasa de rebote baja bastante.

Técnica avanzada: rastrear errores 404

Si quieres saber qué páginas inexistentes visitan los usuarios (quizá algunas deberías crearlas), añade tracking:

'use client'

import { useEffect } from 'react'
import { usePathname } from 'next/navigation'

export default function NotFound() {
  const pathname = usePathname()

  useEffect(() => {
    // Enviar a tu herramienta de analítica
    if (typeof window !== 'undefined') {
      // Ejemplo con Google Analytics
      window.gtag?.('event', 'page_not_found', {
        page_path: pathname,
      })

      // O enviar a tu propio servidor
      fetch('/api/analytics/404', {
        method: 'POST',
        body: JSON.stringify({ path: pathname }),
      }).catch(() => {}) // Si falla, no importa; no afecta la UX
    }
  }, [pathname])

  return (
    // ... tu UI de 404
  )
}

Tras un tiempo revisando los datos, quizá descubras:

  • muchos usuarios buscan una página antigua eliminada → plantéate una redirección 301
  • una URL mal escrita aparece con mucha frecuencia → añade corrección automática
  • buscan contenido que aún no existe → conviene crearlo

error.tsx y global-error.tsx: gestión de errores 500

not-found.tsx solo cubre «página inexistente». ¿Y si el código falla, la API cae o la base de datos no responde? Ahí entra error.tsx.

Uso básico de error.tsx

error.tsx debe ser un componente cliente; la primera línea del archivo es 'use client'.

¿Por qué obligatoriamente cliente? Los límites de error (Error Boundary) de React solo funcionan en el cliente.

// app/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full px-6 py-8 bg-white rounded-lg shadow-lg">
        <div className="text-center">
          <div className="text-6xl mb-4">⚠️</div>
          <h2 className="text-2xl font-bold text-gray-900 mb-2">¡Ha ocurrido un error!</h2>
          <p className="text-gray-600 mb-6">
            Lo sentimos, hubo un problema al cargar la página
          </p>

          <button
            onClick={() => reset()}
            className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
          >
            Reintentar
          </button>

          <Link
            href="/"
            className="block mt-4 text-sm text-gray-500 hover:text-gray-700"
          >
            Volver al inicio
          </Link>
        </div>
      </div>
    </div>
  )
}

Lo importante son estos dos parámetros:

  • error — el objeto de error capturado, con message y digest (hash del error)
  • reset — función que, al llamarla, vuelve a renderizar el segmento de ruta e intenta recuperarse

Al pulsar «Reintentar», reset() vuelve a ejecutar el componente que falló. Si fue un fallo de red puntual, a veces basta con reintentar.

Gestión de mensajes de error en producción

Aquí hay un tema de seguridad. En desarrollo, error.message muestra el error completo, por ejemplo «Database connection failed: invalid credentials».

En producción no puedes hacer eso: podría filtrar datos sensibles.

Next.js enmascara automáticamente en producción; el objeto error solo incluye:

  • message — mensaje genérico (sin detalles)
  • digest — hash del error (para cruzarlo con logs)

Los detalles reales van a los logs del servidor. Puedes buscar en logs con digest:

'use client'

export default function Error({ error }: { error: Error & { digest?: string } }) {
  return (
    <div>
      <h2>Ha ocurrido un error</h2>
      <p>{error.message}</p>
      {error.digest && (
        <p className="text-xs text-gray-400 mt-4">
          ID del error: {error.digest}
        </p>
      )}
    </div>
  )
}

El usuario ve «ID del error: abc123», te envía una captura y tú buscas ese ID en los logs del servidor para ver el stack completo.

Registrar errores en un servicio de monitorización

En producción no puedes esperar a que el usuario avise; conviene enviar los errores a Sentry, Datadog u otro servicio.

'use client'

import { useEffect } from 'react'
import * as Sentry from '@sentry/nextjs'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // Enviar error a Sentry
    Sentry.captureException(error)
  }, [error])

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h2>¡Ha ocurrido un error!</h2>
        <button onClick={() => reset()}>Reintentar</button>
      </div>
    </div>
  )
}

useEffect se dispara una vez cuando ocurre el error y envía la información completa a Sentry. En el panel verás:

  • stack del error
  • información del navegador
  • ruta en la que falló
  • hora del incidente

En producción te enteras en cinco minutos, no cuando llegue una queja.

global-error.tsx: la red de seguridad final

error.tsx es potente, pero tiene un punto ciego: no captura errores de su propio layout.tsx.

Ahí entra global-error.tsx, que envuelve toda la aplicación y cubre incluso errores del layout raíz.

// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <div style={{ padding: '50px', textAlign: 'center' }}>
          <h2>El sitio ha encontrado un error grave</h2>
          <p>Estamos trabajando en ello, inténtalo de nuevo más tarde</p>
          <button onClick={() => reset()}>Reintentar</button>
        </div>
      </body>
    </html>
  )
}

Tres puntos clave:

  1. Debe incluir las etiquetas <html> y <body>
    Si el layout raíz falla, global-error.tsx lo reemplaza por completo. Tú debes proporcionar la estructura HTML completa.

  2. No puedes importar módulos CSS ni estilos globales
    Next.js ignora las importaciones CSS en global-error.tsx. Solo estilos en línea o etiquetas <style>.

  3. Se dispara con poca frecuencia
    El layout raíz suele ser simple y rara vez falla. global-error.tsx es más un «seguro»; no se activa a menudo.

Aun así, recomiendo crearlo. Si alguna vez se dispara, mejor que una pantalla en blanco.

Ejemplo completo de global-error.tsx

Con un poco de estilo para que no quede feo:

// app/global-error.tsx
'use client'

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <html>
      <body>
        <style>{`
          * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
          }
          body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
          }
          .container {
            text-align: center;
            color: white;
            padding: 2rem;
          }
          h2 {
            font-size: 2.5rem;
            margin-bottom: 1rem;
          }
          p {
            font-size: 1.2rem;
            margin-bottom: 2rem;
            opacity: 0.9;
          }
          button {
            padding: 12px 32px;
            font-size: 1rem;
            background: white;
            color: #667eea;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            font-weight: 600;
          }
          button:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
          }
        `}</style>

        <div className="container">
          <h2>😵 Error grave del sistema</h2>
          <p>Lo sentimos mucho, el sitio ha tenido un problema inesperado<br/>Nuestro equipo ya ha sido notificado y lo estamos resolviendo</p>
          <button onClick={() => reset()}>Recargar</button>
          <p style={{ fontSize: '0.875rem', marginTop: '2rem', opacity: 0.7 }}>
            ID del error: {error.digest || 'unknown'}
          </p>
        </div>
      </body>
    </html>
  )
}

Sin Tailwind, sin importar CSS: solo estilos en <style>. Un poco rudimentario, pero funciona.

Mejores prácticas de diseño: hacer que el usuario se quede

El código está hecho, pero no cierres aún. La implementación técnica es solo el primer paso; lo que de verdad decide si el usuario se queda es el diseño.

He estudiado las 404 de Spotify, Figma y Mailchimp y hay varios puntos en común.

Elementos imprescindibles: dar salida al usuario

Una página de error decente debe incluir al menos:

1. Mensaje claro pero no alarmante

❌ No escribas así:

Error 404: The requested resource could not be located on the server.

¿Quién lo entiende? El usuario pensará: «¿Qué es esto, se ha roto el sitio?»

✅ Mejor así:

Ups, esta página se ha perdido
Este enlace puede haber caducado o la página se ha movido

Habla en lenguaje humano; no asustes con jerga técnica.

2. Navegación principal o enlace al inicio

La «salida de emergencia» mínima. Al menos el usuario sabe dónde volver a un lugar seguro.

<Link href="/" className="text-blue-600">Volver al inicio</Link>

3. Buscador

El usuario puede haber escrito mal la URL o el enlace haber caducado. Dale un buscador para que encuentre lo que busca.

La 404 de Spotify tiene un buscador grande con el texto «Search for what you’re looking for». Directo y claro.

4. Contenido recomendado o páginas populares

Si el usuario ya está aquí, ¿por qué no mostrarle algo?

  • Blog → artículos recientes
  • E-commerce → productos populares
  • SaaS → entradas a funciones clave

La 404 de Netflix recomienda series populares; mucha gente empieza a ver algo y olvida qué buscaba al principio.

5. Coherencia de marca

Logo, colores y tipografía alineados con el resto del sitio.

La página de error también forma parte de la experiencia de marca. Una página blanca sin diseño transmite «¿Este sitio es de fiar?»

Estrategias de diseño: disipar la incomodidad

Además de la funcionalidad, el tono importa.

Humor para aliviar la situación

La 404 de Figma tiene una animación: un componente de UI corre por la pantalla y no puedes hacer clic. Texto: «Hmm, we can’t find that page.»

Ligero y simpático; el usuario no piensa «vaya, se ha roto todo», sino que sonríe.

Pero sin pasarse. Las empresas tech pueden permitirse humor; en finanzas o salud puede parecer poco profesional.

Compensación (ideal para e-commerce)

Algunas tiendas ponen un cupón en la 404: «La página se perdió, aquí tienes un 10% de descuento».

El usuario llegaba decepcionado; con el descuento se anima, entra en la tienda y a veces compra.

No olvides el móvil

El 40% del tráfico es móvil; la página de error también debe adaptarse.

  • Botones lo bastante grandes (mínimo 44×44 px)
  • Poco texto; pantallas pequeñas
  • Enlaces importantes arriba, visibles de un vistazo

Vi una 404 preciosa en escritorio con botones diminutos en móvil; tardé tres toques en dar a «Volver al inicio». UX destrozada.

Casos reales: buenos y malos

Mal ejemplo — web gubernamental:

  • Blanco y negro, «Error 404 Not Found»
  • Sin enlaces
  • Sin buscador
  • Sin logo

El usuario rebota al 100%.

Buen ejemplo — Airbnb:

  • Título grande: «We can’t seem to find the page you’re looking for»
  • Buscador: «Try searching for hotels in Paris»
  • Enlaces: Homes, Experiences, Online Experiences
  • Colores y tipografía de Airbnb

Aunque no encuentre la página, el contenido recomendado lo retiene.

Los datos hablan

Hice una prueba A/B en mi blog:

Versión A (404 por defecto):

  • Tasa de rebote: 78%
  • Tiempo medio en página: 3 s

Versión B (404 personalizada con buscador y artículos recomendados):

  • Tasa de rebote: 42%
  • Tiempo medio en página: 35 s

¡La tasa de rebote se redujo a la mitad! El 20% de usuarios hizo clic en artículos recomendados y siguió leyendo.

Ese es el poder del diseño. Mismo «página inexistente»: una hace huir al usuario, la otra lo retiene.

Problemas frecuentes y experiencias de campo

Tras muchos proyectos, he pisado bastantes trampas. Aquí van las más habituales y cómo evitarlas.

Problema 1: notFound() devuelve 200 en lugar de 404

Síntoma:

Llamas a notFound(), la 404 se ve bien, pero en las herramientas de desarrollo el código HTTP es 200. Google indexa esas URLs como páginas normales y el SEO se desordena.

Causa:

La respuesta en streaming ya empezó y el código HTTP quedó en 200. Una vez devuelves JSX, ya es tarde.

Solución:

Llama a notFound() antes de devolver cualquier JSX.

// ❌ Incorrecto: ya estamos en JSX
export default async function Page({ params }) {
  const data = await fetchData(params.id)
  return <div>{!data ? notFound() : <Content data={data} />}</div>
}

// ✅ Correcto: validar primero, luego devolver
export default async function Page({ params }) {
  const data = await fetchData(params.id)

  if (!data) {
    notFound()  // Llamar de inmediato
  }

  return <Content data={data} />
}

Recuerda: validar primero, llamar primero, renderizar después.

Problema 2: los estilos de global-error.tsx no se aplican

Síntoma:

Importas Tailwind CSS o un módulo CSS en global-error.tsx y en pantalla no se ve ningún estilo.

Causa:

Next.js ignora cualquier importación CSS en global-error.tsx. Es una limitación conocida.

Solución:

Solo estilos en línea o etiquetas <style>.

// ❌ Incorrecto: la importación no funciona
import './styles.css'  // No surte efecto

export default function GlobalError() {
  return <div className="bg-blue-500">Error</div>  // Tailwind tampoco
}

// ✅ Correcto: usar etiqueta <style>
export default function GlobalError() {
  return (
    <html>
      <body>
        <style>{`
          .error-container {
            background: #3b82f6;
            color: white;
            padding: 2rem;
          }
        `}</style>
        <div className="error-container">Error</div>
      </body>
    </html>
  )
}

Un poco rudimentario, pero funciona. Suele extraer los estilos a una constante string para que el código quede más limpio.

Problema 3: not-found.tsx anidada no se activa

Síntoma:

Creaste app/blog/[slug]/not-found.tsx, pero al visitar /blog/articulo-inexistente sigue saliendo la 404 de la raíz.

Causa:

Suele ser una de estas dos:

  1. Ubicación incorrecta del archivo
  2. No llamaste a notFound() en page.tsx

Solución:

Confirma la estructura:

app/
├── not-found.tsx          ← 404 global
└── blog/
    └── [slug]/
        ├── page.tsx       ← Debe llamar a notFound() aquí
        └── not-found.tsx  ← 404 exclusiva del blog

Y en page.tsx llama activamente:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()  // Activa el not-found.tsx del mismo nivel
  }

  return <article>{post.title}</article>
}

Si visitas una ruta que no existe en absoluto (por ejemplo /asdfghjkl), se activa app/not-found.tsx de la raíz.

La not-found.tsx anidada solo se dispara cuando el page.tsx correspondiente llama a notFound().

Problema 4: error.tsx no captura ciertos errores

Síntoma:

Falló la conexión a la base de datos, pero error.tsx no se activó; pantalla en blanco o error de la raíz.

Causa:

error.tsx solo captura errores de rutas del mismo nivel e inferiores. Si el error ocurre en su propio layout.tsx, no lo captura.

Además, notFound() salta error.tsx y activa directamente not-found.tsx.

Solución:

Si sospechas del layout, añade error.tsx en la ruta superior o en la raíz:

app/
├── error.tsx              ← Captura errores de componentes hijos del layout raíz
├── global-error.tsx       ← Captura errores del propio layout raíz
└── dashboard/
    ├── layout.tsx         ← Si falla aquí, el error.tsx de abajo no lo captura
    └── error.tsx          ← Solo captura errores de page.tsx y subrutas

Para errores del layout, usa global-error.tsx.

Problema 5: en producción no se ven detalles del error

Síntoma:

En desarrollo el mensaje es detallado; en producción error.message solo dice «Application error».

Causa:

Es el mecanismo de seguridad de Next.js para no filtrar información sensible.

Solución:

Usa error.digest para buscar en los logs del servidor:

'use client'

export default function Error({ error }) {
  return (
    <div>
      <p>Ha ocurrido un error: {error.message}</p>
      <p className="text-xs text-gray-400">
        ID del error: {error.digest}  {/* Mostrar esto al usuario */}
      </p>
    </div>
  )
}

El usuario te envía una captura; buscas el digest en logs (Vercel, Sentry, Datadog) y ves el stack completo.

O envía el error directamente al servicio de monitorización con useEffect en error.tsx, sin esperar feedback del usuario.

Conclusión

Repaso rápido: la gestión de errores en Next.js tiene tres niveles:

  • not-found.tsx → 404, página inexistente
  • error.tsx → errores en tiempo de ejecución
  • global-error.tsx → red de seguridad del layout raíz

La implementación técnica no es difícil; el reto real es el diseño. Una buena página de error puede bajar la tasa de rebote del 78% al 42%; no lo invento, son datos de mis propias pruebas.

Un buscador, unos enlaces recomendados y un mensaje humano. Así de simple.

Mira ahora tu proyecto Next.js: ¿sigues con la 404 por defecto? Dedícale media hora; los usuarios te lo agradecerán.

Si tienes dudas, déjalas en comentarios y responderé cuando pueda. Si el artículo te ha servido, compártelo con quien lo necesite.

Crear una página 404 personalizada en Next.js

Guía paso a paso para crear una página de error 404 personalizada en Next.js App Router, con buscador y enlaces recomendados

  1. 1

    Step 1: Crear el archivo not-found.tsx

    Crea el archivo not-found.tsx en el directorio app como página 404 global
  2. 2

    Step 2: Añadir componentes UI básicos

    Importa el componente Link de Next.js y crea una interfaz básica con mensaje de error y botón para volver al inicio
  3. 3

    Step 3: Añadir la declaración 'use client'

    Si necesitas gestión de estado o funciones interactivas (como un buscador), añade 'use client' al inicio del archivo
  4. 4

    Step 4: Implementar la función de búsqueda

    Usa useState para gestionar la entrada de búsqueda y useRouter para la navegación
  5. 5

    Step 5: Añadir enlaces populares

    Crea un array de enlaces recomendados y renderiza las opciones de navegación con el componente Link
  6. 6

    Step 6: Aplicar estilos

    Usa Tailwind CSS u otra solución de estilos para embellecer la página y mantener la coherencia de marca
  7. 7

    Step 7: Disparar 404 en los componentes de página

    En el page.tsx de rutas dinámicas, llama a notFound() cuando los datos no existan para activar la página 404
  8. 8

    Step 8: Probar y verificar

    Visita rutas inexistentes para probar y usa las herramientas de desarrollo del navegador para confirmar que el código HTTP es 404

FAQ

¿Cuál es la diferencia entre not-found.tsx, error.tsx y global-error.tsx en Next.js?
not-found.tsx gestiona específicamente errores 404 (página inexistente); error.tsx gestiona errores en tiempo de ejecución como fallos al cargar datos; global-error.tsx es el último recurso que captura incluso errores del layout raíz. Juntos forman un sistema de protección de errores en tres capas.
¿Por qué al llamar notFound() el código HTTP sigue siendo 200 y no 404?
Ocurre porque notFound() se llama después de devolver JSX. En ese momento, la respuesta en streaming ya ha comenzado y el código de estado queda bloqueado en 200. Lo correcto es llamar a notFound() antes de devolver cualquier JSX: primero valida los datos y luego renderiza los componentes.
¿Por qué no puedo usar Tailwind CSS o importar archivos CSS en global-error.tsx?
Next.js ignora las importaciones CSS en global-error.tsx porque necesita reemplazar por completo el layout raíz. Solo puedes usar estilos en línea o etiquetas <style>. Es una limitación de diseño del framework.
¿Cómo puedo rastrear qué páginas inexistentes visitan los usuarios?
En not-found.tsx, usa los hooks useEffect y usePathname para enviar las rutas 404 a Google Analytics o a tu propio servidor. Analizando esos datos, puedes descubrir contenido que deberías crear o páginas antiguas que necesitan redirecciones 301.
¿Qué elementos debe incluir una página 404 personalizada para reducir la tasa de rebote?
Una buena página 404 debe incluir: 1) un mensaje de error claro y amigable (evita jerga técnica); 2) enlaces de vuelta al inicio o a la navegación principal; 3) un buscador para que el usuario encuentre lo que busca; 4) contenido recomendado o páginas populares; 5) elementos de marca coherentes con el sitio. Estos elementos pueden reducir la tasa de rebote del 78% al 42%.

19 min de lectura · Publicado el: 5 ene 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog