Next.js 06 ▲ Fetching Data — Async server components & streaming with Suspense
Next.js App Router treats data fetching as a first-class concern of the server. A Server Component can be an async function that awaits a database query or an API call directly in its body, with no useEffect, no loading state variable, and no client-side request waterfall. The result of that await is rendered on the server and sent to the browser as HTML. But a single slow await in a parent component blocks everything below it, and the user sees nothing until the slowest dependency resolves. Streaming with <Suspense> solves this by letting the fast parts of the page arrive first while the slow parts resolve in parallel and stream in as they complete.
This chapter covers the patterns that make server-side data fetching usable in production: async Server Components that fetch directly, <Suspense> boundaries that enable progressive rendering, the use API that lets Client Components consume promises streamed from the server, and the distinction between sequential and parallel fetching. It explains why an async component without a <Suspense> boundary blocks the entire route, and how to structure the component tree so that slow data does not hold the rest of the page hostage.
Key point: An async Server Component without a <Suspense> boundary blocks the entire route from streaming. Wrap the slow component in <Suspense fallback={...}> to let the rest of the page render immediately.
Why streaming with Suspense exists
The blocking waterfall problem. Before streaming, a Server Component that awaited data caused the entire route to wait. If a page rendered a header, a product list, and a reviews section, and the reviews fetch took three seconds, the user saw a blank screen for three seconds even though the header and product list were ready in milliseconds. The browser received no HTML until the server finished rendering the slowest component. <Suspense> changes this by allowing the server to send the fast content immediately and stream the slow content later .
The granularity problem. loading.js provides a route-level loading state, but it applies to the entire page. If only one widget on a dashboard is slow, a full-page skeleton is the wrong fallback. <Suspense> allows the fallback to be scoped to exactly the component that is slow, so the rest of the page remains interactive and visible . The Next.js documentation recommends using <Suspense> close to the dynamic data access rather than relying on a high-level loading.js .
The promise-passing problem. Sometimes the data should be fetched on the server but rendered by a Client Component. The use API allows a Server Component to start a fetch, pass the unresolved promise to a Client Component, and let React suspend the client component until the promise resolves. This is how streaming works across the server-client boundary: the server does not wait for the data, and the client does not need to make the request .
a. Async Server Components
In the App Router, every component is a Server Component by default. Marking a component async allows it to use await directly in its body. The fetch runs on the server, and the rendered output is sent to the browser.
// app/products/page.tsx
async function ProductList() {
const products = await db.query("SELECT * FROM products");
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
export default function ProductsPage() {
return (
<main>
<h1>Products</h1>
<ProductList />
</main>
);
}
The ProductList component is async, but the ProductsPage component that renders it is not. This distinction matters: ProductsPage returns its JSX immediately, and ProductList is the component that awaits. Without a <Suspense> boundary around ProductList, the entire page still waits for the query to resolve before sending any HTML .
A Server Component can also read from the filesystem, access environment variables, or call an internal service without exposing credentials to the client. The code in an async Server Component never ships to the browser.
b. Suspense and the fallback
<Suspense> wraps a component that may suspend, and provides a fallback UI that is shown while the wrapped component is waiting. In the App Router, the fallback is sent to the browser immediately, and the wrapped component’s HTML is streamed in when its data resolves.
import { Suspense } from "react";
export default function ProductsPage() {
return (
<main>
<h1>Products</h1>
<Suspense fallback={<p>Loading products...</p>}>
<ProductList />
</Suspense>
</main>
);
}
The <h1> is sent to the client immediately. The fallback is sent immediately. When ProductList finishes its query, the resolved HTML replaces the fallback in the stream . The Next.js documentation emphasizes that the fallback should be meaningful—a skeleton that matches the loaded layout, not a generic spinner—so that the page does not shift when the content arrives .
A route can also use loading.js, which automatically wraps the page segment in a <Suspense> boundary. The difference is scope: loading.js covers the entire segment, while an explicit <Suspense> covers only the components inside it. The documentation recommends explicit <Suspense> boundaries for granular control, and loading.js for pages where nothing should render without data .
c. Passing promises to Client Components
A Server Component can start a fetch without awaiting it, and pass the unresolved promise to a Client Component. The Client Component uses React’s use API to read the promise’s value. The <Suspense> boundary goes around the Client Component that calls use, not around the Server Component that created the promise.
// app/dashboard/page.tsx (Server Component)
import { Suspense } from "react";
import { StatsChart } from "./stats-chart";
async function getStats() {
const res = await fetch("https://api.example.com/stats");
return res.json();
}
export default function DashboardPage() {
const statsPromise = getStats(); // start the fetch, do not await
return (
<Suspense fallback={<p>Loading chart...</p>}>
<StatsChart dataPromise={statsPromise} />
</Suspense>
);
}
// app/dashboard/stats-chart.tsx (Client Component)
"use client";
import { use } from "react";
export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) {
const stats = use(dataPromise);
return <div>{/* render chart with stats */}</div>;
}
The fallback is sent with the static shell. When the promise resolves, React streams the completed HTML into the page . This pattern is useful when the rendering logic must run on the client—for example, an interactive chart—but the data should be fetched on the server to avoid a client-side request and to keep the API credentials server-side.
d. Sequential versus parallel fetching
Sequential fetching happens when one request depends on the result of another. The documentation uses the example of a <Playlists> component that needs an artistID from a getArtist() call. The page must await getArtist() before it can render <Playlists>, and <Playlists> must await its own fetch before rendering the list .
async function Playlists({ artistID }: { artistID: string }) {
const playlists = await getArtistPlaylists(artistID);
return (
<ul>
{playlists.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
In this case, <Suspense> allows the playlists to stream in after the artist data loads, but the page still waits for the artist data before displaying anything. The documentation notes that the first request blocks everything else, so the data source should be able to resolve it quickly, or the result should be cached if the data changes infrequently .
Parallel fetching is the opposite: multiple independent requests start at the same time. This is the default behavior when a component has multiple await statements that do not depend on each other, but the waits happen sequentially unless the promises are started before awaiting. The pattern is to initiate all the fetches, then await them together or render them in separate <Suspense> boundaries.
export default function Page() {
return (
<>
<Suspense fallback={<ProductsSkeleton />}>
<ProductList /> {/* fetches independently */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews /> {/* fetches independently, in parallel */}
</Suspense>
</>
);
}
Each async component inside its own <Suspense> boundary starts its fetch when React renders it, and the two fetches proceed in parallel. The fallbacks for both are sent immediately, and each resolved component streams in as soon as its data is ready .
Complete Example Session
// ============================================
// PART 1: AN ASYNC SERVER COMPONENT
// ============================================
// app/products/page.tsx
async function ProductList() {
const products = await db.query("SELECT * FROM products");
return <ul>{products.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}
// The component is async.
// The await runs on the server.
// The result is rendered HTML, not a promise.
// ============================================
// PART 2: THE BLOCKING PROBLEM
// ============================================
export default function ProductsPage() {
return (
<main>
<h1>Products</h1>
<ProductList />
</main>
);
}
// Without Suspense, the entire page waits for ProductList.
// The h1 is not sent until the query resolves.
// The user sees a blank screen.
// ============================================
// PART 3: ADDING SUSPENSE
// ============================================
import { Suspense } from "react";
export default function ProductsPage() {
return (
<main>
<h1>Products</h1>
<Suspense fallback={<p>Loading products...</p>}>
<ProductList />
</Suspense>
</main>
);
}
// The h1 and the fallback are sent immediately.
// ProductList streams in when the query resolves.
// ============================================
// PART 4: PASSING A PROMISE TO A CLIENT COMPONENT
// ============================================
// app/dashboard/page.tsx (Server Component)
import { Suspense } from "react";
import { StatsChart } from "./stats-chart";
async function getStats() {
const res = await fetch("https://api.example.com/stats");
return res.json();
}
export default function DashboardPage() {
const statsPromise = getStats(); // do not await
return (
<Suspense fallback={<p>Loading chart...</p>}>
<StatsChart dataPromise={statsPromise} />
</Suspense>
);
}
// ============================================
// PART 5: READING THE PROMISE WITH use
// ============================================
// app/dashboard/stats-chart.tsx
"use client";
import { use } from "react";
export function StatsChart({ dataPromise }: { dataPromise: Promise<Stats> }) {
const stats = use(dataPromise);
return <div>{/* render chart with stats */}</div>;
}
// use suspends the Client Component until the promise resolves.
// The Suspense boundary in the parent shows the fallback.
// ============================================
// PART 6: SEQUENTIAL FETCHING
// ============================================
async function Page({ params }: { params: Promise<{ username: string }> }) {
const { username } = await params;
const artist = await getArtist(username); // blocks everything
return (
<div>
<h1>{artist.name}</h1>
<Suspense fallback={<p>Loading playlists...</p>}>
<Playlists artistID={artist.id} />
</Suspense>
</div>
);
}
// The page waits for getArtist before rendering the h1.
// The playlists stream in after the artist data.
// ============================================
// PART 7: PARALLEL FETCHING WITH SEPARATE BOUNDARIES
// ============================================
export default function Page() {
return (
<>
<Suspense fallback={<ProductsSkeleton />}>
<ProductList />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</>
);
}
// Both fetches start when React renders the components.
// They run in parallel.
// Each fallback is sent immediately.
// Each component streams in when ready.
// ============================================
// PART 8: MEANINGFUL FALLBACKS
// ============================================
// Bad: generic spinner
<Suspense fallback={<Spinner />}>
// Better: skeleton that matches the layout
<Suspense fallback={<ProductCardSkeleton />}>
// The fallback should preserve the layout
// so the page does not shift when content arrives.
// ============================================
// PART 9: ERROR HANDLING MID-STREAM
// ============================================
// If a component throws after streaming starts,
// the nearest error.js boundary catches it.
// The rest of the page remains intact.
// The HTTP status is already 200 and cannot change.
// Wrap risky components in error boundaries,
// or handle errors inside the component.
// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================
import { Suspense } from "react";
export default function Page() {
return (
<main>
<Header /> {/* static, sent immediately */}
<Suspense fallback={<ProductsSkeleton />}>
<ProductList /> {/* streams in */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews /> {/* streams in independently */}
</Suspense>
</main>
);
}
// Static shell first.
// Slow components stream in parallel.
// The user sees content as it becomes available.
The ten parts covered the async Server Component, the blocking problem, adding <Suspense>, passing a promise to a Client Component, reading it with use, sequential fetching, parallel fetching, meaningful fallbacks, mid-stream error handling, and the complete pattern.
Quick Reference
Data fetching patterns
| Pattern | Where the fetch runs | Where use is called | Suspense location |
|---|---|---|---|
| Async Server Component | Server | Not applicable | Around the async component |
| Promise to Client Component | Server | Client Component | Around the Client Component |
| Sequential | Server, await blocks | Not applicable | Around the dependent component |
| Parallel | Server, separate components | Not applicable | Separate boundaries per component |
Suspense versus loading.js
| Feature | loading.js | <Suspense> |
|---|---|---|
| Scope | Entire route segment | Any component |
| Setup | File convention | Explicit wrapper |
| Granularity | Route-level | Component-level |
| Recommended for | Pages with no partial render | Most pages with mixed static/dynamic content |
Key APIs
| API | Purpose |
|---|---|
async component | Await data directly in a Server Component |
<Suspense fallback> | Show fallback while a child suspends |
use(promise) | Read a promise in a Client Component |
loading.js | Route-level automatic Suspense boundary |
Best Practices
✅ Do This:
// Wrap slow async components in Suspense
<Suspense fallback={<Skeleton />}> // ✅
<SlowComponent />
</Suspense>
// Use meaningful fallbacks that match the loaded layout
<Suspense fallback={<ProductCardSkeleton />}> // ✅
// Start fetches without awaiting, pass the promise to the client
const statsPromise = getStats(); // ✅
// Use separate Suspense boundaries for independent data
<Suspense fallback={<A />}><ComponentA /></Suspense> // ✅
<Suspense fallback={<B />}><ComponentB /></Suspense>
❌ Don’t Do This:
// Leave an async component without Suspense
async function Slow() { await fetch(...); } // ❌
<Slow /> // blocks the entire route
// Use a generic spinner for everything
<Suspense fallback={<Spinner />}> // ❌ (layout shift)
// Await a promise that should stream
const data = await getSlowData(); // ❌ (blocks)
// Wrap the entire page in one Suspense boundary
<Suspense fallback={<FullPageSkeleton />}> // ❌ (no granularity)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Entire route blocks on one slow fetch | Async component has no <Suspense> ancestor | Wrap the component in <Suspense fallback> |
| Fallback causes layout shift | Fallback does not match loaded layout | Use a skeleton with the same dimensions |
| Client Component cannot read promise | Missing use call or missing "use client" | Add "use client" and call use(promise) |
| Sequential fetches run in series unintentionally | Awaiting one fetch before starting the next | Start both fetches first, or use separate Suspense boundaries |
| Error after streaming cannot set 4xx status | HTTP 200 already sent with first chunk | Handle errors inside the component or use error.js |
Real-World Examples
1. Basic async Server Component
async function UserList() {
const users = await db.user.findMany();
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
2. Suspense wrapper
<Suspense fallback={<UserListSkeleton />}>
<UserList />
</Suspense>
3. Promise passed to Client Component
const statsPromise = getStats();
<Suspense fallback={<ChartSkeleton />}>
<StatsChart dataPromise={statsPromise} />
</Suspense>
4. Reading the promise
"use client";
import { use } from "react";
const stats = use(dataPromise);
5. Parallel boundaries
<Suspense fallback={<ProductsSkeleton />}><Products /></Suspense>
<Suspense fallback={<ReviewsSkeleton />}><Reviews /></Suspense>
6. Sequential dependency
const artist = await getArtist(username);
<Suspense fallback={<PlaylistsSkeleton />}>
<Playlists artistID={artist.id} />
</Suspense>
7. Route-level loading
// app/products/loading.tsx
export default function Loading() {
return <ProductGridSkeleton />;
}
8. Start fetch before await
const promise = fetchData();
const other = await doOtherWork();
const data = await promise;
9. Error boundary for streaming
// app/products/error.tsx
"use client";
export default function Error({ error, reset }) {
return <button onClick={reset}>Try again</button>;
}
10. Meaningful fallback
<Suspense fallback={<div className="h-64 w-full animate-pulse bg-gray-200" />}>
Visual
The blocking problem
┌─────────────────────────────────────────────────────────────┐
│ WITHOUT SUSPENSE │
│ │
│ Request ──► Server renders ──► ProductList awaits DB │
│ │ │
│ ▼ │
│ 3 seconds pass │
│ │ │
│ ▼ │
│ Full HTML sent │
│ │
│ User sees blank screen for 3 seconds. │
└─────────────────────────────────────────────────────────────┘
With Suspense
┌─────────────────────────────────────────────────────────────┐
│ WITH SUSPENSE │
│ │
│ Request ──► Server renders shell + fallback │
│ │ │
│ ▼ │
│ HTML sent immediately │
│ (h1 + "Loading products...") │
│ │ │
│ ▼ │
│ ProductList awaits DB │
│ │ │
│ ▼ │
│ Resolved HTML streamed in │
│ (replaces fallback) │
│ │
│ User sees content immediately, slow part arrives later. │
└─────────────────────────────────────────────────────────────┘
Promise to Client Component
┌─────────────────────────────────────────────────────────────┐
│ SERVER COMPONENT → CLIENT COMPONENT │
│ │
│ Server Component: │
│ const promise = getStats() (start, don't await) │
│ │ │
│ ▼ │
│ <Suspense fallback={<Skeleton />}> │
│ <StatsChart dataPromise={promise} /> │
│ </Suspense> │
│ │
│ Client Component: │
│ const stats = use(dataPromise) (suspends) │
│ │ │
│ ▼ │
│ Promise resolves ──► chart renders │
│ │
│ The server never waits for the promise. │
│ The client suspends until it resolves. │
└─────────────────────────────────────────────────────────────┘
Sequential vs parallel
┌─────────────────────────────────────────────────────────────┐
│ SEQUENTIAL │
│ │
│ getArtist() ──► Playlists fetch ──► render │
│ │ │ │
│ ▼ ▼ │
│ blocks everything streams after artist │
│ │
│ PARALLEL │
│ │
│ ProductList fetch ──┐ │
│ ├──► both stream in independently │
│ Reviews fetch ──────┘ │
│ │
│ Separate Suspense boundaries. │
│ Fetches start together. │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Server Component data fetch | async component with await |
| Streaming boundary | <Suspense fallback={...}> |
| Client Component promise reading | use(promise) |
| Promise passing | Server starts fetch, passes unresolved promise |
| Sequential fetching | One request depends on another |
| Parallel fetching | Independent requests in separate boundaries |
| Route-level loading | loading.js |
| Component-level loading | <Suspense> |
| Error mid-stream | Nearest error.js catches, rest of page intact |
Key takeaways:
- An
asyncServer Component without<Suspense>blocks the entire route. The server does not send any HTML until the slowest component resolves. Wrapping the slow component in<Suspense>allows the fast parts of the page to arrive immediately . <Suspense>provides component-level granularity.loading.jscovers the entire route segment, while an explicit<Suspense>boundary covers only the components inside it. The documentation recommends<Suspense>close to the data access for most pages .- The
useAPI enables streaming across the server-client boundary. A Server Component can start a fetch and pass the unresolved promise to a Client Component. The Client Component suspends onuse(promise)until the data resolves . - Sequential fetching blocks; parallel fetching streams. If one request depends on another, the page waits for the first. Independent requests should be placed in separate
<Suspense>boundaries so they run in parallel and stream in as they complete . - Fallbacks should be meaningful. A skeleton that matches the loaded layout prevents content shift and communicates what is loading. A generic spinner is the minimum, not the target .
- Mid-stream errors cannot change the HTTP status. The first chunk is sent with 200 OK. If a later component throws, the nearest
error.jsboundary catches it and renders error UI in place of the failed component, but the status code remains 200 .
Remember: Streaming with <Suspense> is the mechanism that makes async Server Components practical. Without it, a single slow fetch holds the entire page hostage. With it, the fast parts of the page arrive immediately, the slow parts stream in when ready, and the user sees progress instead of a blank screen. The pattern is consistent: make the component async, wrap it in <Suspense> with a fallback that matches its layout, and use separate boundaries for independent data so the fetches run in parallel. For Client Components that need to render streamed data, start the fetch on the server, pass the promise, and call use on the client.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!