Streaming and Loading UI in Next.js
Streaming lets you send HTML to the client in chunks as they become ready. Instead of waiting for all requests to finish, the user immediately sees part of the page while the rest loads.
The Problem Without Streaming
Without streaming all HTML is generated before sending:
All data loads on the server
Database query for problems, API call for stats, profile fetch. All sequential or parallel, but HTML is sent only when everything is ready.
User waits
White screen until the server assembles the entire page. TTFB can be several seconds.
How Streaming Solves It
With streaming Next.js sends page parts as they become ready:
Instant response
HTML with layout, navigation and skeletons is sent immediately.
Progressive loading
Data loads progressively. Each block replaces its skeleton.
loading.tsx
The simplest way to add loading UI. Next.js automatically wraps page.tsx in React Suspense with the fallback from loading.tsx:
// app/problems/loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-16 bg-neutral-200 animate-pulse rounded-lg"
/>
))}
</div>
)
}
The user sees the skeleton immediately while content appears as data loads.
Suspense for Granular Control
For finer control use Suspense directly:
// app/dashboard/page.tsx
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
<Suspense fallback={<ProgressSkeleton />}>
<RoadmapProgress />
</Suspense>
</div>
)
}
Each Suspense boundary streams independently. UserStats can appear before RecentActivity.
How It Works Under the Hood
// 1. Server sends HTML with fallbacks
<div>
<h1>Dashboard</h1>
<div id="stats"><!-- StatsSkeleton --></div>
<div id="activity"><!-- ActivitySkeleton --></div>
</div>
// 2. When data is ready, server sends a <script>
// that replaces the fallback with real content
<script>
$RC("stats", "<div>Problems solved: 42</div>")
</script>
The browser receives a stream of data over a single HTTP connection.
Pattern: Async Server Component
Suspense works with async server components:
// This component will be streamed
async function UserStats() {
const stats = await db.user.getStats(userId)
// The query might take 2 seconds
// The user sees a skeleton while data loads
return (
<div>
<p>Problems solved: {stats.problemsSolved}</p>
<p>Articles read: {stats.docsRead}</p>
</div>
)
}
loading.tsx vs Suspense
| loading.tsx | Suspense | |
|---|---|---|
| Scope | Entire page (page.tsx) | Specific component |
| Granularity | One fallback per page | Multiple independent |
| Setup | Automatic | Manual |
Practical tip:
Use loading.tsx for simple pages with a single data block. For dashboards and complex pages use Suspense for each block separately so content appears as it becomes ready.
Useful Resources
- nextjs.org/docs — Loading UI and Streaming
- react.dev — Suspense