Loading States the Right Way
Drop a loading.tsx file next to any page.tsx and Next.js wraps it in a Suspense boundary automatically. The loading UI appears instantly while the page streams in.
You can nest Suspense boundaries for granular control over which parts of the page stream first.
Loading UI & Suspense Boundaries
One of the biggest UX problems in web apps is the blank screen — the user clicks a link,
nothing happens for a second, then the page suddenly appears. It feels broken even when
it isn't. The App Router solves this elegantly with two complementary tools:
loading.tsx files and React Suspense boundaries.
By the end of this lesson you'll be able to show instant loading skeletons for any route, stream slow content progressively, and give users visual feedback the moment they click — without writing a single line of loading state management code.
1. The Problem: Why Pages Feel Slow
When a user navigates to a page that fetches data, three things have to happen before anything renders:
- The request reaches the server
- The server fetches data from the database or API
- The server renders the HTML and sends it back
If step 2 takes 800ms — which is completely normal for a real database query — the user stares at the previous page for nearly a second with no feedback. On slow connections it's even worse.
The traditional fix was managing a isLoading boolean in state and
conditionally rendering a spinner. That works, but it means every page has boilerplate
loading logic mixed into its component code.
Next.js has a better way.
2. loading.tsx — Instant Loading Skeletons
Create a file named loading.tsx in any route segment folder. Next.js
automatically wraps that route in a React Suspense boundary and shows your
loading.tsx content instantly while the page loads — before
any data has been fetched.
app/
└── dashboard/
├── loading.tsx ← Shown instantly on navigation
└── page.tsx ← Shown when data is ready
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div>
<h2>Loading dashboard...</h2>
</div>
);
}
The moment the user clicks the link to /dashboard, Next.js renders this
loading component immediately — no waiting for data. When the actual page finishes
loading, it seamlessly replaces the skeleton.
Building a Real Skeleton
A plain "Loading..." text is better than nothing, but a skeleton screen — a grey placeholder that mimics the shape of the real content — feels much more polished. Here's a skeleton for a dashboard with a header and a list of cards:
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="p-6">
{/* Header skeleton */}
<div className="h-8 w-48 rounded-md bg-gray-200 animate-pulse mb-6" />
{/* Cards skeleton */}
<div className="grid grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-32 rounded-lg bg-gray-200 animate-pulse" />
))}
</div>
</div>
);
}
The animate-pulse Tailwind class adds the gentle fade in/out animation that
makes skeleton screens feel alive. Try to make the skeleton roughly match the shape
of the real content — users find it less jarring when the layout doesn't shift dramatically
when the real content arrives.
3. How loading.tsx Works Under the Hood
When Next.js sees a loading.tsx file, it automatically does this:
// What Next.js generates internally:
<Suspense fallback={<DashboardLoading />}>
<DashboardPage />
</Suspense>
React Suspense works like this: if any component inside the boundary is "suspended"
(waiting for an async operation), React renders the fallback instead.
When the async operation completes, React swaps in the real content.
Since page.tsx is an async Server Component that awaits data,
it suspends while fetching — React shows the loading.tsx fallback,
then swaps it out when the page resolves. You get this behaviour for free just
by creating the file.
4. Suspense Boundaries Inside Pages
loading.tsx is a convenience for wrapping the entire page. But what if
your page has one slow section and several fast sections? With a single
loading.tsx, the entire page waits for the slowest piece.
The solution is placing Suspense boundaries inside your page around only the slow parts. The fast content renders and streams immediately; the slow content streams in when it's ready.
Here's a dashboard page with a fast header and a slow data table:
// app/dashboard/page.tsx
import { Suspense } from "react";
import RevenueTable from "./_components/RevenueTable";
import StatCards from "./_components/StatCards";
export default function DashboardPage() {
return (
<div>
{/* Renders immediately — no async work */}
<h1>Dashboard</h1>
<StatCards />
{/* RevenueTable is slow — wrap it in its own Suspense */}
<Suspense fallback={<RevenueTableSkeleton />}>
<RevenueTable />
</Suspense>
</div>
);
}
function RevenueTableSkeleton() {
return (
<div className="h-64 w-full rounded-lg bg-gray-200 animate-pulse" />
);
}
// app/dashboard/_components/RevenueTable.tsx
async function RevenueTable() {
// This takes 1.5 seconds — but it won't block the rest of the page
const data = await fetch("https://api.example.com/revenue?year=2025");
const revenue = await data.json();
return (
<table>
{/* render revenue data */}
</table>
);
}
With this setup, the user sees the heading and stat cards instantly. The revenue table area shows a skeleton, then fills in 1.5 seconds later — without blocking the rest of the page at all.
5. Parallel Data Fetching with Multiple Suspense Boundaries
You can have multiple Suspense boundaries on one page, each wrapping an independent async component. They load in parallel — none of them waits for the others:
// app/dashboard/page.tsx
import { Suspense } from "react";
import RevenueChart from "./_components/RevenueChart";
import RecentOrders from "./_components/RecentOrders";
import TopCustomers from "./_components/TopCustomers";
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<div className="grid grid-cols-3 gap-6">
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* fetches /api/revenue — 800ms */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders /> {/* fetches /api/orders — 400ms */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<TopCustomers /> {/* fetches /api/customers — 600ms */}
</Suspense>
</div>
</div>
);
}
All three components start fetching at the same time. RecentOrders finishes
first (400ms) and its skeleton is replaced. TopCustomers resolves next
(600ms). RevenueChart is last (800ms). The total wait time is 800ms —
not 400 + 600 + 800 = 1800ms. This is the power of parallel streaming.
Compare this to the old approach: sequential await calls in one component
would have taken 1800ms to resolve before anything rendered.
6. Streaming — What's Actually Happening
When Next.js streams a page, it sends HTML in chunks instead of waiting to send everything at once. The process looks like this:
- Server immediately sends the HTML shell — layout, navigation, and any non-suspended content.
- The browser renders what it has. The user sees real content almost instantly.
- As each Suspense boundary resolves, the server sends another chunk of HTML.
- The browser inserts it into the right place in the DOM.
This is fundamentally different from the old model where the server had to wait for all data before sending any HTML. With streaming, the time to first byte (TTFB) and the time to first contentful paint (FCP) are both dramatically reduced.
7. Nested loading.tsx Files
Just like layouts, loading.tsx files nest. Each one only applies to its
own folder and its children — not to parent routes.
app/
├── loading.tsx ← Shown while the root page loads
├── page.tsx
└── dashboard/
├── loading.tsx ← Shown while any /dashboard/* page loads
├── page.tsx
└── settings/
├── loading.tsx ← Shown while /dashboard/settings loads
└── page.tsx
When a user navigates to /dashboard/settings, only
dashboard/settings/loading.tsx is shown — not the parent ones.
The parent layouts are already rendered and persistent.
8. Using loading.tsx with Dynamic Routes
loading.tsx works seamlessly with dynamic segments. A single
loading.tsx covers all instances of a dynamic route:
app/
└── blog/
└── [slug]/
├── loading.tsx ← Shown for /blog/any-post while it loads
└── page.tsx
// app/blog/[slug]/loading.tsx
export default function BlogPostLoading() {
return (
<article className="max-w-2xl mx-auto p-6">
{/* Title skeleton */}
<div className="h-10 w-3/4 rounded bg-gray-200 animate-pulse mb-4" />
{/* Meta skeleton */}
<div className="h-4 w-1/4 rounded bg-gray-200 animate-pulse mb-8" />
{/* Paragraph skeletons */}
{[...Array(8)].map((_, i) => (
<div key={i} className="h-4 w-full rounded bg-gray-200 animate-pulse mb-3" />
))}
</article>
);
}
9. Common Gotchas
-
loading.tsxwraps the page, not the layout. The layout above it is already rendered — the loading skeleton only replaces thepage.tsxcontent, not the navbar or sidebar. - Don't put slow fetches in layouts. Layouts are not wrapped in Suspense automatically. A slow fetch in a layout will delay the entire route, including the loading skeleton. Keep layouts fast — move slow data fetching into page components or isolated async components.
-
Suspense only catches async Server Components and lazy-loaded Client Components.
It won't catch a regular synchronous Client Component that just takes a while
to render — use
React.lazy()for that. - Streaming requires a streaming-compatible host. Vercel supports it out of the box. If you're self-hosting, make sure your Node.js server or reverse proxy doesn't buffer the response — buffering defeats streaming entirely.
- The skeleton should match the content shape. A skeleton that looks nothing like the real content causes a jarring layout shift. Invest a few minutes in making skeletons that reflect the actual page structure.
Key Takeaways
- Create a
loading.tsxfile in any route folder to get an instant loading skeleton — Next.js handles the Suspense boundary automatically. - Use
<Suspense fallback={...}>inside pages to wrap individual slow components without blocking the entire page. - Multiple Suspense boundaries on one page load in parallel — the total wait time is the slowest component, not the sum of all of them.
- Next.js streams HTML in chunks — fast content reaches the browser immediately, slow content arrives later and slots in.
- Don't put slow data fetches in layouts — they block the loading skeleton from appearing.
- Make your skeletons match the shape of the real content to avoid layout shifts.
Next up: Lesson 106 — Error Handling with error.tsx. You'll learn how to
catch runtime errors at the route level, show user-friendly error messages, and give
users a way to recover without a full page reload.