Skip to main content

Overview

Next.js App Router provides built-in support for loading states and error boundaries through special files. These features help create better user experiences by handling loading states gracefully and providing proper error recovery.

Loading UI

The loading.tsx file creates a loading UI that shows while a route segment is loading.

Loading Benefits

  • Instant loading feedback
  • Better perceived performance
  • Prevents layout shift
  • Automatic implementation

Loading Scope

  • Applies to route segment
  • Wraps page and children
  • Automatic activation
  • Nested loading support

Basic Loading Component

// app/loading.tsx
export default function Loading() {
  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
    </div>
  );
}

Skeleton Loading

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="mb-8">
        <div className="h-8 bg-gray-200 rounded w-1/3 animate-pulse"></div>
        <div className="h-4 bg-gray-200 rounded w-1/2 mt-2 animate-pulse"></div>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
        {Array.from({ length: 4 }).map((_, i) => (
          <div key={i} className="bg-white p-6 rounded-lg shadow-md">
            <div className="h-4 bg-gray-200 rounded w-24 mb-2 animate-pulse"></div>
            <div className="h-8 bg-gray-200 rounded w-16 animate-pulse"></div>
          </div>
        ))}
      </div>

      <div className="bg-white rounded-lg shadow-md p-8">
        <div className="space-y-4">
          {Array.from({ length: 5 }).map((_, i) => (
            <div
              key={i}
              className="h-4 bg-gray-200 rounded animate-pulse"></div>
          ))}
        </div>
      </div>
    </div>
  );
}

Nested Loading States

app/
├── loading.tsx              # Root loading
├── dashboard/
│   ├── loading.tsx          # Dashboard loading
│   ├── page.tsx
│   └── settings/
│       ├── loading.tsx      # Settings loading
│       └── page.tsx
Each loading component applies to its route segment and children.

Error UI

The error.tsx file creates an error UI that catches errors in route segments.

Error Benefits

  • Graceful error handling
  • User-friendly error messages
  • Error recovery options
  • Automatic error catching

Error Scope

  • Catches errors in route segment
  • Wraps page and children
  • Automatic activation
  • Nested error boundaries

Basic Error Component

// app/error.tsx
"use client";

import { useEffect } from "react";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div className="error-boundary">
      <h2>Something went wrong!</h2>
      <p>An error occurred while loading this page.</p>
      <button
        onClick={reset}
        className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
        Try again
      </button>
    </div>
  );
}

Advanced Error Component

// app/dashboard/error.tsx
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const router = useRouter();

  useEffect(() => {
    // Log error to monitoring service
    console.error("Dashboard error:", error);
  }, [error]);

  const handleGoHome = () => {
    router.push("/");
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white p-8 rounded-lg shadow-md text-center">
        <div className="text-red-500 text-6xl mb-4">⚠️</div>
        <h2 className="text-2xl font-bold text-gray-900 mb-4">
          Dashboard Error
        </h2>
        <p className="text-gray-600 mb-6">
          We encountered an error while loading your dashboard. This might be a
          temporary issue.
        </p>

        <div className="space-y-3">
          <button
            onClick={reset}
            className="w-full bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors">
            Try Again
          </button>
          <button
            onClick={handleGoHome}
            className="w-full bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600 transition-colors">
            Go Home
          </button>
        </div>

        {process.env.NODE_ENV === "development" && (
          <details className="mt-6 text-left">
            <summary className="cursor-pointer text-sm text-gray-500">
              Error Details
            </summary>
            <pre className="mt-2 text-xs bg-gray-100 p-2 rounded overflow-auto">
              {error.message}
            </pre>
          </details>
        )}
      </div>
    </div>
  );
}

404 Handling

The not-found.tsx file creates a 404 page for route segments.
// 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="max-w-md w-full text-center">
        <div className="text-6xl font-bold text-gray-300 mb-4">404</div>
        <h2 className="text-2xl font-bold text-gray-900 mb-4">
          Page Not Found
        </h2>
        <p className="text-gray-600 mb-6">
          The page you're looking for doesn't exist or has been moved.
        </p>
        <Link
          href="/"
          className="bg-blue-500 text-white px-6 py-3 rounded hover:bg-blue-600 transition-colors">
          Go Home
        </Link>
      </div>
    </div>
  );
}

Nested Not Found Pages

// app/blog/not-found.tsx
import Link from "next/link";

export default function BlogNotFound() {
  return (
    <div className="blog-not-found">
      <h2>Blog Post Not Found</h2>
      <p>The blog post you're looking for doesn't exist.</p>
      <Link href="/blog">View All Posts</Link>
    </div>
  );
}

Loading with Suspense

// app/dashboard/page.tsx
import { Suspense } from "react";
import DashboardStats from "@/components/DashboardStats";
import DashboardLoading from "./loading";

export default function DashboardPage() {
  return (
    <div className="dashboard">
      <h1>Dashboard</h1>
      <Suspense fallback={<DashboardLoading />}>
        <DashboardStats />
      </Suspense>
    </div>
  );
}

Error Boundary with Retry Logic

// app/error.tsx
"use client";

import { useEffect, useState } from "react";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  const [retryCount, setRetryCount] = useState(0);
  const [isRetrying, setIsRetrying] = useState(false);

  const handleRetry = async () => {
    setIsRetrying(true);
    setRetryCount((prev) => prev + 1);

    // Wait a bit before retrying
    await new Promise((resolve) => setTimeout(resolve, 1000));

    reset();
    setIsRetrying(false);
  };

  return (
    <div className="error-boundary">
      <h2>Something went wrong!</h2>
      <p>Retry attempt: {retryCount}</p>
      <button
        onClick={handleRetry}
        disabled={isRetrying}
        className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50">
        {isRetrying ? "Retrying..." : "Try Again"}
      </button>
    </div>
  );
}

Loading with Progress

// app/loading.tsx
"use client";

import { useEffect, useState } from "react";

export default function Loading() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setProgress((prev) => {
        if (prev >= 100) return 100;
        return prev + Math.random() * 10;
      });
    }, 100);

    return () => clearInterval(interval);
  }, []);

  return (
    <div className="flex items-center justify-center min-h-screen">
      <div className="w-64">
        <div className="text-center mb-4">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
        </div>
        <div className="w-full bg-gray-200 rounded-full h-2">
          <div
            className="bg-blue-500 h-2 rounded-full transition-all duration-300"
            style={{ width: `${progress}%` }}></div>
        </div>
        <p className="text-center mt-2 text-sm text-gray-600">
          Loading... {Math.round(progress)}%
        </p>
      </div>
    </div>
  );
}

Best Practices

Loading State Design

1

Match Layout Structure

Create loading states that match your page layout
2

Use Skeleton Screens

Implement skeleton loading for better perceived performance
3

Provide Context

Show what’s loading and why it might take time
4

Handle Edge Cases

Consider slow connections and timeout scenarios

Error Handling

1

User-Friendly Messages

Provide clear, actionable error messages
2

Recovery Options

Offer ways for users to recover from errors
3

Log Errors

Log errors for debugging and monitoring
4

Graceful Degradation

Provide fallback content when possible

Global Error Handler

// app/global-error.tsx
"use client";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <body>
        <div className="min-h-screen flex items-center justify-center">
          <div className="text-center">
            <h2>Something went wrong!</h2>
            <button onClick={reset}>Try again</button>
          </div>
        </div>
      </body>
    </html>
  );
}

Conditional Loading

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="dashboard-loading">
      <div className="loading-header">
        <div className="h-8 bg-gray-200 rounded w-1/3 animate-pulse"></div>
      </div>

      <div className="loading-content">
        <div className="loading-sidebar">
          <div className="space-y-2">
            {Array.from({ length: 5 }).map((_, i) => (
              <div
                key={i}
                className="h-4 bg-gray-200 rounded animate-pulse"></div>
            ))}
          </div>
        </div>

        <div className="loading-main">
          <div className="space-y-4">
            {Array.from({ length: 3 }).map((_, i) => (
              <div
                key={i}
                className="h-32 bg-gray-200 rounded animate-pulse"></div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
Problem: Loading component not appearing Solution: Check that loading.tsx is in the correct directory and the page is actually loading
Problem: Error boundary not catching errors Solution: Ensure error.tsx is in the correct directory and the error is thrown in a client component
Problem: Loading state flashes too quickly Solution: Add minimum loading time or use skeleton screens
Problem: Reset function not working properly Solution: Check that the error is recoverable and the reset function is properly implemented
Key Takeaway: Loading states and error boundaries are essential for creating robust user experiences. Use loading.tsx for better perceived performance and error.tsx for graceful error handling and recovery.