
Next.js loading.js
The gap between "the user clicked a link" and "the page they wanted is fully rendered" is where a lot of apps feel sluggish even when the underlying work isn't actually slow. loading.js is Next.js's answer to that gap: drop one file into a route segment's folder, and Next.js automatically wraps everything inside that segment in a React Suspense boundary, showing your loading UI instantly while the real content streams in behind it.
The mechanism itself is simple. What's worth understanding in depth is exactly where this boundary sits relative to other special files, what it can't cover, and how its interaction with SEO and streaming actually works under the hood.
Basic Usage
export default function Loading() {
// You can add any UI inside Loading, including a Skeleton.
return <LoadingSkeleton />;
}
By default this file is a Server Component, though it can be a Client Component if you add "use client" — nothing about the convention requires either. It accepts no parameters at all; it's a pure, static fallback, just like not-found.js and forbidden.js.
Where the Boundary Actually Sits
In the component hierarchy, loading.js wraps not-found.js, page.js, and any nested layout.js files beneath it in a <Suspense> boundary. It explicitly does not wrap the layout.js, template.js, or error.js at the same segment level — those sit outside the boundary this file creates, one level up in scope.
This single fact explains a behavior that otherwise looks like a bug: if the layout in the same folder as your loading.js reads uncached or runtime data — cookies(), headers(), an uncached fetch — that layout's data access is invisible to this loading boundary entirely. The loading.js fallback simply cannot cover something that isn't inside the tree it wraps.
What actually happens in that situation depends on whether Cache Components is enabled:
- Without Cache Components: navigation blocks completely until the layout finishes rendering. The
loading.jsfallback never gets a chance to appear, because the thing standing in front of it in the tree hasn't resolved yet. - With Cache Components: any runtime data access in the layout must be explicitly wrapped in its own
<Suspense>boundary, or Next.js flags it with a build-time error rather than silently letting navigation block. The static shell streams immediately, with the uncached layout content filling in once it's ready.
Either way, the fix is the same: move uncached data fetching out of the layout and into page.js, where loading.js was designed to actually cover it — or, if the data genuinely belongs in the layout, wrap that specific access in its own local <Suspense> boundary.
Instant Loading States
The whole point of this convention is what the docs call an "instant loading state" — fallback UI that appears the moment navigation starts, not after some arbitrary delay. Because the fallback itself is prerendered, you can put real, meaningful content in it — a skeleton that mirrors the eventual layout, a cover photo, a title — rather than a generic spinner that tells the user nothing about what's coming.
Navigation Behavior
Three specific behaviors are worth calling out explicitly, since they're the actual payoff of using loading.js instead of, say, a client-side loading spinner triggered by a fetch call:
- The fallback UI is prefetched, which is what makes navigation feel immediate rather than waiting on a network round-trip before showing anything at all — assuming prefetching has had time to complete.
- Navigation is interruptible. Changing routes doesn't have to wait for the current route's content to fully load before the user can navigate somewhere else entirely. The in-flight streaming work for the abandoned navigation is simply discarded.
- Shared layouts stay interactive while new route segments stream in beneath them — the sidebar, the nav bar, whatever persists across the navigation, never freezes just because the segment below it hasn't finished loading yet.
SEO and Streaming
This is where loading.js intersects with search engine behavior in a way that's easy to get wrong if you're not paying attention to which crawler is visiting.
For bots that only scrape static HTML and can't execute JavaScript the way a real browser can (the docs specifically name Twitterbot as an example), Next.js resolves generateMetadata before streaming any UI, placing the resulting metadata directly in the <head> of the initial HTML response. For everything else, Next.js automatically detects the requesting user agent and may use streaming metadata instead — you don't have to manually branch this logic yourself; Next.js picks the right strategy per request. And because this entire streaming mechanism is server-rendered rather than client-only, it doesn't hurt SEO in the way a purely client-side loading spinner over blank HTML would.
Status Codes Are Where This Gets Genuinely Subtle
When a response streams, Next.js sends a 200 status code up front to signal the request succeeded — and then, because HTTP headers are already committed to the client at that point, the status code cannot be changed later, even if something inside the streamed content represents an error condition, like a call to notFound() partway through.
For a streamed 404 specifically, Next.js compensates by injecting a <meta name="robots" content="noindex"> tag directly into the streamed HTML. That keeps the page out of search indexes even though the outer HTTP status reads 200 — search engines respect the noindex directive in the markup itself rather than only trusting the transport-level status code. Some crawlers may still log this as a "soft 404" in their own terminology, but per Google's own guidance on the robots meta tag, the explicit noindex marker is what actually prevents indexation — the soft-404 label doesn't change that outcome.
If you have a genuine compliance or analytics requirement for an actual 404 HTTP status code — not just a noindex-marked 200 — you need to resolve that determination before the response body starts streaming, which means checking resource existence in Proxy rather than deep inside a component tree, and keeping that Proxy check fast, since it runs on every matching request:
You can run this check in Proxy to rewrite missing slugs to a not-found route, or produce a 404 response directly. Keep Proxy checks fast, and avoid fetching full content there.
When exactly does the response body start streaming? The moment a Suspense fallback renders — a loading.tsx kicking in, or a Server Component suspending under a <Suspense> boundary. Once that happens, the response headers are already locked in. Practically, this means if you need notFound() to actually produce a 404 status, it has to fire before any Suspense boundary above it starts streaming, and before any await that might itself suspend.
Browser Limits Worth Knowing About
Some browsers buffer the beginning of a streaming response and won't display anything until it exceeds roughly 1024 bytes. In practice this rarely matters for a real application with a meaningful amount of markup — it's really only visible on bare-minimum "hello world" test pages, where the entire response is small enough to sit under that buffering threshold.
Platform Support
| Deployment Option | Supported |
|---|---|
| Node.js server | Yes |
| Docker container | Yes |
| Static export | No |
| Adapters | Platform-specific |
Streaming — and by extension, loading.js's value as an instant fallback — depends on a server that can actually stream a response incrementally. A static export produces fixed HTML files ahead of time with nothing left to stream, so this convention has no meaningful effect there.
Manual Suspense Boundaries Beyond loading.js
loading.js is really just Next.js automating one Suspense boundary for you at the segment level. You're not limited to that one boundary — you can wrap any individual component in its own <Suspense> for more granular loading states within a single page:
import { Suspense } from "react";
import { PostFeed, Weather } from "./Components";
export default function Posts() {
return (
<section>
<Suspense fallback={<p>Loading feed...</p>}>
<PostFeed />
</Suspense>
<Suspense fallback={<p>Loading weather...</p>}>
<Weather />
</Suspense>
</section>
);
}
This gets you the same two underlying benefits loading.js relies on, applied at a finer grain: streaming server rendering (HTML progressively sent from server to client as pieces become ready, rather than one big blocking response) and selective hydration (React prioritizes making the components a user is actually interacting with responsive first, rather than hydrating strictly top-to-bottom).
Key Takeaways
| Behavior | Detail |
|---|---|
| Scope | Wraps not-found.js, page.js, and nested layouts below it — not its own segment's layout.js, template.js, or error.js |
| Layout runtime data | Invisible to this boundary; wrap layout-level runtime access in its own <Suspense>, or move it into page.js |
| Prefetching | The fallback UI itself is prefetched, making navigation feel instant |
| Interruptible navigation | Changing routes again doesn't wait for the abandoned route's content to finish loading |
| Streaming status codes | Always 200 while streaming; a streamed 404 relies on an injected noindex meta tag, not the HTTP status |
| Guaranteed 404 status | Must be resolved before streaming starts — check in Proxy, not deep in a component tree |
| Static export | Not supported — there's nothing left to stream once HTML is generated at build time |
loading.js earns its place as one of the simplest, highest-leverage files in the App Router precisely because it requires almost no code to use correctly — the entire API surface is "export a component" — while the behavior it unlocks (prefetched fallbacks, interruptible navigation, streaming that doesn't cost you SEO) would otherwise take real, hand-rolled infrastructure to replicate.


