Streaming & Suspense
Stream HTML to the client with Suspense boundaries and loading UI
Streaming Architecture
Next.js streams HTML from the server to the client as it renders. Instead of waiting for the entire page, the browser progressively paints sections as they arrive:
Blocking (no streaming):
[ Request → Fetch Data → Render HTML → Send → Client paints ]
Streaming with Suspense:
[ Request → Render shell → Stream section 1 → Stream section 2 → ... ]
Basic Suspense Boundary
Wrap slow async components in <Suspense> to stream them independently:
import { Suspense } from "react"
import { PostsSkeleton } from "./skeletons"
export default function BlogPage() {
return (
<div>
<h1>Blog</h1>
<Suspense fallback={<PostsSkeleton />}>
<BlogPosts />
</Suspense>
<Suspense fallback={<div>Loading sidebar...</div>}>
<Sidebar />
</Suspense>
</div>
)
}
async function BlogPosts() {
const posts = await fetch("https://api.example.com/posts").then((r) =>
r.json()
)
return <ul>{/* ... */}</ul>
}
Blocking vs Streaming
Streaming from API Routes
// app/api/chat/route.ts
export async function POST(request: Request) {
const { message } = await request.json()
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const words = message.split(" ")
for (const word of words) {
controller.enqueue(
encoder.encode(JSON.stringify({ token: word + " " }) + "\n")
)
await new Promise((r) => setTimeout(r, 50)) // simulate delay
}
controller.close()
},
})
return new Response(stream, {
headers: { "Content-Type": "text/event-stream" },
})
}
Dashboard Pattern with Streaming
import { Suspense } from "react"
import { CardSkeleton } from "@/components/skeletons"
export default function DashboardPage() {
return (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Suspense fallback={<CardSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<UserActivity />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<RecentOrders />
</Suspense>
</div>
)
}
async function RevenueChart() {
const data = await fetch("https://api.example.com/revenue", {
next: { revalidate: 30 },
}).then((r) => r.json())
return <RevenueChartView data={data} />
}
async function UserActivity() {
const data = await fetch("https://api.example.com/activity", {
next: { tags: ["activity"] },
}).then((r) => r.json())
return <ActivityView data={data} />
}
Streaming is fully SEO-compatible. Search engines see the fully rendered HTML because the crawler waits for the stream to complete. Next.js sends a complete HTML response even with streaming — it's just delivered progressively.
Partial Prerendering (PPR)
PPR combines static prerendering with dynamic streaming:
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
ppr: true, // enable per-route
ppr: "incremental", // or incremental opt-in
},
}
// app/page.tsx — static shell with dynamic holes
export default function Page() {
return (
<div>
<Header /> {/* Prerendered at build time */}
<Suspense fallback={<Skeleton />}>
<DynamicContent /> {/* Streamed at request time */}
</Suspense>
</div>
)
}
See It In Action
1import { Suspense } from "react"2 3function CardSkeleton() {4return <div className="h-32 w-full animate-pulse rounded-lg bg-gray-200" />5}6 7async function StatsCards() {8await new Promise((r) => setTimeout(r, 2000))9return (10 11<div className="grid grid-cols-3 gap-4">12<div className="rounded-lg border p-4">Users: 12,340</div>13<div className="rounded-lg border p-4">Revenue: $9,876</div>14<div className="rounded-lg border p-4">Orders: 567</div>15</div>16)17}18 19async function RecentActivity() {20await new Promise((r) => setTimeout(r, 3000))21return (22 23<ul className="space-y-2">24<li>User signed up — 2m ago</li>25<li>Order placed #1024 — 5m ago</li>26<li>Payment processed — 12m ago</li>27</ul>28)29}30 31async function TopProducts() {32await new Promise((r) => setTimeout(r, 1000))33return (34 35<div className="flex gap-2">36<span className="rounded bg-blue-100 px-3 py-1">Widget Pro</span>37<span className="rounded bg-blue-100 px-3 py-1">Speed Kit</span>38<span className="rounded bg-blue-100 px-3 py-1">Cloud Sync</span>39</div>40)41}42 43export default function Dashboard() {44return (45 <div>46 <h1>Dashboard</h1>47 <Suspense fallback={<CardSkeleton />}>48 <StatsCards />49 </Suspense>50 <Suspense fallback={<CardSkeleton />}>51 <RecentActivity />52 </Suspense>53 <Suspense fallback={<CardSkeleton />}>54 <TopProducts />55 </Suspense>56 </div>57)58}Each async data component simulates a different fetch delay. Wrapping each in its own <Suspense> boundary lets them render independently — the page shell appears immediately, TopProducts streams first (1s), then StatsCards (2s), then RecentActivity (3s).