NextjsLoading UI & loading.tsx

Loading UI & loading.tsx

Drop a loading.tsx file into any route segment, and Next.js automatically wraps that segment's page.tsx (and everything nested under it) in a React <Suspense> boundary, using your loading.tsx component as the fallback. You get instant loading states without writing a single <Suspense> tag yourself.
How it works

app/dashboard/loading.tsx

TSX
export default function Loading() {
  return <p>Loading dashboard…</p>
}

app/dashboard/page.tsx

TSX
import { getDashboardData } from '@/libs/analytics'

export default async function DashboardPage() {
  const data = await getDashboardData() // slow-ish query

  return <Dashboard data={data} />
}
Behind the scenes, this pair is equivalent to manually wrapping <DashboardPage /> in <Suspense fallback={<Loading />}> inside the parent layout — Next.js just does it for you whenever it sees a loading.tsx file next to a page.tsx.
Shown during navigation, not just first load
The loading UI appears both on a fresh page load and when navigating client-side from another page in your app — clicking a link into /dashboard shows the loading state immediately while the new page's data is being fetched on the server, keeping the UI responsive instead of leaving the previous page frozen with no feedback.
A worked skeleton example

A skeleton — gray placeholder shapes matching the real layout — usually feels smoother than a spinner or plain text, since it previews the page's structure before content arrives.

app/dashboard/loading.tsx — skeleton

TSX
export default function Loading() {
  return (
    <div>
      <div className="skeleton-line" style={{ width: '40%', height: 24 }} />
      <div style={{ display: 'flex', gap: 16, marginTop: 16 }}>
        <div className="skeleton-box" style={{ width: 200, height: 120 }} />
        <div className="skeleton-box" style={{ width: 200, height: 120 }} />
        <div className="skeleton-box" style={{ width: 200, height: 120 }} />
      </div>
    </div>
  )
}
Granularity at different nesting levels
Because loading.tsx is scoped to the route segment it's placed in, you can add it at multiple levels of nesting for progressively finer loading states — a top-level layout shows a full-page skeleton for the very first visit, while a nested segment shows a smaller, local loading state when only that inner section is navigating.

Nested loading.tsx files

Text
app/
  dashboard/
    layout.tsx
    loading.tsx        ← shown while /dashboard itself loads
    page.tsx
    settings/
      loading.tsx      ← shown while /dashboard/settings loads
      page.tsx
Only the innermost matching loading.tsx is used for a given navigation — navigating straight to /dashboard/settings shows the settings segment's own loading UI, not the dashboard-level one, since the shared parts of the layout above it are already rendered.
Tip
Reach for loading.tsx as the default way to add loading states to whole pages. Use manual <Suspense> boundaries (covered on the previous page) when you need multiple independent loading states within a single page, since a route can only have one loading.tsx.
Note
loading.tsx only affects the segment it's placed in and its children — it has no effect on sibling routes, and a parent layout's content above the segment continues to render (and stay interactive) while the segment below it is loading.
  • A loading.tsx file in a route segment automatically wraps that segment's page.tsx in a Suspense boundary, using itself as the fallback.

  • It appears on both first load and client-side navigation into that route.

  • A skeleton UI matching the real layout typically feels smoother than a spinner.

  • Nested loading.tsx files give progressively finer loading states as navigation gets deeper into the route tree.

  • Use loading.tsx for whole-page loading states; use manual <Suspense> boundaries for multiple independent loading states within one page.