Skip to Content
ExamplesNext.js Suspense

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/dynamic

lib/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.

lib/api.ts

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.

app/page.tsx

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.

app/recent-orders.tsx

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.

app/activity-feed.tsx

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.

app/analytics-chart.tsx

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 dev

Open 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.

Last updated on