Learn how to use React Suspense and the loading.js convention to progressively stream UI from the server to the client in Next.js.
Streaming allows the server to send parts of a page to the client as soon as they’re ready, rather than waiting for the entire page to render. Users see content sooner, even if parts of the page are still loading.
Without streaming, the browser must wait for the server to render the full page before showing anything. With streaming, Next.js sends a static shell immediately and streams in dynamic content as it resolves:
Without streaming: request → [wait for all data] → full HTML → displayWith streaming: request → static shell → display ↓ dynamic chunk 1 → streamed in dynamic chunk 2 → streamed in
Streaming is built on HTTP chunked transfer encoding. Each <Suspense> boundary in your component tree becomes a streaming chunk.
Create a loading.js file in the same folder as your page to show a loading state while the page renders:
export default function Loading() { return <div>Loading...</div>}
On navigation, the user immediately sees the layout and loading state. The new content swaps in once rendering is complete.How it works internally:loading.js is nested inside layout.js and automatically wraps page.js and its children in a <Suspense> boundary.
A layout that accesses uncached or runtime data (such as cookies(), headers(), or uncached fetches) does not fall back to the same route segment’s loading.js. It blocks navigation until the layout finishes rendering. Move data fetching into page.js where loading.js can cover it, or wrap the uncached access in its own <Suspense> boundary.
For more granular control, wrap specific components in <Suspense> boundaries:
import { Suspense } from 'react'import BlogList from '@/components/BlogList'import BlogListSkeleton from '@/components/BlogListSkeleton'export default function BlogPage() { return ( <div> {/* Sent to the client immediately */} <header> <h1>Welcome to the Blog</h1> <p>Read the latest posts below.</p> </header> <main> {/* Streams in when BlogList resolves */} <Suspense fallback={<BlogListSkeleton />}> <BlogList /> </Suspense> </main> </div> )}
Content outside the <Suspense> boundary (<header>) is sent immediately. Content inside streams in when the async work completes.
When Cache Components is enabled, Next.js uses Partial Prerendering (PPR) by default. The static shell (including <Suspense> fallbacks) is prerendered at build time. Dynamic content streams in at request time:
import { Suspense } from 'react'import { cookies } from 'next/headers'import { cacheLife } from 'next/cache'export default function Page() { return ( <> {/* Prerendered at build time */} <h1>My Blog</h1> {/* Cached — included in static shell */} <BlogPosts /> {/* Fallback prerendered; content streams at request time */} <Suspense fallback={<p>Loading preferences...</p>}> <UserPreferences /> </Suspense> </> )}async function BlogPosts() { 'use cache' cacheLife('hours') const res = await fetch('https://api.vercel.app/blog') const posts = await res.json() return <ul>{posts.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul>}async function UserPreferences() { // Reads cookies — dynamic, streams at request time const theme = (await cookies()).get('theme')?.value || 'light' return <p>Theme: {theme}</p>}
The <Suspense> fallback (<p>Loading preferences...</p>) is included in the static HTML shell sent on the first request. The UserPreferences content streams in once it resolves.