Next.js Suspense
A multi-section dashboard built with Next.js App Router that demonstrates three levels of Suspense: loading.tsx for route-level loading, <Suspense> boundaries for component-level streaming, and next/dynamic for code-split client components. Each section loads independently — no waterfalls.
How it works
When the user navigates to the page, loading.tsx shows while the async page component awaits getUser(). Once the page renders, three sections appear — each with its own loading state. The orders and activity sections are async server components wrapped in <Suspense> that stream in as their data resolves. The analytics chart is a client component lazy-loaded with next/dynamic, which code-splits it into a separate JS bundle.
nextjs-suspense/
├── lib/api.ts ← simulated async data (fast, medium, slow)
├── app/layout.tsx ← root layout (minimal)
├── app/loading.tsx ← route-level Suspense fallback (file convention)
├── app/page.tsx ← async page: awaits user, composes Suspense boundaries
├── app/recent-orders.tsx ← async server component (1.5s delay)
├── app/activity-feed.tsx ← async server component (3s delay)
└── app/analytics-chart.tsx ← 'use client': lazy-loaded via next/dynamiclib/api.ts
Three simulated data sources with different response times. getUser is fast (300ms) and awaited directly in the page. getOrders (1.5s) and getActivity (3s) are slow and streamed via Suspense.
export type User = {
name: string
role: string
}
export type Order = {
id: number
item: string
total: number
}
export type Activity = {
id: number
action: string
time: string
}
export async function getUser(): Promise<User> {
await new Promise((r) => setTimeout(r, 300))
return { name: 'Jordan', role: 'Admin' }
}
export async function getOrders(): Promise<Order[]> {
await new Promise((r) => setTimeout(r, 1500))
return [
{ id: 1, item: 'Mechanical Keyboard', total: 129 },
{ id: 2, item: 'Wireless Mouse', total: 69 },
{ id: 3, item: 'USB-C Hub', total: 49 },
]
}
export async function getActivity(): Promise<Activity[]> {
await new Promise((r) => setTimeout(r, 3000))
return [
{ id: 1, action: 'Placed order #1042', time: '2 min ago' },
{ id: 2, action: 'Updated billing info', time: '1 hour ago' },
{ id: 3, action: 'Logged in', time: '3 hours ago' },
]
}app/layout.tsx
Minimal root layout — just the HTML shell and the system font stack.
import type { ReactNode } from 'react'
export const metadata = { title: 'Next.js Suspense' }
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body style={{ fontFamily: 'system-ui, sans-serif', maxWidth: 520, margin: '2rem auto' }}>
{children}
</body>
</html>
)
}app/loading.tsx
Next.js wraps the page in a <Suspense> boundary automatically when this file exists. While the async Page component awaits getUser(), this fallback renders. Once the page finishes, it replaces this content — no extra code needed. This is route-level Suspense via file convention.
export default function Loading() {
return <p>Loading dashboard...</p>
}app/page.tsx
The page is an async server component. It awaits getUser() (which blocks the page for 300ms — loading.tsx covers this). Then it renders three sections: orders and activity are async server components in their own <Suspense> boundaries that stream independently. The chart is loaded via next/dynamic, which code-splits the component and shows the loading callback until the JS bundle arrives.
import { Suspense } from 'react'
import dynamic from 'next/dynamic'
import { getUser } from '../lib/api'
import { RecentOrders } from './recent-orders'
import { ActivityFeed } from './activity-feed'
const AnalyticsChart = dynamic(() => import('./analytics-chart'), {
loading: () => <p style={{ color: '#999' }}>Loading chart...</p>,
})
export default async function Page() {
const user = await getUser()
return (
<main>
<h1>Welcome back, {user.name}</h1>
<p style={{ color: '#666' }}>Role: {user.role}</p>
<hr style={{ margin: '1.5rem 0' }} />
<section>
<h2>Recent Orders</h2>
<Suspense fallback={<p style={{ color: '#999' }}>Loading orders...</p>}>
<RecentOrders />
</Suspense>
</section>
<section style={{ marginTop: '1.5rem' }}>
<h2>Activity</h2>
<Suspense fallback={<p style={{ color: '#999' }}>Loading activity...</p>}>
<ActivityFeed />
</Suspense>
</section>
<section style={{ marginTop: '1.5rem' }}>
<h2>Analytics</h2>
<AnalyticsChart />
</section>
</main>
)
}app/recent-orders.tsx
An async server component that fetches its own data. It’s wrapped in <Suspense> in the parent, so “Loading orders…” shows for 1.5 seconds while the query runs. When it resolves, React streams the HTML into the page — no client JavaScript needed for this component.
import { getOrders } from '../lib/api'
export async function RecentOrders() {
const orders = await getOrders()
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{orders.map((o) => (
<li key={o.id} style={{ padding: '0.4rem 0', borderBottom: '1px solid #eee' }}>
{o.item}
<span style={{ float: 'right' }}>${o.total}</span>
</li>
))}
</ul>
)
}app/activity-feed.tsx
Same pattern as orders but with a 3-second delay. Because each async component is in its own <Suspense> boundary, orders and activity load independently — the orders appear first (1.5s) and activity follows later (3s). Without separate boundaries, the entire page would wait for the slowest component.
import { getActivity } from '../lib/api'
export async function ActivityFeed() {
const activity = await getActivity()
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{activity.map((a) => (
<li key={a.id} style={{ padding: '0.4rem 0', borderBottom: '1px solid #eee' }}>
{a.action}
<span style={{ color: '#999', marginLeft: '0.5rem' }}>{a.time}</span>
</li>
))}
</ul>
)
}app/analytics-chart.tsx
A client component loaded via next/dynamic. The dynamic() call in page.tsx is a composite of React.lazy() and <Suspense> — it code-splits this component into a separate JS bundle and shows the loading callback until it arrives. This is useful for heavy client components (charting libraries, editors, maps) that shouldn’t block the initial page load. The component must use export default for the dynamic import to work.
'use client'
const data = [
{ label: 'Mon', value: 40 },
{ label: 'Tue', value: 70 },
{ label: 'Wed', value: 55 },
{ label: 'Thu', value: 90 },
{ label: 'Fri', value: 65 },
]
export default function AnalyticsChart() {
const max = Math.max(...data.map((d) => d.value))
return (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', height: 120 }}>
{data.map((d) => (
<div key={d.label} style={{ textAlign: 'center', flex: 1 }}>
<div
style={{
height: `${(d.value / max) * 100}px`,
background: '#333',
borderRadius: '4px 4px 0 0',
}}
/>
<span style={{ fontSize: '0.75rem', color: '#666' }}>{d.label}</span>
</div>
))}
</div>
)
}Run it
npx create-next-app@latest nextjs-suspense --ts --app --no-tailwind --no-eslint --no-src-dir
cd nextjs-suspense
mkdir lib
# create the files above
npm run devOpen http://localhost:3000 . You’ll see “Loading dashboard…” briefly, then the page with the user greeting. The orders stream in after ~1.5 seconds, the activity after ~3 seconds, and the chart loads as its JS bundle arrives. Each section is independent — no section waits for another.