Here's a scenario we've lived through more than once: a page that hits three different data sources — user profile, recent activity, and some aggregate stats. All three requests run sequentially (oops) or even in parallel, and the user stares at a loading spinner for 2-3 seconds before anything appears. Then everything pops in at once. It looks broken. It feels broken.
The fix isn't always 'make the queries faster' (though yes, do that too). Sometimes the fix is streaming — sending the page to the browser progressively, so fast parts appear immediately while slow parts catch up. Next.js App Router makes this surprisingly easy. The hard part is understanding when to reach for it and how to structure your components so it actually works.
What Streaming Actually Means
Traditional SSR is all-or-nothing. The server fetches everything, renders everything, then sends the full HTML. The browser waits. With streaming, the server sends HTML in chunks as it becomes ready. The browser can start rendering immediately — paint the layout, show static content, hydrate fast components — while slower data is still in flight.
HTTP/1.1 has supported chunked transfer encoding forever. React 18's server rendering supports it. Next.js App Router is built around it. The missing piece until recently was a good programming model for expressing 'this part can render now, that part needs to wait.' That's what Suspense gives you.
Suspense isn't a loading state management tool. It's a way of drawing a boundary that says: 'everything inside this boundary can render independently, on its own schedule.'
The Simplest Streaming Setup
In the App Router, streaming happens automatically when you wrap async components in Suspense. Here's the pattern:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserProfile } from './user-profile'
import { RecentActivity } from './recent-activity'
import { StatsPanel } from './stats-panel'
import { Skeleton } from '@/components/ui/skeleton'
export default function DashboardPage() {
// This renders immediately — no async, no waiting
return (
<div className="dashboard-grid">
<Suspense fallback={<Skeleton className="h-24 w-full" />}>
<UserProfile />
</Suspense>
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
<RecentActivity />
</Suspense>
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<StatsPanel />
</Suspense>
</div>
)
}
// app/dashboard/recent-activity.tsx
async function RecentActivity() {
// This fetch might take 800ms — but it doesn't block the rest of the page
const activity = await fetchRecentActivity()
return (
<ul>
{activity.map(item => (
<li key={item.id}>{item.description}</li>
))}
</ul>
)
}The page HTML starts streaming immediately. The layout renders, the skeletons appear, and each async component streams in as its data resolves. From the user's perspective: the page loads instantly (layout + skeletons), then content pops in as it becomes available. Much better than a blank screen for 2 seconds.
Parallel vs Sequential Fetching — This Matters More Than You Think
One of the most common mistakes we see is accidentally serializing data fetches inside async components. Watch out for this:
// ❌ Sequential — total wait time is sum of all fetches
async function BadStatsPanel() {
const user = await getUser() // 200ms
const stats = await getStats(user.id) // 400ms
const goals = await getGoals(user.id) // 300ms
// Total: ~900ms
return <div>{/* render stats and goals */}</div>
}
// ✅ Parallel — total wait time is the slowest fetch
async function GoodStatsPanel() {
const user = await getUser() // 200ms, needed first
// These don't depend on each other, so run them together
const [stats, goals] = await Promise.all([
getStats(user.id), // 400ms
getGoals(user.id), // 300ms
])
// Total: ~600ms
return <div>{/* render stats and goals */}</div>
}
// ✅✅ Even better — start fetches early, before you even need the user
async function BestStatsPanel() {
const userPromise = getUser()
// While user is fetching, start other work...
const user = await userPromise
const [stats, goals] = await Promise.all([
getStats(user.id),
getGoals(user.id),
])
return <div>{/* render stats and goals */}</div>
}This isn't a streaming-specific lesson, but it matters here because the whole point of Suspense boundaries is giving each component its own timeline. If your component is needlessly slow due to sequential fetches, the streaming benefit is reduced.
Granularity: Where to Put Your Suspense Boundaries
This is where the actual design work happens. Too few Suspense boundaries and you're back to all-or-nothing. Too many and you get a jarring experience where the page shifts around constantly as chunks stream in.
Our rule of thumb: a Suspense boundary makes sense when the content behind it is independently useful and its loading state won't make the rest of the page look broken. Navigation, headers, and layout should always render immediately. Data-heavy panels, feeds, and stats are good Suspense candidates.
// app/project/[id]/page.tsx
import { Suspense } from 'react'
export default function ProjectPage({ params }: { params: { id: string } }) {
return (
<div>
{/* Always renders immediately — pure props, no async */}
<ProjectHeader projectId={params.id} />
<div className="grid grid-cols-3 gap-4">
{/* Fast: usually cached, renders in ~50ms */}
<Suspense fallback={<TeamMembersSkeleton />}>
<TeamMembers projectId={params.id} />
</Suspense>
{/* Medium: needs aggregation, ~300ms */}
<Suspense fallback={<ActivityFeedSkeleton />}>
<ActivityFeed projectId={params.id} />
</Suspense>
{/* Slow: expensive analytics query, ~1200ms */}
{/* Wrap in its own boundary so it doesn't hold up TeamMembers */}
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsWidget projectId={params.id} />
</Suspense>
</div>
</div>
)
}
// ProjectHeader doesn't fetch — it renders immediately
// and sets up the page shell while everything else loads
function ProjectHeader({ projectId }: { projectId: string }) {
return (
<header>
<nav>{/* breadcrumbs, actions — static or from URL */}</nav>
</header>
)
}Notice that ProjectHeader doesn't do any fetching. If you need project data in the header, consider passing it down from the page (where you can use generateMetadata to fetch it server-side early) or accept that the header will be part of a Suspense boundary. Don't let one slow piece block your entire page layout from rendering.
Loading Files: The Route-Level Shortcut
Next.js has a nice shortcut for page-level loading states: the loading.tsx file. Drop one in any route segment and Next.js automatically wraps your page.tsx in a Suspense boundary with that loading component as the fallback.
// app/dashboard/loading.tsx
// This is automatically used as the Suspense fallback for this route segment
export default function DashboardLoading() {
return (
<div className="dashboard-grid">
<div className="animate-pulse">
<div className="h-24 bg-muted rounded-lg mb-4" />
<div className="h-64 bg-muted rounded-lg mb-4" />
<div className="h-48 bg-muted rounded-lg" />
</div>
</div>
)
}
// app/dashboard/page.tsx
// Next.js wraps this in Suspense using loading.tsx as the fallback
export default async function DashboardPage() {
const data = await fetchDashboardData() // loading.tsx shows while this runs
return <Dashboard data={data} />
}loading.tsx is great for route transitions — navigating to /dashboard shows the skeleton immediately while the page fetches. But it's coarser than manual Suspense boundaries. For fine-grained progressive loading within a page, you still want to manage your own boundaries. Use loading.tsx for the route shell, manual Suspense for the internals.
A Pattern That Trips People Up: Data Fetching in Layouts
Layouts in Next.js are persistent across navigation. This means a fetch in your root layout runs on every page load — but it doesn't participate in streaming the same way page components do. If your layout awaits a slow database query, you're blocking the entire page from rendering.
// ❌ Don't do this — blocks the entire layout from rendering
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const user = await getUser() // 200ms
const subscription = await getSubscription(user.id) // 400ms
// Layout doesn't render for 600ms, nothing shows to the user
return (
<div>
<Sidebar user={user} subscription={subscription} />
{children}
</div>
)
}
// ✅ Better — use Suspense in the layout for slow parts
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
// Fast: maybe 50ms with a good cache
const user = await getUser()
return (
<div>
{/* Subscription status is slow, wrap it */}
<Suspense fallback={<SidebarSkeleton user={user} />}>
<Sidebar user={user} />
</Suspense>
{children}
</div>
)
}
// Sidebar fetches its own subscription data
async function Sidebar({ user }: { user: User }) {
const subscription = await getSubscription(user.id)
return <SidebarContent user={user} subscription={subscription} />
}The general principle: layouts should be fast. If you need slow data in a layout, push that fetch down into the component that actually needs it and wrap that component in Suspense.
Error Boundaries: Streaming Can Fail Halfway
Here's a fun edge case we hit: when a component inside a Suspense boundary throws after the HTML has already started streaming, you can't send a 500 status code — those headers were sent long ago. React and Next.js handle this by streaming an error boundary replacement instead.
// error.tsx next to your page or wrapping component
'use client'
export default function ComponentError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="error-panel">
<p>This section failed to load.</p>
<button onClick={reset}>Try again</button>
</div>
)
}
// In your component tree, pair Suspense with error boundaries
// so failures degrade gracefully instead of blowing up the page
import { ErrorBoundary } from 'react-error-boundary'
function SafeAnalyticsWidget({ projectId }: { projectId: string }) {
return (
<ErrorBoundary fallback={<div>Analytics unavailable</div>}>
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsWidget projectId={projectId} />
</Suspense>
</ErrorBoundary>
)
}In production, third-party APIs go down, databases have slow moments, and networks are unreliable. If you're streaming five components and one of them errors, you want the other four to still render correctly. ErrorBoundary + Suspense is the combination that makes this work.
Always pair Suspense with an error boundary in production code. Suspense handles 'not ready yet', ErrorBoundary handles 'failed completely'. You need both.
When Streaming Isn't the Right Answer
Streaming is great, but it's not always the right tool. A few cases where you might not want it:
- SEO-critical content: Googlebot handles streaming well, but if you're worried about crawlers seeing skeleton placeholders instead of content, ensure critical content renders above the fold without Suspense.
- Simple pages with one fast query: Adding Suspense boundaries adds complexity. If your page does one 50ms database query, just await it and render. Don't over-engineer.
- Content that looks broken when partially loaded: If sections A and B are visually linked (like a sidebar and main content that reference each other), loading them at different times looks jarring. Sometimes it's better to load them together.
- When you need the complete HTML for social preview/OG images: Dynamic OG image generation usually wants the full page data synchronously.
The decision tree is roughly: will the user benefit from seeing partial content faster? Is the loading skeleton meaningful and not disorienting? Then use Suspense. If the answer to either is no, don't add the complexity.
Putting It Together: A Real Dashboard Pattern
Here's what a production dashboard structure looks like when you apply all of this. This is basically the pattern we use across the templates on peal.dev — layouts that render instantly, fast data above the fold, slow analytics deferred:
// app/(dashboard)/overview/page.tsx
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
export default function OverviewPage() {
// No async here — this function returns immediately
// All async work happens inside the suspended components
return (
<>
{/* Fast: renders from URL params + static data */}
<PageHeader title="Overview" />
{/* Above the fold: fast query, cached aggressively */}
<Suspense fallback={<MetricCardsSkeleton />}>
<MetricCards />
</Suspense>
<div className="grid grid-cols-2 gap-6 mt-6">
{/* Medium speed: paginated, usually warm in cache */}
<ErrorBoundary fallback={<div className="card">Feed unavailable</div>}>
<Suspense fallback={<FeedSkeleton />}>
<RecentActivityFeed />
</Suspense>
</ErrorBoundary>
{/* Slow: complex aggregation, no cache */}
<ErrorBoundary fallback={<div className="card">Analytics unavailable</div>}>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</ErrorBoundary>
</div>
</>
)
}
// Each component handles its own data fetching
async function MetricCards() {
// Cached: cache('metrics', ...) or Next.js fetch cache
const metrics = await getMetrics()
return (
<div className="grid grid-cols-4 gap-4">
{metrics.map(m => <MetricCard key={m.id} {...m} />)}
</div>
)
}
async function RevenueChart() {
// Slow query — but doesn't block anything else
const data = await getRevenueData({ days: 30 })
return <LineChart data={data} />
}The page component itself is synchronous. It just describes the structure. Each async component is isolated behind its own Suspense boundary. The user sees the layout and skeletons immediately, MetricCards pop in first (fastest query), then the feed, then eventually the chart. The perceived load time drops dramatically even if total data fetching time is the same.
One More Thing: use() for Client Components
React 19 introduces the use() hook, which lets client components participate in Suspense by consuming promises. This is useful when you need to pass a promise from a server component to a client component without awaiting it on the server side first:
// Server component — starts the fetch, passes the Promise down
export default function Page() {
// Note: NOT awaited here
const commentsPromise = getComments()
return (
<Suspense fallback={<CommentsSkeleton />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
)
}
// Client component — uses the promise with use()
'use client'
import { use } from 'react'
function Comments({ commentsPromise }: { commentsPromise: Promise<Comment[]> }) {
// use() suspends the component until the promise resolves
const comments = use(commentsPromise)
return (
<ul>
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
)
}This pattern is useful when your comments component needs to be a client component (maybe it has interactive features) but you still want it to participate in streaming. The promise starts resolving on the server, streams to the client, and use() picks it up there.
The mental model shift: stop thinking about 'when does the page load' and start thinking about 'when does each piece of content become available.' Streaming lets users interact with what's ready while the rest catches up.
If you want to see these patterns in action without building from scratch, our templates at peal.dev ship with proper Suspense boundaries and loading states pre-wired — dashboards, auth flows, the works. Sometimes seeing working code is worth more than another blog post.
The practical takeaway: audit your slow pages. Find the slowest data fetch. Wrap it in a Suspense boundary with a good skeleton. That one change will make your app feel meaningfully faster without touching a single query. Then iterate — find the next slowest thing. Within an afternoon you can have a dashboard that feels instant even when the underlying data is slow.
