Data fetching isn't always instant or successful. Users need to know when data is loading, what happened if something fails, and what to do when there's no data to show. Handling these states gracefully is essential for great user experience. Next.js provides powerful tools like loading.tsx, error.tsx, and Suspense boundaries to make this easy. Let's learn to handle every data fetching state professionally!
Loading States with loading.tsx
Next.js automatically shows loading UI while your page data is being fetched:
Basic loading.tsx
// loading.tsx automatically wraps page in Suspense
export default function Loading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse">
{/* Header skeleton */}
<div className="h-8 bg-gray-200 rounded w-1/4 mb-8" />
{/* Posts skeleton */}
<div className="space-y-6">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="border rounded-lg p-6">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
))}
</div>
</div>
</div>
);
}
// ✅ Shows while page.tsx is fetching data
// ✅ Automatic Suspense boundary
// ✅ No manual loading state management// While this page is loading, loading.tsx is shown
async function BlogPage() {
// Data fetching
const posts = await fetch('https://api.example.com/posts')
.then(r => r.json());
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Blog Posts</h1>
<div className="space-y-6">
{posts.map(post => (
<article key={post.id} className="border rounded-lg p-6">
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
<p className="text-gray-700">{post.excerpt}</p>
</article>
))}
</div>
</div>
);
}
export default BlogPage;
// Flow:
// 1. User navigates to /blog
// 2. loading.tsx shows immediately
// 3. page.tsx fetches data
// 4. page.tsx replaces loading.tsx when readySkeleton Loading Pattern
export default function DashboardLoading() {
return (
<div className="container mx-auto px-4 py-8">
{/* Match the actual layout structure */}
<div className="animate-pulse">
{/* Header */}
<div className="mb-8">
<div className="h-10 bg-gray-200 rounded w-1/3 mb-2" />
<div className="h-4 bg-gray-200 rounded w-1/4" />
</div>
{/* Stats grid */}
<div className="grid grid-cols-4 gap-6 mb-8">
{[1, 2, 3, 4].map(i => (
<div key={i} className="bg-white rounded-lg shadow p-6">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4" />
<div className="h-8 bg-gray-200 rounded w-3/4" />
</div>
))}
</div>
{/* Chart */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<div className="h-6 bg-gray-200 rounded w-1/4 mb-4" />
<div className="h-64 bg-gray-200 rounded" />
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow p-6">
<div className="h-6 bg-gray-200 rounded w-1/4 mb-4" />
<div className="space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-12 bg-gray-200 rounded" />
))}
</div>
</div>
</div>
</div>
);
}
// ✅ Skeleton matches actual layout
// ✅ Visual continuity
// ✅ Better perceived performance🎨 Skeleton Design Tip
Make your loading skeleton match the structure of the actual content. This creates visual continuity and helps users understand what's loading.
Multiple Loading States with Suspense
Use Suspense boundaries for granular control over loading states:
import { Suspense } from 'react';
// Fast component - loads quickly
async function QuickStats() {
const stats = await fetch('https://api.example.com/quick-stats', {
cache: 'force-cache',
}).then(r => r.json());
return (
<div className="grid grid-cols-4 gap-6">
<StatCard title="Users" value={stats.users} />
<StatCard title="Revenue" value={`$${stats.revenue}`} />
<StatCard title="Orders" value={stats.orders} />
<StatCard title="Growth" value={`${stats.growth}%`} />
</div>
);
}
// Slow component - takes time
async function DetailedAnalytics() {
const analytics = await fetch('https://api.example.com/analytics', {
cache: 'no-store',
}).then(r => r.json());
return (
<div>
<h2 className="text-2xl font-bold mb-4">Analytics</h2>
<ComplexChart data={analytics.chartData} />
<DetailedTable data={analytics.tableData} />
</div>
);
}
// Loading skeletons
function StatsSkeleton() {
return (
<div className="grid grid-cols-4 gap-6 animate-pulse">
{[1, 2, 3, 4].map(i => (
<div key={i} className="bg-white rounded-lg shadow p-6">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4" />
<div className="h-8 bg-gray-200 rounded w-3/4" />
</div>
))}
</div>
);
}
function AnalyticsSkeleton() {
return (
<div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/4 mb-4" />
<div className="h-64 bg-gray-200 rounded mb-4" />
<div className="h-96 bg-gray-200 rounded" />
</div>
);
}
// Page with multiple Suspense boundaries
export default function DashboardPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Dashboard</h1>
{/* Quick stats - own loading state */}
<Suspense fallback={<StatsSkeleton />}>
<QuickStats />
</Suspense>
{/* Analytics - own loading state */}
<div className="mt-12">
<Suspense fallback={<AnalyticsSkeleton />}>
<DetailedAnalytics />
</Suspense>
</div>
</div>
);
}
// ✅ QuickStats shows fast - no waiting
// ✅ Analytics shows loading skeleton independently
// ✅ Each section loads at its own paceBenefits of Multiple Suspense Boundaries
- Independent loading: Fast content shows immediately
- Better UX: Users see something useful right away
- Perceived performance: Page feels faster
- Granular control: Different loading UI for different sections
Error States with error.tsx
Handle errors gracefully with error boundaries:
Basic error.tsx
'use client'; // Error components must be Client Components
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log error to error reporting service
console.error('Blog page error:', error);
}, [error]);
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-md mx-auto text-center">
<div className="text-6xl mb-4">😕</div>
<h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
<p className="text-gray-600 mb-6">
We couldn't load the blog posts. Please try again.
</p>
<button
onClick={reset}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
Try Again
</button>
</div>
</div>
);
}
// ✅ Catches errors in page.tsx
// ✅ Shows user-friendly message
// ✅ Provides retry button
// ✅ Logs error for debuggingDetailed Error Component
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
export default function BlogError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log to error reporting service (e.g., Sentry)
console.error('Blog error:', {
message: error.message,
digest: error.digest,
stack: error.stack,
});
}, [error]);
// Different messages based on error
const isNetworkError = error.message.includes('fetch');
const isNotFound = error.message.includes('404');
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full">
<div className="text-center">
{/* Icon */}
<div className="text-6xl mb-6">
{isNetworkError ? '🌐' : isNotFound ? '🔍' : '😕'}
</div>
{/* Title */}
<h1 className="text-3xl font-bold mb-4">
{isNetworkError
? 'Connection Problem'
: isNotFound
? 'Posts Not Found'
: 'Something Went Wrong'}
</h1>
{/* Description */}
<p className="text-gray-600 mb-8">
{isNetworkError
? 'Please check your internet connection and try again.'
: isNotFound
? 'We couldn't find any blog posts. They may have been moved or deleted.'
: 'An unexpected error occurred. Our team has been notified.'}
</p>
{/* Error ID (for support) */}
{error.digest && (
<p className="text-sm text-gray-500 mb-6">
Error ID: {error.digest}
</p>
)}
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<button
onClick={reset}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold"
>
Try Again
</button>
<Link
href="/"
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition font-semibold"
>
Go Home
</Link>
</div>
{/* Support link */}
<p className="mt-8 text-sm text-gray-500">
Still having trouble?{' '}
<Link href="/support" className="text-blue-600 hover:underline">
Contact Support
</Link>
</p>
</div>
{/* Development: Show error details */}
{process.env.NODE_ENV === 'development' && (
<details className="mt-8 p-4 bg-red-50 border border-red-200 rounded-lg">
<summary className="cursor-pointer font-semibold text-red-800">
Error Details (Dev Only)
</summary>
<pre className="mt-4 text-xs overflow-auto">
{error.message}
{'
'}
{error.stack}
</pre>
</details>
)}
</div>
</div>
);
}
// ✅ Context-specific error messages
// ✅ Error ID for support
// ✅ Multiple action options
// ✅ Development error details
// ✅ Professional appearanceTry-Catch in Components
For more control, handle errors within the component:
async function BlogPage() {
try {
const response = await fetch('https://api.example.com/posts');
if (!response.ok) {
throw new Error(`Failed to fetch posts: ${response.status}`);
}
const posts = await response.json();
// Check for empty results
if (posts.length === 0) {
return (
<div className="container mx-auto px-4 py-8">
<EmptyState
icon="📝"
title="No posts yet"
description="Be the first to create a blog post!"
action={{
label: "Create Post",
href: "/blog/new"
}}
/>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Blog Posts</h1>
<div className="space-y-6">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
);
} catch (error) {
return (
<div className="container mx-auto px-4 py-8">
<ErrorMessage
title="Failed to Load Posts"
message={error instanceof Error ? error.message : 'Unknown error'}
action={{
label: "Try Again",
onClick: () => window.location.reload()
}}
/>
</div>
);
}
}
export default BlogPage;
// ✅ Handles errors within component
// ✅ Custom error UI
// ✅ Handles empty state
// ✅ More control than error.tsxEmpty States
Show helpful empty states when there's no data:
Reusable Empty State Component
interface EmptyStateProps {
icon?: string;
title: string;
description: string;
action?: {
label: string;
href?: string;
onClick?: () => void;
};
}
export function EmptyState({
icon = '📭',
title,
description,
action,
}: EmptyStateProps) {
return (
<div className="text-center py-12">
{/* Icon */}
<div className="text-6xl mb-4">{icon}</div>
{/* Title */}
<h3 className="text-2xl font-bold mb-2">{title}</h3>
{/* Description */}
<p className="text-gray-600 mb-6 max-w-md mx-auto">
{description}
</p>
{/* Action */}
{action && (
action.href ? (
href={action.href}
className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold"
>
{action.label}
</a>
) : (
<button
onClick={action.onClick}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-semibold"
>
{action.label}
</button>
)
)}
</div>
);
}
// Usage examples:
// <EmptyState
// icon="📝"
// title="No posts yet"
// description="Start writing your first blog post today."
// action={{ label: "Create Post", href: "/blog/new" }}
// />
// <EmptyState
// icon="🔍"
// title="No results found"
// description="Try adjusting your search or filters."
// action={{ label: "Clear Filters", onClick: clearFilters }}
// />
// ✅ Reusable across the app
// ✅ Clear message
// ✅ Actionable (tells user what to do)
// ✅ CustomizableContext-Specific Empty States
async function BlogPage({ searchParams }: { searchParams: { q?: string } }) {
const query = searchParams.q || '';
const posts = await fetch(
`https://api.example.com/posts?q=${encodeURIComponent(query)}`
).then(r => r.json());
// Empty state for search results
if (query && posts.length === 0) {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Search Results</h1>
<EmptyState
icon="🔍"
title={`No results for "${query}"`}
description="Try different keywords or browse all posts."
action={{ label: "View All Posts", href: "/blog" }}
/>
</div>
);
}
// Empty state for no posts at all
if (posts.length === 0) {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Blog</h1>
<EmptyState
icon="📝"
title="No posts yet"
description="Be the first to share your thoughts. Create a post to get started."
action={{ label: "Create First Post", href: "/blog/new" }}
/>
</div>
);
}
// Normal state - show posts
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">
{query ? `Search: "${query}"` : 'Blog Posts'}
</h1>
<div className="space-y-6">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
);
}
// ✅ Different empty states for different scenarios
// ✅ Context-specific messages
// ✅ Helpful actionsReusable State Components
Loading Skeleton Component
interface SkeletonProps {
className?: string;
width?: string;
height?: string;
}
export function Skeleton({
className = '',
width = '100%',
height = '1rem'
}: SkeletonProps) {
return (
<div
className={`bg-gray-200 rounded animate-pulse ${className}`}
style={{ width, height }}
/>
);
}
// Specific skeleton components
export function PostSkeleton() {
return (
<div className="border rounded-lg p-6">
<Skeleton width="75%" height="1.5rem" className="mb-4" />
<Skeleton width="100%" height="1rem" className="mb-2" />
<Skeleton width="90%" height="1rem" className="mb-2" />
<Skeleton width="60%" height="1rem" />
</div>
);
}
export function PostListSkeleton({ count = 5 }: { count?: number }) {
return (
<div className="space-y-6">
{Array.from({ length: count }).map((_, i) => (
<PostSkeleton key={i} />
))}
</div>
);
}
// Usage:
// <Suspense fallback={<PostListSkeleton count={3} />}>
// <PostList />
// </Suspense>
// ✅ Reusable skeleton utilities
// ✅ Composable for complex layouts
// ✅ Consistent loading experienceError Message Component
interface ErrorMessageProps {
title: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
severity?: 'error' | 'warning' | 'info';
}
export function ErrorMessage({
title,
message,
action,
severity = 'error',
}: ErrorMessageProps) {
const colors = {
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
};
const icons = {
error: '❌',
warning: '⚠️',
info: 'ℹ️',
};
return (
<div className={`border-2 rounded-lg p-6 ${colors[severity]}`}>
<div className="flex items-start gap-4">
<span className="text-3xl">{icons[severity]}</span>
<div className="flex-1">
<h3 className="text-xl font-bold mb-2">{title}</h3>
<p className="mb-4">{message}</p>
{action && (
<button
onClick={action.onClick}
className="px-4 py-2 bg-white border-2 border-current rounded-lg font-semibold hover:bg-opacity-10 transition"
>
{action.label}
</button>
)}
</div>
</div>
</div>
);
}
// Usage:
// <ErrorMessage
// severity="error"
// title="Failed to Load"
// message="Could not fetch blog posts. Please try again."
// action={{ label: "Retry", onClick: () => router.refresh() }}
// />
// ✅ Flexible for different severity levels
// ✅ Consistent error display
// ✅ Reusable across appState Handling Structure
Project structure with loading, error, and empty state handling
Select a file or folder to see details
State Handling Best Practices
1. Always Provide Loading States
// ✅ GOOD: Loading state provided
export default function Page() {
return (
<div>
<Suspense fallback={<LoadingSkeleton />}>
<DataComponent />
</Suspense>
</div>
);
}
// ❌ BAD: No loading state
export default function Page() {
return (
<div>
<DataComponent /> {/* User sees nothing while loading */}
</div>
);
}2. Match Skeleton to Actual Layout
// ✅ GOOD: Skeleton matches layout
function PostListSkeleton() {
return (
<div className="space-y-6">
{[1, 2, 3].map(i => (
<div key={i} className="border rounded-lg p-6 animate-pulse">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
))}
</div>
);
}
// ❌ BAD: Generic spinner (doesn't match layout)
function PostListSkeleton() {
return <div className="spinner" />; // Layout shifts when loaded
}3. Provide Helpful Error Messages
// ✅ GOOD: Specific, actionable error message
return (
<ErrorMessage
title="Connection Failed"
message="Please check your internet connection and try again."
action={{ label: "Retry", onClick: retry }}
/>
);
// ❌ BAD: Generic error message
return <div>Error</div>;
// ❌ BAD: Technical jargon
return <div>Error: ECONNREFUSED at port 3000</div>;4. Handle Empty States Gracefully
// ✅ GOOD: Helpful empty state with action
if (posts.length === 0) {
return (
<EmptyState
title="No posts yet"
description="Create your first post to get started."
action={{ label: "Create Post", href: "/blog/new" }}
/>
);
}
// ❌ BAD: Just showing nothing
if (posts.length === 0) {
return <div>No posts</div>;
}
// ❌ BAD: Treating empty as error
if (posts.length === 0) {
throw new Error('No posts found');
}5. Avoid Loading State Flashing
// ✅ GOOD: Use cache to avoid flashing
async function getData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }, // Cache for 60 seconds
});
return res.json();
}
// When cached, loads instantly - no loading state shown
// When not cached, shows loading state
// ❌ BAD: No cache, always shows loading
async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'no-store', // Always fetches, always shows loading
});
return res.json();
}
// Tip: Cache aggressively to reduce loading state frequency6. Progressive Enhancement
// ✅ GOOD: Show critical content first, stream in rest
export default function Page() {
return (
<div>
{/* Critical content - no Suspense, loads immediately */}
<Header />
<Hero />
{/* Secondary content - can load progressively */}
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ArticlesSkeleton />}>
<RecentArticles />
</Suspense>
</div>
);
}
// ✅ User sees something immediately
// ✅ Secondary content streams in
// ✅ Better perceived performanceKey Takeaways
- loading.tsx - automatic loading UI for pages
- error.tsx - catches errors with user-friendly UI
- Suspense boundaries - granular loading control
- Empty states - helpful messages when no data
- Skeleton loaders - match actual layout structure
- Error messages - specific, actionable, helpful
- Progressive enhancement - show critical content first
- Reusable components - consistent state handling
🎉 Congratulations!
You've completed the Data Fetching section! You've mastered:
- ✅ Fetching data in Server Components
- ✅ Parallel and sequential data fetching
- ✅ Advanced data fetching patterns
- ✅ Caching and revalidation strategies
- ✅ On-demand revalidation with cache tags
- ✅ Handling loading, error, and empty states
You now have complete mastery of data fetching in Next.js! You can build applications that fetch data efficiently, cache intelligently, revalidate precisely, and handle every state gracefully. These skills are essential for creating production-ready Next.js applications.
🚀 Ready for More?
With routing, layouts, components, and data fetching mastered, you're ready to build complete Next.js applications. Continue learning about forms, authentication, deployment, and other advanced topics to become a Next.js expert!