Most Next.js App Router pages I inherit render as a single block. The database query that powers the sidebar takes 800ms, so the whole page takes 800ms to first byte. The user stares at a blank browser tab. The Largest Contentful Paint is over 2 seconds. The PageSpeed score is in the 50s.
Streaming SSR with Suspense fixes this. Fast parts of the page render immediately. Slow parts stream in as they become ready. The user sees the page shell and main content right away, then the sidebar fills in.
The concept is simple. The implementation has a few patterns you need to know.
How streaming works
Without streaming, the server renders the entire page into a single HTML string, then sends it. The browser shows nothing until the full string arrives.
With streaming, the server sends HTML in chunks:
- The page shell (head, header, layout) goes first.
- Components wrapped in Suspense send a placeholder.
- As wrapped components finish rendering, they stream in.
- The browser swaps the placeholder for the real content when it arrives.
The user sees the page shell and main content within 100ms. The slow parts fill in over the next second. Perceived performance is dramatically better.
The basic pattern
Wrap any slow component in Suspense:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { Stats } from '@/components/Stats'
import { RecentOrders } from '@/components/RecentOrders'
import { ActivityFeed } from '@/components/ActivityFeed'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
)
}Each Suspense boundary streams independently. The Stats component renders first (200ms query), fills in. RecentOrders (800ms query) streams next. ActivityFeed (1.5s query) streams last.
Without Suspense, all three components render together and the page shows nothing for 1.5 seconds. With Suspense, the user sees the shell in 50ms and the Stats in 250ms.
Server components fetch their own data
The pattern works best when each component fetches its own data:
// app/components/Stats.tsx
import { db } from '@/lib/db'
export async function Stats() {
const stats = await db.query.stats()
return (
<div>
<Metric label="Revenue" value={stats.revenue} />
<Metric label="Users" value={stats.users} />
</div>
)
}This makes Stats an independent streaming unit. It suspends until its own data is ready, then resolves. No need to thread loading state through props.
Avoid the antipattern:
// Bad — fetches in layout, blocks the whole tree
export default async function Layout({ children }) {
const data = await fetchHeavyData() // blocks everything
return <div>{children}</div>
}Fetch in the leaf component, not the layout. Layout fetches block every page beneath them.
Meaningful fallbacks
Every Suspense boundary needs a fallback. Generic spinners are okay, but skeletons that match the shape of the content are better.
function StatsSkeleton() {
return (
<div className="grid grid-cols-3 gap-4">
{[0, 1, 2].map(i => (
<div key={i} className="h-24 rounded-lg bg-gray-100 animate-pulse" />
))}
</div>
)
}The skeleton should have the same dimensions and layout as the real content. When the data arrives, the swap is smooth, not jarring.
A bad fallback — a tiny centered spinner where a 600px tall list should be — causes layout shift. Layout shift kills your Core Web Vitals more than slow loads do.
Multiple boundaries per page
Do not wrap the whole page in one Suspense. Wrap each section.
// Bad — users see nothing until everything is ready
<Suspense fallback={<PageSkeleton />}>
<Header />
<Stats />
<RecentOrders />
<ActivityFeed />
</Suspense>
// Good — each section streams independently
<Header />
<Suspense fallback={<StatsSkeleton />}><Stats /></Suspense>
<Suspense fallback={<OrdersSkeleton />}><RecentOrders /></Suspense>
<Suspense fallback={<FeedSkeleton />}><ActivityFeed /></Suspense>With multiple boundaries, the user sees the header immediately, then Stats, then RecentOrders. They feel the page is loading. With one boundary, they see nothing until the last component finishes.
Loading files
Next.js provides a convention for page-level Suspense:
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />
}The loading.tsx file wraps the corresponding page.tsx in a Suspense boundary automatically. Use it for page-level loading states. Use explicit Suspense boundaries for sections within a page.
The use hook (React 19)
React 19 adds a use hook that lets you unwrap promises inside components:
import { use } from 'react'
async function getUser(id: string) {
const res = await fetch(`/api/users/${id}`)
return res.json()
}
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return <h1>{user.name}</h1>
}
// Parent
export default async function Page() {
const userPromise = getUser('123')
return (
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}The promise starts in the parent, the child awaits it with use. The child suspends until the promise resolves. This pattern lets you control when promises start and where they suspend.
Error boundaries
If a streamed component throws after suspending, the page should not crash. Wrap Suspense in error boundaries:
// app/dashboard/error.tsx
'use client'
export default function Error({ error, reset }: {
error: Error
reset: () => void
}) {
return (
<div>
<p>Something went wrong: {error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}This is Next.js's error file convention. Each route segment can have an error.tsx that catches errors from that segment and its children.
Common pitfalls
- One giant Suspense around the whole page — defeats the point. Split into sections.
- Fetching in layouts — blocks every page beneath the layout. Fetch in leaf components.
- No fallback or generic spinner — causes layout shift. Use skeletons with real dimensions.
- Forgetting loading.tsx for new routes — users see a blank page during navigation. Always add it.
- Client components with manual loading state — replace with Suspense for cleaner code.
When NOT to use Suspense
- For critical above-the-fold content — render it eagerly. Suspense adds a round trip.
- For tiny components that take 10ms — wrapping them adds complexity for no gain.
- For SEO-critical content — search engines may not wait for streamed content to finish. Render important SEO content synchronously.
Measure the impact
After adding streaming, check:
- Time to First Byte (TTFB) — should drop significantly. The shell returns fast.
- Largest Contentful Paint (LCP) — main content appears sooner.
- Cumulative Layout Shift (CLS) — should stay low with proper skeletons.
- First Input Delay (FID) — interactive elements hydrate faster.
A typical before/after: TTFB from 900ms to 150ms, LCP from 2.1s to 1.1s, PageSpeed from 52 to 88. That is a meaningful user-visible difference.
Streaming is not a feature. It is how Next.js should work by default. Wrap slow components in Suspense, use real skeletons, and watch your Core Web Vitals improve.
Want streaming SSR set up properly?
I build Next.js apps with streaming, Suspense, and Core Web Vitals in the green. Let's talk.
Frequently Asked Questions
What is streaming SSR in Next.js?
Streaming SSR sends HTML to the browser in chunks as it becomes available, rather than waiting for the entire page to render. Parts of the page appear immediately while slower parts stream in. This improves Time to First Byte and Largest Contentful Paint.
How does Suspense work in Next.js App Router?
Wrap a component in Suspense with a fallback. React renders the fallback first, then streams the real content when the wrapped component is ready. Each Suspense boundary is an independent streaming unit.
Should I wrap the whole page in one Suspense boundary?
No. Wrap each section independently so fast parts render immediately and slow parts stream in. One boundary for the whole page means users see nothing until everything is ready, which defeats the purpose.
Does Next.js streaming work with client components?
Yes, but the model is different. Client components can use Suspense for lazy-loaded modules and React 19's use hook. Server components benefit most because each one can fetch its own data and stream independently.