Skip to content

loading.tsx Is Not a Spinner

A loading file is a Suspense boundary around a whole route segment, which means it decides how much of your page waits for your slowest query. Most of them are placed by accident.

3 min read

Dropping a loading.tsx into a route gives you an instant loading state, and that is where most teams stop reading. The file is not a spinner component. It is the fallback of a Suspense boundary that Next.js wraps around the entire segment, and the boundary is the part that matters.

What that means in practice: everything inside the segment waits for the slowest thing in it. One loading.tsx at the top of a dashboard route turns a page with a fast header, a fast sidebar and one slow chart into a page that shows nothing until the chart is ready.

The header and the sidebar were ready in 40ms. The visitor saw a skeleton for two seconds because the boundary was drawn around all three.

Where the boundary belongs

Around the slow thing, not around the page.

// app/dashboard/page.tsx - no loading.tsx in this directory.
import { Suspense } from 'react';
 
export default function Dashboard() {
  return (
    <>
      <Header />       {/* renders immediately */}
      <Sidebar />      {/* renders immediately */}
 
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />   {/* the only thing that waits */}
      </Suspense>
    </>
  );
}

The shell streams first, the chart arrives when it arrives, and the visitor has something to read and something to click in the meantime. Same total time, completely different experience - and a much better LCP, because the largest painted element is no longer a skeleton.

When a route-level loading file is right

It is not always wrong. Use one when the whole segment genuinely depends on a single fetch - an article page where there is no page without the article, a detail view that is one record. There, a route-level fallback is honest: there really is nothing to show yet.

The test is one question: is there anything on this page I could render before the data arrives? If yes, the boundary is in the wrong place.

Two details that cost people an afternoon

A loading.tsx applies to its segment and every segment below it. A file at app/loading.tsx is a fallback for the entire application, which is almost never what someone meant when they created it while working on one route.

And the fallback must reserve the space its content will occupy. A skeleton 120px tall in front of a 400px chart is a 280px layout shift the moment the data lands, and CLS does not care that the shift was your own loading state.

function ChartSkeleton() {
  // Same height as the chart, so nothing moves when it swaps in.
  return <div className="h-[400px] animate-pulse rounded-xl bg-ink-100" />;
}

That is the whole of it. loading.tsx is a convenience for one case, and <Suspense> is the tool for every other one - which is worth knowing before the convenience makes the decision for you.

There is also a compliance edge to this that is easy to miss. A loading state that is only visual announces nothing to assistive technology, and since the BFSG brought accessibility into scope for German e-commerce that is a shipping problem rather than a style one - the Germany page lists the App Router failures that come up most, and this file is on it. Accessibility claims in the United States reach the same code from a different direction.

Related questions

Where should a loading file go?
At the smallest segment whose data can actually be slow. One at the root wraps the whole route, so the entire page waits for the slowest query on it.
Should we use Suspense instead?
For anything below the segment level, yes. A loading file is the segment-level shorthand for the same mechanism, and Suspense lets you draw the boundary exactly where the slow data is.
Why does our loading state never appear?
Usually because the route is static, so there is nothing to wait for, or because the data resolved from cache faster than the browser painted. Both are good outcomes.
Does a loading file hurt SEO?
Not by itself. What hurts is placing it so high that a crawler is served the skeleton while the real content arrives in a stream it may not wait for. Keep the main content outside the boundary.

← Back to all articles

Call us+1 848 272 7583WhatsApp+90 850 308 5436Emailinfo@codefacture.comContact page