
Using loading.tsx and Streaming UI for Instant Feedback in Next.js
When a page depends on slow data, the worst thing it can do is nothing. The user clicks a link, the old page stays on screen, and for a second or two there's no sign that anything happened. They click again. Then they leave.
The App Router has two tools for this. loading.tsx gives a route segment an instant fallback that shows the moment navigation starts. React Suspense, which loading.tsx is built on, lets you go further and stream individual parts of a page as their data arrives, so the fast parts show up immediately and only the slow parts wait.
This post covers how loading.tsx works and where to put it, how to design skeletons that don't make the page jump, when to switch to granular Suspense boundaries, how to stream a promise into a Client Component, what happens to status codes and SEO when you stream, and the infrastructure problems that can quietly turn streaming off.
What Streaming Means Here
A traditional server-rendered page is all or nothing. The server fetches every piece of data, renders the full HTML, and only then sends a response. The time to first byte is the time of the slowest query.
With streaming, the server sends the response in chunks. The first chunk contains everything that can render right away: the layout, navigation, and fallback UI for the parts that are still loading. As each slow part finishes on the server, React sends its HTML in a later chunk and swaps it into place in the browser. The connection stays open until every part has arrived.
The user sees a usable page almost immediately, and it fills in progressively. Nothing about this needs client-side data fetching; it's still server rendering, just delivered in pieces.
loading.tsx: The One-File Version
Add a loading.tsx file next to a page.tsx, and that segment gets an instant loading state:
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div
className="animate-pulse space-y-4"
aria-busy="true"
aria-live="polite"
>
<div className="h-8 w-48 rounded bg-slate-200" />
<div className="h-4 w-full rounded bg-slate-200" />
<div className="h-4 w-full rounded bg-slate-200" />
<div className="h-4 w-2/3 rounded bg-slate-200" />
<span className="sr-only">Loading dashboard</span>
</div>
);
}
// app/dashboard/page.tsx
import { getDashboardData } from "@/lib/dashboard";
export default async function DashboardPage() {
const data = await getDashboardData(); // slow: about 1.5s
return (
<div>
<h1 className="text-2xl font-semibold">Welcome back, {data.userName}</h1>
<p>{data.openTickets} open tickets</p>
</div>
);
}
Behind the scenes, Next.js wraps the page in a Suspense boundary with your loading component as the fallback. The rendered tree for that segment looks like this:
// Conceptual structure for app/dashboard
<DashboardLayout>
<Suspense fallback={<Loading />}>
<DashboardPage />
</Suspense>
</DashboardLayout>
That gives you a few things for free:
- Instant navigation. The loading UI is part of what
Linkprefetches, so when the user clicks, Next.js can show the skeleton immediately, without a server round trip. - Shared layouts stay interactive. The sidebar and header in the layout above remain usable while the page loads.
- Navigation is interruptible. If the user clicks another link before the page finishes, Next.js moves on without waiting.
- Streaming on first load too. On a direct visit, the server sends the layout and the skeleton first, then streams the page HTML when the data resolves.
The loading component is a Server Component by default. It takes no props. Keep it lightweight: it should render instantly and not fetch anything itself.
Where to Put loading.tsx
A loading.tsx covers its own segment and everything nested below it, until a deeper loading.tsx takes over. Its scope is the most important design decision.
app/
└── dashboard/
├── layout.tsx
├── loading.tsx # covers /dashboard and anything below without its own
├── page.tsx
├── invoices/
│ ├── loading.tsx # table skeleton for /dashboard/invoices
│ └── page.tsx
└── settings/
└── page.tsx # uses the dashboard skeleton
Within a segment, loading.tsx sits inside the layout and template and wraps the page, not-found.tsx, and nested layouts. It doesn't wrap the layout in its own folder, and it doesn't wrap error.tsx. That leads to the most common surprise.
A Slow Layout Blocks the Loading State
If a layout awaits slow or uncached data (an uncached fetch, cookies(), headers()), the loading.tsx below it can't help, because the fallback is rendered inside that layout. Without Cache Components, the navigation simply waits for the layout. With Cache Components enabled, Next.js requires that work to be inside its own Suspense boundary and reports a build error if it isn't.
The fix is to move the data into the page, or wrap the slow part of the layout in its own boundary:
// app/dashboard/layout.tsx
import { Suspense } from "react";
import { DashboardNav } from "./dashboard-nav";
import { AccountMenu } from "./account-menu";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<aside className="w-56 border-r p-4">
<DashboardNav />
<Suspense fallback={<div className="mt-6 h-10 rounded bg-slate-100" />}>
<AccountMenu />
</Suspense>
</aside>
<main className="flex-1 p-8">{children}</main>
</div>
);
}
AccountMenu reads the session and is the only slow part of the layout. Wrapping it means the layout renders immediately and the page's loading.tsx can show right away.
Designing Skeletons That Don't Jump
A loading state that causes layout shift trades one bad experience for another. A few rules keep skeletons calm:
- Match the final dimensions. If the real content is a 300px chart, the skeleton should reserve 300px. If it's a table with 10 rows, render 10 placeholder rows of the same height.
- Keep the static parts real. Headings, tabs, and labels that don't depend on data can render as real text in the skeleton. Only replace the data.
- Avoid spinners for content areas. A spinner gives no hint of what's coming and usually takes up a different amount of space than the content. Skeletons shaped like the content feel faster.
- Respect reduced motion. Tailwind's
motion-safe:animate-pulseskips the pulse for users who prefer reduced motion. - Announce it.
aria-busyon the container and a visually hidden "Loading" label help screen reader users know the page is working.
A table skeleton that follows those rules:
// app/dashboard/invoices/loading.tsx
export default function InvoicesLoading() {
return (
<div aria-busy="true">
<h1 className="mb-6 text-2xl font-semibold">Invoices</h1>
<span className="sr-only">Loading invoices</span>
<div className="divide-y rounded-lg border">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex h-12 items-center gap-4 px-4">
<div className="h-4 w-24 rounded bg-slate-200 motion-safe:animate-pulse" />
<div className="h-4 flex-1 rounded bg-slate-100 motion-safe:animate-pulse" />
<div className="h-4 w-16 rounded bg-slate-200 motion-safe:animate-pulse" />
</div>
))}
</div>
</div>
);
}
The heading is real text, so it doesn't move when the page loads. Each row is exactly as tall as a real row.
Going Granular with Suspense
loading.tsx treats the whole page as one unit: nothing shows until everything in the page has loaded. That's fine when the page has a single data dependency. Most pages have several, with different speeds.
Wrapping individual components in Suspense lets each one stream independently:
// app/dashboard/page.tsx
import { Suspense } from "react";
import { RevenueCard, RevenueCardSkeleton } from "./revenue-card";
import { RecentOrders, RecentOrdersSkeleton } from "./recent-orders";
export default function DashboardPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<Suspense fallback={<RevenueCardSkeleton />}>
<RevenueCard />
</Suspense>
<Suspense fallback={<RecentOrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
// app/dashboard/revenue-card.tsx
import { getRevenue } from "@/lib/analytics";
export async function RevenueCard() {
const revenue = await getRevenue(); // about 2s
return (
<section className="h-32 rounded-lg border p-5">
<h2 className="text-sm text-slate-500">Revenue (30 days)</h2>
<p className="text-3xl font-bold">${revenue.total.toLocaleString()}</p>
</section>
);
}
export function RevenueCardSkeleton() {
return (
<div className="h-32 rounded-lg border bg-slate-50 motion-safe:animate-pulse" />
);
}
The page component itself is no longer async. It renders the heading immediately, and each async child fetches its own data. Because the two components are siblings in separate boundaries, their fetches start at the same time and each appears as soon as its own data is ready. A slow revenue API no longer holds up the order list.
Nested Boundaries for Progressive Detail
Boundaries can nest. An outer boundary shows the overall shape while an inner one waits for the slowest detail:
// app/products/[id]/page.tsx
import { Suspense } from "react";
import { ProductDetails } from "./product-details";
import { Reviews, ReviewsSkeleton } from "./reviews";
import { ProductSkeleton } from "./product-skeleton";
export default async function ProductPage({
params,
}: PageProps<"/products/[id]">) {
const { id } = await params;
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails id={id} />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={id} />
</Suspense>
</Suspense>
);
}
The product details replace the outer skeleton as soon as they're ready, and the reviews keep their own skeleton until they finish. Users can read the product description and add it to their cart while reviews are still loading.
Choosing Between loading.tsx and Suspense
loading.tsx | Suspense | |
|---|---|---|
| Scope | The whole page segment | Any component |
| Setup | Add one file | Wrap components yourself |
| Prefetched for instant navigation | Yes | Not by default |
| Best for | Pages that can't show anything without data | Pages with several data sources |
They work well together. A loading.tsx gives the route an instant fallback on navigation, and Suspense boundaries inside the page control how the content streams once it starts rendering. The Next.js docs recommend placing explicit boundaries close to the slow work rather than relying only on a high-level loading.tsx, since a single page-wide fallback hides everything until the slowest part is done.
Streaming a Promise into a Client Component
Sometimes the component that needs the data is a Client Component, such as a chart. You can start the fetch on the server without awaiting it and pass the promise down. The Client Component reads it with React's use:
// app/dashboard/stats/page.tsx
import { Suspense } from "react";
import { StatsChart } from "./stats-chart";
import { getDailyStats } from "@/lib/analytics";
export default function StatsPage() {
const statsPromise = getDailyStats(); // not awaited
return (
<>
<h1 className="text-2xl font-semibold">Stats</h1>
<Suspense fallback={<div className="h-72 rounded-lg bg-slate-50" />}>
<StatsChart statsPromise={statsPromise} />
</Suspense>
</>
);
}
// app/dashboard/stats/stats-chart.tsx
"use client";
import { use } from "react";
type DailyStat = { date: string; visits: number };
export function StatsChart({
statsPromise,
}: {
statsPromise: Promise<DailyStat[]>;
}) {
const stats = use(statsPromise);
const max = Math.max(...stats.map((s) => s.visits), 1);
return (
<div className="flex h-72 items-end gap-1">
{stats.map((s) => (
<div
key={s.date}
title={`${s.date}: ${s.visits}`}
className="flex-1 rounded-t bg-sky-500"
style={{ height: `${(s.visits / max) * 100}%` }}
/>
))}
</div>
);
}
The heading and fallback stream immediately. When the promise resolves on the server, the data streams to the browser, use returns it, and the chart renders. The resolved value must be serializable, the same as any prop passed to a Client Component.
Instant Feedback on the Link Itself
loading.tsx covers the destination. For links whose destination can't be prefetched (for example, a dynamic route with prefetch={false} and no loading.tsx), you can show a small pending hint on the link with useLinkStatus:
// app/components/nav-link.tsx
"use client";
import Link, { useLinkStatus } from "next/link";
function PendingDot() {
const { pending } = useLinkStatus();
return (
<span
aria-hidden
className={`ml-2 inline-block h-2 w-2 rounded-full bg-sky-500 transition-opacity ${
pending ? "opacity-100" : "opacity-0"
}`}
/>
);
}
export function NavLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
return (
<Link href={href} prefetch={false}>
{children}
<PendingDot />
</Link>
);
}
useLinkStatus must be called from a component rendered inside the Link. The dot is always rendered and only its opacity changes, so it doesn't cause layout shift. If the route was already prefetched, the pending state is skipped entirely, which is why loading.tsx and prefetching should still be your first choice.
Status Codes, Redirects, and SEO
Streaming changes one rule about HTTP: once the first chunk is sent, the status code is already 200 and can't be changed. That has consequences:
- Call
notFound()andredirect()before anything suspends. If you check that a record exists at the top of the page, before anySuspenseboundary or slowawait, Next.js can still return a real 404 or redirect. If the check happens inside a streamed component, Next.js handles it in the HTML instead (a streamed 404 includes anoindexrobots tag), but the status stays 200. - Errors after streaming starts are caught by the nearest
error.tsxand rendered in place of the failed section. The rest of the page stays intact. - Search engines see the full content. Streaming is still server rendering. For bots that only read static HTML, Next.js detects the user agent and waits for the full render, so they receive a complete document with metadata in the
head.
For the existence check, keep it cheap and do it first:
// app/posts/[slug]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { postExists } from "@/lib/posts";
import { PostBody, PostBodySkeleton } from "./post-body";
import { Comments } from "./comments";
export default async function PostPage({ params }: PageProps<"/posts/[slug]">) {
const { slug } = await params;
if (!(await postExists(slug))) notFound(); // before anything streams
return (
<>
<Suspense fallback={<PostBodySkeleton />}>
<PostBody slug={slug} />
</Suspense>
<Suspense fallback={<p>Loading comments...</p>}>
<Comments slug={slug} />
</Suspense>
</>
);
}
When Streaming Doesn't Seem to Work
Your code can be streaming perfectly and the user still sees everything arrive at once, because something in between buffered the response.
- Reverse proxies. Nginx buffers responses by default. Send the
X-Accel-Buffering: noheader, for example fromheaders()innext.config.ts, or disableproxy_bufferingfor your app. - CDNs. Some CDNs collect the whole response before forwarding it. Check your provider's streaming support.
- Serverless platforms. Not every platform streams by default. AWS Lambda, for instance, needs response streaming mode enabled. Vercel streams natively.
- Compression. Gzip and Brotli may buffer small chunks before flushing.
- Static export.
output: "export"produces static HTML files, so there's no server to stream from. - Tiny demo pages. Safari buffers the first 1,024 bytes of a response, so a "hello world" test may look like it isn't streaming. Real pages exceed that easily.
To confirm streaming end to end, open the document request in Chrome DevTools and look at the timing breakdown. A short time to first byte followed by a long content download means chunks are arriving over time.
You can also stream the response yourself with a small Node script:
// stream-check.mjs
const res = await fetch("http://localhost:3000/dashboard", {
headers: { "Accept-Encoding": "identity" },
});
const reader = res.body.getReader();
const start = Date.now();
let i = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(
`chunk ${i++} at +${Date.now() - start}ms (${value.length} bytes)`,
);
}
Run it against a production build (next build then next start) with node stream-check.mjs. If streaming works, you'll see an early chunk with the shell and later chunks timed roughly to your slow queries. Accept-Encoding: identity turns off compression so it doesn't hide the chunking.
Common Mistakes
Awaiting everything at the top of the page. If the page awaits three queries before returning JSX, Suspense boundaries below can't help. Move each await into the component that needs it.
One giant loading.tsx at the root. It turns every navigation into a full-page skeleton. Put loading files close to the routes they describe and use Suspense for sections.
Skeletons with the wrong size. They cause layout shift when content arrives. Match the final dimensions.
Fetching in the loading component. The fallback should render instantly. Anything slow in it defeats the purpose.
Expecting a 404 status from a streamed notFound(). Check existence before the first boundary if the status code matters.
Conclusion
loading.tsx is the fastest way to make a slow route feel responsive: one file gives you an instant, prefetched fallback and streaming on first load. Suspense boundaries take the same idea down to individual components, so fast data shows immediately and slow data streams in when it's ready. Keep layouts free of slow work, size skeletons to match real content, do existence checks before anything streams, and make sure nothing between your server and the browser is buffering the response.
If your sections are still slow because their fetches run one after another, the next step is to look at request waterfalls; the post on fetching data in parallel vs sequentially covers that. And for how loading.tsx fits alongside error boundaries in the same segment, see custom error boundaries with error.tsx.


