Nothing frustrates users more than staring at a blank screen wondering if something went wrong. Loading states tell users "we're working on it" and make your app feel responsive even when data takes time to load. Next.js makes this incredibly easy with loading.tsx files that automatically create React Suspense boundaries. Add a loading file, and Next.js handles the rest—showing instant loading UI while your page loads, then seamlessly swapping in the real content. Let's master this essential UX pattern!
What Is loading.tsx?
loading.tsx is a special file that defines loading UI for a route segment:
- Automatic Suspense: Next.js wraps your page in React Suspense
- Instant feedback: Shows immediately during navigation
- Automatic replacement: Swaps to page content when ready
- Works with streaming: Pages can stream in progressively
Loading States Structure
Each route segment can have its own loading.tsx
Select a file or folder to see details
How It Works
When you create a loading.tsx file, Next.js automatically wraps your page like this:
<Layout>
<Suspense fallback={<YourLoadingUI />}>
<Page />
</Suspense>
</Layout>You just create the loading UI, Next.js handles the Suspense boundary automatically!
Creating Your First Loading State
Step 1: Simple Loading Spinner
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
);
}That's it! Now when navigating to any page in your app, this loading UI shows first.
Step 2: Better Loading UI with Message
export default function Loading() {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mb-4" />
<p className="text-gray-600 text-lg">Loading...</p>
</div>
);
}Step 3: Styled Loading Component
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="text-center">
{/* Animated spinner */}
<div className="relative">
<div className="animate-spin rounded-full h-20 w-20 border-t-4 border-b-4 border-blue-600 mx-auto" />
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className="h-10 w-10 rounded-full bg-blue-100" />
</div>
</div>
{/* Loading text */}
<h2 className="mt-6 text-xl font-semibold text-gray-800">
Loading your content
</h2>
<p className="mt-2 text-gray-600">
This won't take long...
</p>
{/* Animated dots */}
<div className="mt-4 flex justify-center gap-1">
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce delay-100" />
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce delay-200" />
</div>
</div>
</div>
);
}🎨 Make It Match Your Design
Loading states should match your app's design system. Use the same colors, fonts, and style to maintain visual consistency.
Skeleton Screens
Better than spinners: skeleton screens that mimic your actual content layout:
Blog Post Skeleton
export default function BlogPostLoading() {
return (
<div className="container mx-auto px-4 py-8 max-w-3xl">
{/* Title skeleton */}
<div className="h-12 bg-gray-200 rounded-lg w-3/4 mb-4 animate-pulse" />
{/* Meta info skeleton */}
<div className="flex gap-4 mb-8">
<div className="h-4 bg-gray-200 rounded w-32 animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-24 animate-pulse" />
</div>
{/* Featured image skeleton */}
<div className="aspect-video bg-gray-200 rounded-lg mb-8 animate-pulse" />
{/* Content skeletons */}
<div className="space-y-4">
<div className="h-4 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-5/6 animate-pulse" />
<div className="h-4 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-4/6 animate-pulse" />
</div>
{/* More content */}
<div className="mt-8 space-y-4">
<div className="h-4 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-3/4 animate-pulse" />
</div>
</div>
);
}Dashboard Skeleton
export default function DashboardLoading() {
return (
<div className="p-8">
{/* Header skeleton */}
<div className="mb-8">
<div className="h-8 bg-gray-200 rounded w-64 mb-2 animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-96 animate-pulse" />
</div>
{/* Stats grid skeleton */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-lg shadow p-6">
<div className="h-4 bg-gray-200 rounded w-24 mb-3 animate-pulse" />
<div className="h-8 bg-gray-200 rounded w-32 mb-2 animate-pulse" />
<div className="h-3 bg-gray-200 rounded w-20 animate-pulse" />
</div>
))}
</div>
{/* Chart skeleton */}
<div className="bg-white rounded-lg shadow p-6">
<div className="h-6 bg-gray-200 rounded w-48 mb-4 animate-pulse" />
<div className="h-64 bg-gray-200 rounded animate-pulse" />
</div>
</div>
);
}Product Grid Skeleton
export default function ProductsLoading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="h-10 bg-gray-200 rounded w-64 mb-8 animate-pulse" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="bg-white rounded-lg shadow overflow-hidden">
{/* Image skeleton */}
<div className="aspect-square bg-gray-200 animate-pulse" />
{/* Content skeleton */}
<div className="p-4 space-y-3">
<div className="h-4 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-3/4 animate-pulse" />
<div className="h-6 bg-gray-200 rounded w-24 animate-pulse" />
</div>
</div>
))}
</div>
</div>
);
}Why Skeleton Screens?
- Perceived performance: Users feel content is loading faster
- Reduce layout shift: Similar size to real content
- Better UX: Shows what's coming, not just "loading"
- Professional appearance: Looks more polished than spinners
Nested Loading States
Each route segment can have its own loading state, creating granular loading experiences:
app/
layout.tsx ← Layout persists
loading.tsx ← Shows for all routes
dashboard/
layout.tsx ← Dashboard layout persists
loading.tsx ← Shows for dashboard routes
page.tsx → /dashboard
analytics/
loading.tsx ← Shows only for analytics
page.tsx → /dashboard/analyticsHow Nested Loading Works
URL: /dashboard
<RootLayout>
<Suspense fallback={<DashboardLoading />}>
<DashboardLayout>
<DashboardPage />
</DashboardLayout>
</Suspense>
</RootLayout>
Shows: Dashboard loading while page loadsURL: /dashboard/analytics
<RootLayout>
<DashboardLayout> ← Stays mounted
<Suspense fallback={<AnalyticsLoading />}>
<AnalyticsPage />
</Suspense>
</DashboardLayout>
</RootLayout>
Shows: Analytics loading while page loads
Dashboard layout stays visible!⚡ Granular Loading
Nested loading states mean only the changing part shows a loading state. The layout above stays interactive and visible, providing better UX than full-page loading.
Streaming with Suspense
Next.js streams pages progressively. You can show partial content while other parts load:
Manual Suspense Boundaries
import { Suspense } from 'react';
import { RevenueChart } from '@/components/RevenueChart';
import { RecentOrders } from '@/components/RecentOrders';
// Fast components load first
function QuickStats() {
return (
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-white p-6 rounded shadow">
<h3>Total Sales</h3>
<p className="text-3xl">$12,345</p>
</div>
{/* More stats */}
</div>
);
}
// Loading fallback for slow components
function ChartSkeleton() {
return <div className="h-64 bg-gray-200 rounded animate-pulse" />;
}
function OrdersSkeleton() {
return <div className="h-96 bg-gray-200 rounded animate-pulse" />;
}
export default function DashboardPage() {
return (
<div className="p-8">
{/* Shows immediately */}
<QuickStats />
{/* Shows loading, then streams in */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* Also streams independently */}
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}Result: QuickStats shows instantly, then chart and orders stream in independently as they're ready!
Streaming Benefits
- Fast content shows immediately
- Slow content doesn't block fast content
- Better perceived performance
- Users can interact with loaded parts
Common Loading Patterns
1. Page-Level Loading (Simplest)
export default function Loading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-3/4" />
<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>
);
}2. Component-Level Loading (Granular)
import { Suspense } from 'react';
export default function Page() {
return (
<div>
{/* Each component loads independently */}
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<Table />
</Suspense>
</div>
);
}3. Progressive Loading (Best UX)
import { Suspense } from 'react';
export default function ProductsPage() {
return (
<div>
{/* Header shows immediately */}
<header>
<h1>Products</h1>
<Filters /> {/* Client component - instant */}
</header>
{/* Products stream in */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
</div>
);
}4. Layout with Loading Content
import { Suspense } from 'react';
export default function BlogPostPage() {
return (
<article>
{/* Post content loads */}
<Suspense fallback={<PostSkeleton />}>
<PostContent />
</Suspense>
{/* Comments load separately */}
<div className="mt-12">
<h2>Comments</h2>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</div>
</article>
);
}Loading State Best Practices
1. Match the Layout
Skeleton screens should match your actual content structure:
// ✅ Good: Matches actual content
export default function Loading() {
return (
<div className="grid grid-cols-3 gap-4">
{[1,2,3].map(i => (
<div key={i} className="card">
<div className="h-48 bg-gray-200 animate-pulse" />
<div className="p-4 space-y-2">
<div className="h-4 bg-gray-200" />
<div className="h-4 bg-gray-200 w-3/4" />
</div>
</div>
))}
</div>
);
}
// ❌ Bad: Doesn't match
export default function Loading() {
return <div>Loading...</div>;
}2. Use Semantic Delays
// Show loading after a brief delay to avoid flashing
// (Only needed for very fast loads)
'use client';
import { useEffect, useState } from 'react';
export default function Loading() {
const [show, setShow] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setShow(true), 200);
return () => clearTimeout(timer);
}, []);
if (!show) return null;
return <div>Loading...</div>;
}3. Provide Context
// ✅ Good: Clear what's loading
export default function Loading() {
return (
<div className="text-center py-12">
<Spinner />
<p>Loading your dashboard...</p>
</div>
);
}
// ✅ Better: Skeleton shows structure
export default function Loading() {
return <DashboardSkeleton />;
}
// ❌ Bad: Generic and unclear
export default function Loading() {
return <div>Loading</div>;
}4. Keep It Simple
Loading states should be lightweight—they're temporary:
// ✅ Good: Simple, fast to render
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded" />
</div>
);
}
// ❌ Bad: Too complex
export default function Loading() {
// Don't fetch data in loading states
// Don't use heavy animations
// Don't import large dependencies
}5. Accessibility
export default function Loading() {
return (
<div
role="status"
aria-live="polite"
aria-label="Loading content"
>
<div className="spinner" />
<span className="sr-only">Loading...</span>
</div>
);
}Reusable Loading Components
Extract common loading UI into reusable components:
Skeleton Component
export function Skeleton({
className = '',
width = 'w-full',
height = 'h-4',
}: {
className?: string;
width?: string;
height?: string;
}) {
return (
<div
className={`${height} ${width} bg-gray-200 rounded animate-pulse ${className}`}
/>
);
}
// Usage
import { Skeleton } from '@/components/Skeleton';
export default function Loading() {
return (
<div className="space-y-4">
<Skeleton height="h-8" width="w-3/4" />
<Skeleton height="h-4" />
<Skeleton height="h-4" />
<Skeleton height="h-4" width="w-5/6" />
</div>
);
}Card Skeleton
import { Skeleton } from './Skeleton';
export function CardSkeleton() {
return (
<div className="bg-white rounded-lg shadow p-6">
<Skeleton height="h-6" width="w-1/2" className="mb-4" />
<Skeleton height="h-4" className="mb-2" />
<Skeleton height="h-4" className="mb-2" />
<Skeleton height="h-4" width="w-3/4" />
</div>
);
}
// Usage
export default function Loading() {
return (
<div className="grid grid-cols-3 gap-6">
{[1,2,3].map(i => <CardSkeleton key={i} />)}
</div>
);
}Spinner Component
export function Spinner({
size = 'md',
className = '',
}: {
size?: 'sm' | 'md' | 'lg';
className?: string;
}) {
const sizeClasses = {
sm: 'h-4 w-4 border-2',
md: 'h-8 w-8 border-2',
lg: 'h-12 w-12 border-4',
};
return (
<div
className={`${sizeClasses[size]} border-blue-600 border-t-transparent rounded-full animate-spin ${className}`}
role="status"
aria-label="Loading"
>
<span className="sr-only">Loading...</span>
</div>
);
}Complete Practical Example
Blog Post Loading Skeleton
A complete skeleton that matches blog post layout
Output Preview
Key Takeaways
- loading.tsx creates automatic Suspense boundaries - no manual setup
- Shows instantly during navigation - better perceived performance
- Each route segment can have its own loading state - granular control
- Skeleton screens better than spinners - show structure, reduce shift
- Combine with manual Suspense - for component-level streaming
- Match your actual layout - similar structure and dimensions
- Keep loading UI lightweight - it's temporary
- Provide context and accessibility - tell users what's loading
What's Next?
You've mastered loading states—an essential part of great UX! Next, we'll explore error handling with error.tsx files. You'll learn how to create error boundaries, handle errors gracefully, provide recovery options, and build resilient applications that handle failures elegantly.
Error handling is just as important as loading states. Users need to know when something went wrong and what they can do about it. Let's make your app bulletproof!
⚡ Performance + UX
Loading states are about perceived performance, not just actual performance. Even if your app is fast, showing instant feedback makes it feel faster and more responsive. Invest in good loading states!