Next.js Loading UI and Suspense — Streaming and Skeleton Screens
Advertisement
Introduction
Next.js uses React Suspense under the hood to stream HTML from the server progressively. A loading.tsx file at any level in your app directory creates an automatic Suspense boundary — the shell renders instantly, and content streams in as data resolves. This is one of the most impactful performance patterns in Next.js 14 and 15, and it requires almost no code to implement.
Why This Matters
Without streaming, users see a blank page until the server finishes fetching all data, rendering the full component tree, and sending the complete HTML. On pages that fetch from slow APIs or databases, this can easily take 1–3 seconds.
With streaming, the browser receives HTML in chunks. The page shell (header, nav, layout) renders in under 100ms. Slow data sections stream in as they resolve. Users see meaningful content immediately, dramatically improving Time to First Contentful Paint (FCP) and perceived performance — even if Time to Interactive (TTI) stays the same.
loading.tsx — Instant Loading State
Create a loading.tsx file alongside any page.tsx. Next.js wraps the page in a Suspense boundary and shows the loading UI instantly while the page fetches data:
// app/blog/loading.tsx
export default function BlogLoading() {
return (
<div className="max-w-4xl mx-auto py-12">
<div className="space-y-8">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-48 bg-gray-200 rounded-xl mb-4" />
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
</div>
))}
</div>
</div>
)
}The user sees this skeleton instantly while app/blog/page.tsx fetches its data.
Granular Suspense with Manual Boundaries
For more control, wrap specific slow components in <Suspense> rather than using loading.tsx:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from './components/UserStats'
import { RecentActivity } from './components/RecentActivity'
import { TopProducts } from './components/TopProducts'
export default function DashboardPage() {
return (
<div className="grid grid-cols-12 gap-6">
{/* Fast component — no suspense needed */}
<header className="col-span-12">
<h1>Dashboard</h1>
</header>
{/* Slow — wrapping individually */}
<div className="col-span-4">
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
</div>
<div className="col-span-8">
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
<div className="col-span-12">
<Suspense fallback={<ProductsSkeleton />}>
<TopProducts />
</Suspense>
</div>
</div>
)
}Each section streams independently — UserStats resolving does not wait for TopProducts.
Skeleton Components
Build reusable skeleton components that match your actual UI:
// components/skeletons/CardSkeleton.tsx
export function CardSkeleton() {
return (
<div className="animate-pulse rounded-xl border border-gray-100 p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-10 h-10 rounded-full bg-gray-200" />
<div className="flex-1">
<div className="h-4 bg-gray-200 rounded w-1/3 mb-1" />
<div className="h-3 bg-gray-200 rounded w-1/4" />
</div>
</div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
<div className="h-4 bg-gray-200 rounded w-4/6" />
</div>
</div>
)
}
export function CardSkeletonGroup({ count = 3 }: { count?: number }) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
)
}Streaming Server Component
A slow Server Component that streams its content:
// components/dashboard/RecentOrders.tsx
import { db } from '@/lib/db'
export async function RecentOrders() {
// Intentionally slow — this is where streaming helps
const orders = await db.order.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
include: { user: true, items: true },
})
return (
<section>
<h2 className="text-xl font-semibold mb-4">Recent Orders</h2>
<ul className="divide-y">
{orders.map((order) => (
<li key={order.id} className="py-3">
<p className="font-medium">{order.user.name}</p>
<p className="text-sm text-gray-500">{order.items.length} items — ${order.total}</p>
</li>
))}
</ul>
</section>
)
}// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RecentOrders } from '@/components/dashboard/RecentOrders'
import { OrdersSkeleton } from '@/components/skeletons/OrdersSkeleton'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Streams in when RecentOrders finishes fetching */}
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
)
}Loading for Nested Routes
loading.tsx works at any directory level and applies to all child routes:
app/
├── dashboard/
│ ├── loading.tsx ← applies to /dashboard and all sub-routes
│ ├── page.tsx
│ └── analytics/
│ ├── loading.tsx ← overrides parent loading for /dashboard/analytics
│ └── page.tsxMore specific loading.tsx files override parent ones.
useTransition for Client-Side Loading
For Client Components that need pending state during navigation or actions:
'use client'
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
export function NavigationButton({ href }: { href: string }) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
function navigate() {
startTransition(() => {
router.push(href)
})
}
return (
<button onClick={navigate} disabled={isPending} className="relative">
{isPending && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full" />
</span>
)}
<span className={isPending ? 'opacity-0' : ''}>Go to page</span>
</button>
)
}Common Mistakes
- Creating
loading.tsxat the root level (app/loading.tsx) — it applies to the entire app, including navigation - Using
animate-pulsewithout actual skeleton shapes — a blank pulsing div is less useful than a shaped skeleton - Not colocating
loading.tsxwith the relevantpage.tsx— affects too broad a scope - Wrapping an entire page in a single
<Suspense>instead of individual slow sections - Using client-side
useEffectto fetch data when a streaming Server Component would be simpler
Best Practices
- Place
loading.tsxat the same level as thepage.tsxit covers — do not use a single global one - Build skeleton screens that visually match the loaded content — reduces perceived loading time
- Wrap independently-slow sections in separate
<Suspense>boundaries for maximum streaming benefit - Fetch data in multiple sibling Server Components so they stream in parallel
- Use
useTransitionfor client-side navigation loading states when the built-in loading.tsx is insufficient
Key Takeaways
loading.tsxcreates an automatic Suspense boundary — shown immediately while the page loads data- React Suspense in Next.js enables HTTP streaming — the browser receives HTML in chunks, not all at once
- Multiple
<Suspense>boundaries on one page let different sections stream in independently - Skeleton screens should visually match the loaded content — shape, size, and position should be accurate
loading.tsxis scoped to its directory and applies to all child routes without their ownloading.tsx- Streaming dramatically improves FCP (First Contentful Paint) even when TTI stays the same
useTransitionprovidesisPendingstate for client-side navigation loading indicators- Server Components inside
<Suspense>render concurrently — they do not block each other
Advertisement