Chapter 5

Loading, Error and Not Found: Special Convention Files

Why These Three Special Files Exist

Every asynchronous operation has three possible outcomes: success, failure, and in-progress. Every resource can be absent. This is the fundamental state space of any application โ€” these three conditions will occur regardless of whether your framework helps you handle them.

Next.js App Router makes a deliberate choice: it elevates all three states to framework-level conventions.

All three files follow the same scoping rule: each file applies to its own directory and all nested directories. This means you can define different loading, error, and 404 experiences for different sections of your application, rather than using a single global fallback for everything.

loading.tsx: The Visual Foundation of Streaming

How It Works Mechanically

When you create a loading.tsx in a route directory, Next.js automatically wraps that directory's page.tsx in a React <Suspense> boundary, with loading.tsx's output as the fallback:

// What Next.js constructs internally (pseudocode)
<Suspense fallback={<LoadingComponent />}>
  <PageComponent />
</Suspense>

This activates React's streaming render capability: the server sends the HTML containing loading.tsx's content immediately โ€” this is called the "initial shell." When page.tsx completes its asynchronous work on the server (database queries, API calls), the remaining HTML is appended to the response via HTTP streaming. The browser swaps the loading placeholder with the real content.

This process requires no additional client-initiated requests. The entire response โ€” loading state and real content โ€” travels through a single HTTP connection. The user perceives: instant page response (skeleton appears), followed by content "flowing in."

The Core UX Value: Immediate Route Response

The most important user experience property of loading.tsx is that users see a response immediately after clicking a link, without waiting for the server to finish all data fetching. This eliminates the blank-screen delay that plagues server-rendered applications with no loading states.

// app/blog/loading.tsx โ€” Blog list loading state
export default function BlogLoading() {
  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      {/* Title skeleton */}
      <div className="h-10 bg-gray-200 rounded w-32 mb-8 animate-pulse" />

      {/* Post card skeletons */}
      <div className="space-y-6">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="animate-pulse border rounded-lg p-6">
            <div className="h-6 bg-gray-200 rounded w-3/4 mb-2" />
            <div className="h-4 bg-gray-200 rounded w-1/4 mb-3" />
            <div className="space-y-2">
              <div className="h-4 bg-gray-200 rounded" />
              <div className="h-4 bg-gray-200 rounded" />
              <div className="h-4 bg-gray-200 rounded w-5/6" />
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
// app/blog/[slug]/loading.tsx โ€” Post page loading state,
// shaped to match the actual post layout for a smoother transition
export default function PostLoading() {
  return (
    <article className="max-w-3xl mx-auto px-4 py-8 animate-pulse">
      {/* Title skeleton */}
      <div className="h-10 bg-gray-200 rounded w-4/5 mb-4" />

      {/* Author/meta skeleton */}
      <div className="flex items-center gap-4 mb-8">
        <div className="h-10 w-10 bg-gray-200 rounded-full flex-shrink-0" />
        <div className="h-4 bg-gray-200 rounded w-32" />
        <div className="h-4 bg-gray-200 rounded w-24" />
      </div>

      {/* Cover image skeleton */}
      <div className="h-64 bg-gray-200 rounded-lg mb-8" />

      {/* Body text skeleton */}
      <div className="space-y-3">
        {Array.from({ length: 8 }).map((_, i) => (
          <div
            key={i}
            className={`h-4 bg-gray-200 rounded ${
              i % 4 === 3 ? 'w-3/4' : 'w-full'
            }`}
          />
        ))}
      </div>
    </article>
  );
}

The skeleton's shape should mirror the actual content's layout. A skeleton that has the same proportions as the real page reduces layout shift when content appears and creates a more polished transition.

Relationship to Manual Suspense

loading.tsx is route-level automatic Suspense. For component-level Suspense โ€” when one data-heavy component on a page should load independently of the rest โ€” you still use <Suspense> manually:

// app/dashboard/page.tsx โ€” Mixing route-level loading and component-level Suspense
import { Suspense } from 'react';
import { RealtimeChart } from './RealtimeChart';
import { ActivityFeed } from './ActivityFeed';

// dashboard/loading.tsx handles the initial page load
// Individual components can have their own Suspense boundaries

export default async function DashboardPage() {
  // Critical data fetched here; loading.tsx covers the wait
  const summary = await fetchSummary();

  return (
    <div className="grid grid-cols-3 gap-6">
      {/* Renders immediately once page starts streaming */}
      <SummaryCards data={summary} />

      {/* Chart data is slow โ€” independent Suspense so it doesn't block cards */}
      <Suspense fallback={<ChartSkeleton />}>
        <RealtimeChart />
      </Suspense>

      {/* Activity stream also loads independently */}
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  );
}

This pattern lets critical content (SummaryCards) appear first, while slower non-critical content (chart, activity feed) streams in independently without blocking the primary rendering path.

error.tsx: Graceful Degradation for Runtime Errors

Why It Must Be a Client Component

error.tsx requires 'use client' โ€” this is not optional. The reason is foundational: React error boundaries require the componentDidCatch lifecycle method (or its hook equivalent), which exists only in client-side React. Server components have no "error boundary" concept โ€” errors in server components cause the server-side render to fail, handled by a different mechanism entirely.

error.tsx receives two props:

// app/blog/[slug]/error.tsx โ€” Post page error handler
'use client';

import { useEffect } from 'react';

interface Props {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function PostError({ error, reset }: Props) {
  useEffect(() => {
    // Report to error monitoring (Sentry, Datadog, etc.)
    reportError(error, {
      digest: error.digest,
      context: 'blog-post',
    });
  }, [error]);

  return (
    <div className="max-w-3xl mx-auto px-4 py-16 text-center">
      <div className="text-6xl mb-6">๐Ÿ˜•</div>
      <h2 className="text-2xl font-bold text-gray-900 mb-3">
        Something went wrong loading this post
      </h2>
      <p className="text-gray-600 mb-8">
        We've logged the error and are looking into it. Please try again.
      </p>

      {/* error.digest is a server-side error identifier
          useful for correlating client reports with server logs */}
      {error.digest && (
        <p className="text-xs text-gray-400 mb-6 font-mono">
          Error ID: {error.digest}
        </p>
      )}

      <div className="flex gap-3 justify-center">
        <button
          onClick={reset}
          className="px-6 py-2 bg-blue-600 text-white rounded-lg
                     hover:bg-blue-700 transition-colors"
        >
          Try Again
        </button>
        <a
          href="/blog"
          className="px-6 py-2 border border-gray-300 rounded-lg
                     hover:bg-gray-50 transition-colors"
        >
          Back to Blog
        </a>
      </div>
    </div>
  );
}

error.digest is a hash that Next.js generates server-side. Because the actual server exception is never sent to the client (which would be a security risk), error.digest serves as the correlation ID: users can report it to support, support can use it to find the exact error in server logs. This is why it's surfaced in the error UI.

Scoping: What error.tsx Catches and Doesn't Catch

error.tsx catches errors from the same directory's page.tsx and all child routes, but it does not catch errors from its sibling layout.tsx. This is intentional design, not a limitation: if the layout itself errors, the error boundary cannot render inside the broken layout.

app/
โ””โ”€โ”€ blog/
    โ”œโ”€โ”€ layout.tsx      โ† errors here are NOT caught by blog/error.tsx
    โ”œโ”€โ”€ error.tsx       โ† catches errors from page.tsx and child routes
    โ”œโ”€โ”€ page.tsx        โ† errors caught by blog/error.tsx
    โ””โ”€โ”€ [slug]/
        โ”œโ”€โ”€ page.tsx    โ† errors caught by blog/error.tsx if no [slug]/error.tsx exists
        โ””โ”€โ”€ error.tsx   โ† if present, catches [slug]/page.tsx errors first

To catch layout errors, the error boundary must be in the parent directory โ€” it wraps the layout from outside. This is why the hierarchy matters when designing error recovery.

global-error.tsx: The Last Line of Defense

When the root app/layout.tsx itself throws an error, no regular error.tsx can catch it โ€” they all live inside the root layout. app/global-error.tsx exists for this case:

// app/global-error.tsx โ€” Final fallback for catastrophic failures
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  // global-error replaces the root layout when it renders,
  // so it must provide its own complete html/body structure
  return (
    <html>
      <body>
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'center',
            minHeight: '100vh',
            fontFamily: 'system-ui, -apple-system, sans-serif',
            textAlign: 'center',
            padding: '2rem',
          }}
        >
          <h1 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '1rem' }}>
            Application Error
          </h1>
          <p style={{ color: '#6b7280', marginBottom: '2rem', maxWidth: '28rem' }}>
            Something went wrong at the application level. Please refresh the page.
          </p>
          {error.digest && (
            <p style={{ fontSize: '0.75rem', color: '#9ca3af', marginBottom: '1rem', fontFamily: 'monospace' }}>
              Error ID: {error.digest}
            </p>
          )}
          <button
            onClick={reset}
            style={{
              padding: '0.5rem 1.5rem',
              background: '#2563eb',
              color: 'white',
              border: 'none',
              borderRadius: '0.375rem',
              cursor: 'pointer',
              fontSize: '0.875rem',
            }}
          >
            Reload Application
          </button>
        </div>
      </body>
    </html>
  );
}

Two important notes about global-error.tsx:

First, it must include <html> and <body> tags because it replaces the root layout entirely.

Second, in development mode, Next.js shows its own error overlay on top of global-error.tsx. The file only takes full effect in production builds. This means you must test global-error.tsx with next build && next start, not next dev.

not-found.tsx: Handling Missing Resources

How notFound() and the File Work Together

not-found.tsx works in coordination with the notFound() function from next/navigation. Calling notFound() terminates the current component's rendering and transfers control to the nearest not-found.tsx in the directory hierarchy:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/data';

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  // Post doesn't exist โ€” trigger not-found.tsx
  // Code after notFound() never executes
  if (!post) notFound();

  // Post was soft-deleted โ€” also a 404 from the user's perspective
  if (post.deletedAt) notFound();

  // Post exists but user doesn't have permission โ€” this might be a redirect
  // to login instead of a 404, depending on your design
  if (!post.isPublic && !currentUser) redirect('/login');

  return <PostView post={post} />;
}
// app/blog/[slug]/not-found.tsx โ€” Post-specific 404 page
import Link from 'next/link';

export default function PostNotFound() {
  return (
    <div className="max-w-3xl mx-auto px-4 py-16 text-center">
      <h1 className="text-4xl font-bold text-gray-900 mb-4">404</h1>
      <h2 className="text-xl font-semibold text-gray-700 mb-3">
        Post Not Found
      </h2>
      <p className="text-gray-500 mb-8">
        This post may have been removed or the link is incorrect.
      </p>
      <div className="flex gap-3 justify-center">
        <Link
          href="/blog"
          className="px-6 py-3 bg-blue-600 text-white rounded-lg
                     font-medium hover:bg-blue-700 transition-colors"
        >
          Browse All Posts
        </Link>
        <Link
          href="/"
          className="px-6 py-3 border border-gray-300 rounded-lg
                     font-medium hover:bg-gray-50 transition-colors"
        >
          Go Home
        </Link>
      </div>
    </div>
  );
}

The Global not-found.tsx

app/not-found.tsx (at the root) is the global 404 page. It fires in two situations:

  1. The URL doesn't match any route in the application (no page.tsx handles it)
  2. Code calls notFound() but there's no not-found.tsx in the current directory hierarchy
// app/not-found.tsx โ€” Global 404 page
import Link from 'next/link';
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Page Not Found',
  robots: { index: false }, // Don't index 404 pages
};

export default function GlobalNotFound() {
  return (
    <div className="min-h-screen flex flex-col items-center justify-center bg-white">
      <div className="text-center px-4">
        <p className="text-9xl font-black text-gray-100 select-none mb-4">404</p>
        <h1 className="text-3xl font-bold text-gray-900 mb-3">
          Page not found
        </h1>
        <p className="text-gray-500 max-w-md mx-auto mb-8">
          The page you're looking for might have been moved, deleted, or may never have existed.
        </p>
        <div className="flex flex-col sm:flex-row gap-3 justify-center">
          <Link
            href="/"
            className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium
                       hover:bg-blue-700 transition-colors"
          >
            Go to homepage
          </Link>
          <Link
            href="/blog"
            className="px-6 py-3 border border-gray-300 rounded-lg font-medium
                       hover:bg-gray-50 transition-colors"
          >
            Browse blog
          </Link>
        </div>
      </div>
    </div>
  );
}

The Critical Distinction: not-found vs error

These two files are frequently confused, but they represent fundamentally different conditions:

Characteristic not-found.tsx error.tsx
Triggered by Resource doesn't exist (business logic) Unexpected exception (technical failure)
Trigger mechanism notFound() function or unmatched route throw or rejected Promise
HTTP semantics 404 Not Found 500 Internal Server Error
Must be client component No (can be server component) Yes (required)
User interpretation Content isn't here Something went wrong
Recovery action Navigate elsewhere Retry or contact support

notFound() handles expected "no content" situations: a query returned no results, a resource was deleted, content is not yet published. error.tsx handles unexpected technical failures: database connection lost, external API timeout, code bug.

Keeping this distinction semantically clean matters for both user experience and search engine behavior. A 404 response tells search engines to de-index the URL. A 500 response tells them to retry later. Getting these right has real SEO consequences.

The Complete Error Handling Architecture

Combining all three files with HTTP status codes produces a complete, layered error handling system:

User requests a URL
    โ”‚
    โ”œโ”€โ”€ URL matches no route
    โ”‚       โ””โ”€โ”€ โ†’ app/not-found.tsx (HTTP 404)
    โ”‚
    โ””โ”€โ”€ URL matches a route โ€” rendering begins
            โ”‚
            โ”œโ”€โ”€ page.tsx calls notFound()
            โ”‚       โ””โ”€โ”€ โ†’ nearest not-found.tsx (HTTP 404)
            โ”‚
            โ”œโ”€โ”€ page.tsx throws an unhandled exception
            โ”‚       โ””โ”€โ”€ โ†’ nearest error.tsx (HTTP 500)
            โ”‚
            โ”œโ”€โ”€ layout.tsx throws an unhandled exception
            โ”‚       โ””โ”€โ”€ โ†’ parent error.tsx or global-error.tsx
            โ”‚
            โ”œโ”€โ”€ page.tsx is pending (Suspense suspended)
            โ”‚       โ””โ”€โ”€ โ†’ nearest loading.tsx (HTTP 200 streaming)
            โ”‚
            โ””โ”€โ”€ rendering succeeds
                    โ””โ”€โ”€ โ†’ page.tsx content (HTTP 200)

For practical projects, the recommended minimum configuration is:

Granular not-found.tsx and error.tsx in every dynamic route (e.g., app/blog/[slug]/error.tsx separately from app/shop/[id]/error.tsx) is usually over-engineering unless the sections genuinely need different error UI.

Practical Example: A Complete Route With All Convention Files

Putting all five convention files together for a single route:

app/
โ””โ”€โ”€ blog/
    โ””โ”€โ”€ [slug]/
        โ”œโ”€โ”€ page.tsx          # The content
        โ”œโ”€โ”€ layout.tsx        # Post-specific shell (back button, share buttons)
        โ”œโ”€โ”€ loading.tsx       # Skeleton while fetching
        โ”œโ”€โ”€ error.tsx         # Runtime error recovery
        โ””โ”€โ”€ not-found.tsx     # When the post slug doesn't exist
// app/blog/[slug]/page.tsx โ€” The complete implementation
import { notFound } from 'next/navigation';
import { getPost, getRelatedPosts } from '@/lib/data';
import { Suspense } from 'react';
import { RelatedPosts } from './RelatedPosts';

interface Props {
  params: Promise<{ slug: string }>;
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;

  // This might throw (DB connection failure) โ†’ goes to error.tsx
  // Or return null (not found) โ†’ we call notFound() โ†’ goes to not-found.tsx
  const post = await getPost(slug);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <PostMeta post={post} />
      <PostContent content={post.content} />

      {/* Related posts load independently, don't block main content */}
      <Suspense fallback={<RelatedPostsSkeleton />}>
        <RelatedPosts tags={post.tags} currentSlug={slug} />
      </Suspense>
    </article>
  );
}

Each file plays its role: loading.tsx covers the initial render while getPost runs; error.tsx handles database failures gracefully; not-found.tsx provides a helpful experience when the slug doesn't exist; page.tsx handles only the success path.

Summary

loading.tsx, error.tsx, and not-found.tsx represent Next.js's commitment to making the three fundamental states of web application rendering โ€” in-progress, failed, and absent โ€” first-class framework concepts rather than something each developer must wire up manually.

loading.tsx uses React streaming to deliver instant page response, eliminating the blank-screen experience without sacrificing server-side data fetching. error.tsx as a client component error boundary converts runtime failures into recoverable UI, with error.digest bridging the gap between user-visible error IDs and server-side logs. global-error.tsx sits outside the root layout, providing a last-resort fallback for the most catastrophic failures. not-found.tsx combined with the notFound() function handles the business-logic absence case with semantically correct 404 responses, clearly separated from technical errors.

The scoping rules โ€” each file applies to its directory and children, bubbling up through the hierarchy when no closer file exists โ€” let different application sections have genuinely independent error handling while ensuring no state goes unhandled.

Rate this chapter
4.9  / 5  (63 ratings)

๐Ÿ’ฌ Comments