Streaming & Suspense¶
Progressive loading for better user experience.
The Problem¶
Traditional page rendering waits for all data before showing anything:
┌──────────────────────────────────────┐
│ Request │
├──────────────────────────────────────┤
│ Wait for ALL data... (3 seconds) │
├──────────────────────────────────────┤
│ Render everything at once │
└──────────────────────────────────────┘
With streaming, show content as it becomes available:
┌──────────────────────────────────────┐
│ Request │
├──────────────────────────────────────┤
│ Send shell immediately │
│ Stream content 1 (fast) ────► │
│ Stream content 2 (medium) ────► │
│ Stream content 3 (slow) ────► │
└──────────────────────────────────────┘
React Suspense¶
Basic Suspense¶
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function SlowComponent() {
const data = await fetchSlowData(); // Takes 3 seconds
return <div>{data.content}</div>;
}
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Show immediately */}
<QuickStats />
{/* Stream when ready */}
<Suspense fallback={<LoadingSkeleton />}>
<SlowComponent />
</Suspense>
</div>
);
}
Multiple Suspense Boundaries¶
export default function Dashboard() {
return (
<div className="dashboard">
<h1>Dashboard</h1>
<div className="grid">
{/* Each loads independently */}
<Suspense fallback={<CardSkeleton />}>
<RevenueCard />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<UsersCard />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
</div>
</div>
);
}
Nested Suspense¶
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* Product info streams first */}
<Suspense fallback={<ProductSkeleton />}>
<ProductInfo id={params.id} />
{/* Reviews stream after product */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={params.id} />
</Suspense>
</Suspense>
</div>
);
}
Loading States¶
Loading.tsx (Route-Level)¶
// app/posts/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
<div className="space-y-3">
<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>
);
}
Custom Skeleton Components¶
// components/skeletons.tsx
export function CardSkeleton() {
return (
<div className="card animate-pulse">
<div className="h-6 bg-gray-200 rounded w-1/2 mb-2" />
<div className="h-8 bg-gray-200 rounded w-3/4" />
</div>
);
}
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/4" />
<div className="h-4 bg-gray-200 rounded w-1/3" />
<div className="h-4 bg-gray-200 rounded w-1/4" />
</div>
))}
</div>
);
}
Streaming Data¶
Server Component with Async Data¶
// components/RecentOrders.tsx
async function RecentOrders() {
// This fetch happens on the server
const orders = await fetch('https://api.example.com/orders', {
next: { revalidate: 60 }, // Cache for 60 seconds
}).then(res => res.json());
return (
<table>
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<tr key={order.id}>
<td>{order.id}</td>
<td>{order.customer}</td>
<td>${order.total}</td>
</tr>
))}
</tbody>
</table>
);
}
Parallel Data Fetching¶
async function Dashboard() {
// These run in parallel
const [revenue, users, orders] = await Promise.all([
fetchRevenue(),
fetchUsers(),
fetchOrders(),
]);
return (
<div>
<RevenueCard data={revenue} />
<UsersCard data={users} />
<OrdersTable data={orders} />
</div>
);
}
Sequential with Streaming¶
async function ProductPage({ id }: { id: string }) {
// Fetch product first (needed for reviews query)
const product = await fetchProduct(id);
return (
<div>
<ProductInfo product={product} />
{/* Reviews stream separately, depend on product */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={product.id} />
</Suspense>
</div>
);
}
Error Handling¶
Error Boundaries¶
// app/dashboard/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="error-container">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
Component-Level Error Handling¶
import { ErrorBoundary } from 'react-error-boundary';
export default function Dashboard() {
return (
<div>
<ErrorBoundary fallback={<div>Failed to load revenue</div>}>
<Suspense fallback={<CardSkeleton />}>
<RevenueCard />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<div>Failed to load users</div>}>
<Suspense fallback={<CardSkeleton />}>
<UsersCard />
</Suspense>
</ErrorBoundary>
</div>
);
}
Streaming with useTransition¶
'use client';
import { useState, useTransition } from 'react';
export function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value);
startTransition(async () => {
const data = await searchPosts(value);
setResults(data);
});
}
return (
<div>
<input
value={query}
onChange={handleSearch}
placeholder="Search..."
/>
{isPending ? (
<div className="opacity-50">Searching...</div>
) : (
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
)}
</div>
);
}
useDeferredValue¶
'use client';
import { useState, useDeferredValue, memo } from 'react';
const ExpensiveList = memo(function ExpensiveList({ query }: { query: string }) {
// Expensive filtering/rendering
const items = filterItems(allItems, query);
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
export function Search() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
{/* Input stays responsive, list updates are deferred */}
<ExpensiveList query={deferredQuery} />
</div>
);
}
Partial Prerendering (PPR)¶
Next.js experimental feature for combining static and dynamic content:
// next.config.js
module.exports = {
experimental: {
ppr: true,
},
};
// app/page.tsx
export default function Page() {
return (
<main>
{/* Static shell */}
<Header />
<StaticContent />
{/* Dynamic content streams in */}
<Suspense fallback={<UserSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<PersonalizedFeed />
</Suspense>
</main>
);
}
Best Practices¶
| Practice | Benefit |
|---|---|
| Wrap slow components in Suspense | Progressive loading |
| Use meaningful skeletons | Better perceived performance |
| Fetch in parallel when possible | Faster total load |
| Handle errors at boundaries | Graceful degradation |
| Use useTransition for updates | Responsive UI |
| Place Suspense close to data | Granular loading states |
Anti-Patterns¶
// ❌ Bad: One Suspense for everything
<Suspense fallback={<Loading />}>
<SlowComponent1 />
<SlowComponent2 />
<SlowComponent3 />
</Suspense>
// ✅ Good: Independent Suspense boundaries
<Suspense fallback={<Skeleton1 />}>
<SlowComponent1 />
</Suspense>
<Suspense fallback={<Skeleton2 />}>
<SlowComponent2 />
</Suspense>
<Suspense fallback={<Skeleton3 />}>
<SlowComponent3 />
</Suspense>
// ❌ Bad: Waterfall fetches
const user = await fetchUser();
const posts = await fetchPosts(user.id);
const comments = await fetchComments(posts[0].id);
// ✅ Good: Parallel where possible
const [user, posts] = await Promise.all([
fetchUser(),
fetchPosts(),
]);