Next.js loading.tsx: Streaming, Suspense and Skeleton Loading States
Master Next.js 14+ loading.tsx, React Suspense, and skeleton UI patterns. Stream data, avoid layout shift, and build loading states users won't hate.
Why Loading States Still Ruin Apps in 2026
You've shipped a beautiful page. Fast server, optimized queries, great design. Then a user on a 4G connection hits it and stares at a blank white rectangle for 900ms. First impression: broken. It doesn't matter that your data arrives eventually — that initial nothing destroys trust faster than almost anything else.
Next.js 13's App Router shipped a proper answer to this in 2023: the loading.tsx file convention. Place one next to your page.tsx and React automatically wraps the page in a <Suspense> boundary with your loading UI as the fallback. Simple idea, genuinely good ergonomics. But most tutorials stop there, and the nuance of how streaming, nested Suspense, and manual <Suspense> boundaries interact is where the real complexity lives.
This article is about that complexity. We'll cover loading.tsx basics, how HTTP streaming actually works with Server Components, when you should reach for manual <Suspense> instead of the file convention, and how to build skeleton UIs that don't make your users feel patronized. You won't need any library except Next.js itself — though we'll touch on patterns that work well with component systems like Empire UI toward the end.
Worth noting: everything here assumes Next.js 14+ with the App Router and React 18. The Pages Router's getServerSideProps approach is a different world and deliberately outside scope.
How loading.tsx and Streaming Actually Work
When Next.js encounters a loading.tsx file, it wraps the co-located page.tsx in a <Suspense> boundary automatically. The key insight is what happens *at the HTTP layer*: the server starts sending HTML immediately, flushing the shell (your layout, navigation, the loading fallback) to the browser before the async data fetch completes. That's streaming — the connection stays open and chunks arrive over time.
React 18's renderToPipeableStream makes this possible. The browser receives partial HTML, paints what it has (your loading skeleton), and then React sends a small <script> chunk when the suspended component resolves. React replaces the fallback with the real content client-side — no full page reload, no extra network round-trip. The Time to First Byte is fast; the page feels alive immediately.
/app
/dashboard
loading.tsx ← shown instantly while page.tsx suspends
page.tsx ← async Server Component, can await freely
layout.tsx ← never suspended, renders firstOne thing that trips people up: loading.tsx only covers the *initial* navigation to that route. If you navigate away and back via the client-side router, Next.js uses cached data and may skip the loading state entirely. That's usually correct behavior, but it means you can't rely on loading.tsx to test how your skeleton looks — you'll need to simulate slow responses or test with network throttling in DevTools.
In practice, the file convention handles 80% of cases. The remaining 20% — partial page updates, data-fetching inside components deep in the tree, progressive loading of independent data streams — need manual <Suspense> boundaries, which we'll get into shortly.
Writing a loading.tsx That Doesn't Look Lazy
Honestly, most skeleton screens are bad. They're either a barely-styled gray blob that looks like an error, or they're so elaborate they take more dev time than the actual page. The sweet spot: match the *structure* of your real content closely, skip the fine details.
Here's a dashboard loading skeleton that mirrors a real stats grid without being precious about pixel-perfect fidelity:
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="p-6 space-y-6">
{/* Page header skeleton */}
<div className="space-y-2">
<div className="h-8 w-48 rounded-lg bg-zinc-200 dark:bg-zinc-800 animate-pulse" />
<div className="h-4 w-72 rounded bg-zinc-200 dark:bg-zinc-800 animate-pulse" />
</div>
{/* Stats row */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="h-28 rounded-xl bg-zinc-200 dark:bg-zinc-800 animate-pulse"
/>
))}
</div>
{/* Main content area */}
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 h-64 rounded-xl bg-zinc-200 dark:bg-zinc-800 animate-pulse" />
<div className="h-64 rounded-xl bg-zinc-200 dark:bg-zinc-800 animate-pulse" />
</div>
</div>
);
}The animate-pulse class from Tailwind gives you the fade-in/fade-out shimmer effect without any JavaScript — it's a pure CSS opacity animation that cycles every 2 seconds. If you want a proper shimmer sweep (the moving highlight that travels left-to-right), you'll need a custom keyframe animation instead. Quick aside: the sweep shimmer is more impressive but the pulse is more readable on complex layouts where the moving gradient can confuse the eye.
Keep your skeleton elements at 8px or 12px border-radius increments. That .rounded-lg maps to 8px, .rounded-xl to 12px. Matching the actual component's border-radius is the single quickest way to make a skeleton look intentional rather than placeholder-ish.
Manual Suspense Boundaries for Granular Streaming
The file convention is great for whole-page loading states. But what if you have a page where the header loads instantly from cache, a sidebar takes 200ms, and the main content requires a 1.5-second database query? You don't want the entire page to show a skeleton just because one slow part is still fetching.
Manual <Suspense> boundaries solve this. Wrap each independently-fetching component in its own boundary with its own fallback, and they'll resolve at their own pace. The user sees content progressively as it arrives:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { StatsGrid } from '@/components/StatsGrid';
import { ActivityFeed } from '@/components/ActivityFeed';
import { RevenueChart } from '@/components/RevenueChart';
import { StatsSkeleton } from '@/components/skeletons/StatsSkeleton';
import { FeedSkeleton } from '@/components/skeletons/FeedSkeleton';
import { ChartSkeleton } from '@/components/skeletons/ChartSkeleton';
export default function DashboardPage() {
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<StatsGrid />
</Suspense>
<div className="grid grid-cols-3 gap-4">
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
</div>
);
}Each of those components (StatsGrid, RevenueChart, ActivityFeed) is a Server Component that can await its data directly. No useEffect, no loading state inside the component, no client-side fetch. The component just fetches and renders. React handles the rest.
Look, this pattern fundamentally changes how you think about data fetching in Next.js. You're not coordinating requests at the page level anymore — each component owns its data. That makes components genuinely reusable across pages without worrying about what data the parent has fetched. The tradeoff is that you need more skeleton components, one per independently-loading section. Worth it.
Async Server Components: The Pattern Behind the Magic
Manual <Suspense> only works the way you expect when the components inside are actually suspending. In React 18+ with Next.js, Server Components can be async functions that await data — and that await is what causes the suspension. Client Components need a different approach: they use libraries like SWR or React Query, or they trigger suspension via use(promise) (stable in React 19).
// components/StatsGrid.tsx — Server Component, no 'use client'
import { db } from '@/lib/db';
export async function StatsGrid() {
// This await suspends the component — Suspense boundary catches it
const stats = await db.query.stats.findMany({
orderBy: (s, { desc }) => [desc(s.createdAt)],
limit: 4,
});
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{stats.map((stat) => (
<StatCard key={stat.id} stat={stat} />
))}
</div>
);
}The await db.query... call is what suspends. React sees the promise, throws it up to the nearest <Suspense> boundary, renders the fallback, and then continues when the promise resolves. You didn't write a single line of loading state management inside the component — it's entirely declarative.
One thing to watch: parallel vs sequential fetching. If you await multiple things in sequence inside a single component, they run serially. That can add up fast. Use Promise.all for independent fetches:
// Bad — sequential, 300ms + 400ms = 700ms total
const user = await getUser(id);
const posts = await getPosts(id);
// Good — parallel, max(300ms, 400ms) = 400ms total
const [user, posts] = await Promise.all([
getUser(id),
getPosts(id),
]);Skeleton Design Patterns Worth Stealing
The structural skeleton we built earlier works for dashboards. But different content types need different skeleton strategies. Here's a quick breakdown of patterns that actually feel right to users.
Text-heavy content (blog posts, docs, feed items): use variable-width lines that mimic real text line breaks. A full-width bar for headings, then 100%, 90%, 85%, 100%, 70% width lines for body paragraphs. That variance reads as "text" not "loading bars".
function TextSkeleton() {
const lineWidths = ['w-full', 'w-11/12', 'w-4/5', 'w-full', 'w-3/4'];
return (
<div className="space-y-2.5">
<div className="h-6 w-1/2 rounded bg-zinc-200 dark:bg-zinc-800 animate-pulse" />
{lineWidths.map((w, i) => (
<div
key={i}
className={`h-4 ${w} rounded bg-zinc-200 dark:bg-zinc-800 animate-pulse`}
/>
))}
</div>
);
}Card grids (products, templates, components): repeat a fixed skeleton card N times — typically 6 or 8. Don't try to match the exact column count for every breakpoint; close enough is fine. The motion of 6 pulsing cards reads as "content incoming" regardless of exact layout match. This pattern pairs naturally with component libraries — if you're building out a grid with design-system cards like the ones on Empire UI or browsing templates, a grid skeleton gives users immediate spatial context about what's loading.
Tables: this one's hard. Tables with 30 rows and 8 columns are painful to skeleton-ize faithfully. Use a simplified 5-row version with 3 columns and a note that more is loading. Users understand tables enough to fill in the mental model. Or — honestly just use a loading spinner for tables. Sometimes a skeleton isn't the right tool.
One more thing — don't animate every skeleton on the page at the exact same phase. The default animate-pulse has identical timing for every element, which creates a weird synchronized pulsing effect when you have many skeletons. Add slight animation delays to stagger the pulse: style={{ animationDelay: ${i * 75}ms }}. 75ms per element gives a natural ripple effect without being distracting.
Integrating Skeleton States with a Component Library
If you're already pulling components from a design system, your skeleton approach should match those components' visual language. Skeleton elements that use 4px rounded corners look off when your actual cards use 16px rounded corners (rounded-2xl). Match the radii. Match the spacing tokens. The skeleton and the real component should feel like they inhabit the same design system — because they do.
Empire UI's component library ships with style presets that make this straightforward. If you're using glassmorphism components — the frosted-glass cards with backdrop-blur — your skeleton should still have the glass border and rounded-2xl shape, just without the blur (since backdrop-filter on a skeleton doesn't make sense). Dropping the blur but keeping the border and radius maintains spatial consistency.
For teams using the gradient generator to create brand gradients, skeleton backgrounds should sample from the same palette — desaturated, lower-opacity versions of your brand colors rather than generic gray. It's a small touch but it makes the skeleton feel designed instead of afterthought.
// Example: a glass-style skeleton card for Empire UI glassmorphism layouts
function GlassCardSkeleton() {
return (
<div className="rounded-2xl border border-white/20 p-6 space-y-4 animate-pulse">
<div className="h-5 w-2/3 rounded-lg bg-white/20" />
<div className="space-y-2">
<div className="h-3 w-full rounded bg-white/15" />
<div className="h-3 w-5/6 rounded bg-white/15" />
<div className="h-3 w-4/5 rounded bg-white/15" />
</div>
<div className="h-8 w-28 rounded-lg bg-white/20" />
</div>
);
}That's a skeleton that lives in your glass world rather than breaking out of it. The bg-white/20 and bg-white/15 keep the translucent language consistent. Works on dark or colorful backgrounds the same way your real glass cards do.
FAQ
No. loading.tsx wraps the entire page in a single Suspense boundary. If you want independent loading states for different sections of the same page, you need manual <Suspense> boundaries around each section.
Yes, but Client Components don't suspend on their own from data fetching. loading.tsx only triggers when a Server Component awaits something. For Client Component loading states, handle them inside the component with SWR, React Query, or the use() hook with a promise.
Next.js caches route segments after the first visit. Subsequent client-side navigations to the same route use cached data and skip the loading state. This is intentional — you'd need to invalidate the cache or use router.refresh() to force a re-fetch and see the skeleton again.
animate-pulse fades the whole element opacity up and down. A shimmer moves a highlight gradient across the element left-to-right. Pulse is simpler and works on any background; shimmer looks more polished but requires a custom CSS keyframe and can look odd on complex shapes.