Loading UI

Custom loading states with loading.tsx - Suspense boundary that shows instantly on navigation. Layouts stay visible.

Overview

Create a loading.tsx file to show a custom fallback while a page loads. It is used as the Suspense fallback for that segment. Parent layouts stay mounted - only the page slot shows the loading UI.

Partial content with loading state
Loaded content
FileCreates URL?Purpose
app/loading.tsxNo - special fileGlobal loading fallback
app/blog/loading.tsxNo - special fileBlog-specific loading
app/dashboard/loading.tsxNo - special fileDashboard-specific loading
loading.tsx does not create a URL. Nearest-wins - the closest file to the navigated page is used. Layouts remain interactive while content loads.

How it Works

loading.tsx wraps the page in a Suspense boundary. On navigation the fallback shows immediately while the page chunk loads.

loading.tsx
export default function Loading() {
  return "Loading..."
}
Component hierarchy
<Layout>
  <Suspense fallback={<Loading />}>
    <Page />
  </Suspense>
</Layout>
Loading...
  1. User navigates to a route
  2. Loading UI appears in the page slot (layouts stay visible)
  3. Page content loads in the background via React.lazy
  4. Loading UI is replaced with the actual page

Global Loading UI

Place loading.tsx at the root of app for a default fallback for all routes that do not define their own.

app
layout.tsx
page.tsx
loading.tsx
/
app/loading.tsx
export default function Loading() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="h-12 w-12 animate-spin rounded-full border-t-2 border-b-2 border-black dark:border-white" />
    </div>
  )
}

Nested Loading UI

Route-specific loading by placing loading.tsx in subdirectories. Closest file to the page wins.

app
loading.tsx
page.tsx
blog
loading.tsx
page.tsx
[slug]
loading.tsx
page.tsx
dashboard
loading.tsx
page.tsx
/
/blog
/blog/:slug
/dashboard
NavigationLoading UI Used
/ → /aboutapp/loading.tsx - global
/ → /blogapp/blog/loading.tsx
/ → /blog/hello-worldapp/blog/[slug]/loading.tsx
/ → /dashboardapp/dashboard/loading.tsx

Skeleton Examples

Skeletons show approximate layout and usually feel better than a spinner alone.

Blog Post Skeleton

app/blog/[slug]/loading.tsx
export default function BlogPostLoading() {
  return (
    <article className="mx-auto max-w-3xl animate-pulse py-8">
      <div className="mb-4 h-10 w-3/4 rounded bg-neutral-200 dark:bg-neutral-800" />
      <div className="mb-8 flex gap-4">
        <div className="h-4 w-24 rounded bg-neutral-200 dark:bg-neutral-800" />
        <div className="h-4 w-32 rounded bg-neutral-200 dark:bg-neutral-800" />
      </div>
      <div className="space-y-3">
        <div className="h-4 w-full rounded bg-neutral-200 dark:bg-neutral-800" />
        <div className="h-4 w-full rounded bg-neutral-200 dark:bg-neutral-800" />
        <div className="h-4 w-5/6 rounded bg-neutral-200 dark:bg-neutral-800" />
      </div>
    </article>
  )
}

Dashboard Skeleton

app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="flex gap-6 p-6 animate-pulse">
      <div className="w-64 space-y-3">
        <div className="h-8 rounded bg-neutral-200 dark:bg-neutral-800" />
        <div className="h-4 w-3/4 rounded bg-neutral-200 dark:bg-neutral-800" />
        <div className="h-4 w-2/3 rounded bg-neutral-200 dark:bg-neutral-800" />
      </div>
      <div className="flex-1 space-y-4">
        <div className="h-8 w-1/3 rounded bg-neutral-200 dark:bg-neutral-800" />
        <div className="grid grid-cols-3 gap-4">
          <div className="h-24 rounded bg-neutral-200 dark:bg-neutral-800" />
          <div className="h-24 rounded bg-neutral-200 dark:bg-neutral-800" />
          <div className="h-24 rounded bg-neutral-200 dark:bg-neutral-800" />
        </div>
        <div className="h-64 rounded bg-neutral-200 dark:bg-neutral-800" />
      </div>
    </div>
  )
}

Card Grid Skeleton

app/products/loading.tsx
export default function ProductsLoading() {
  return (
    <div className="container mx-auto p-6">
      <div className="mb-6 h-8 w-48 animate-pulse rounded bg-neutral-200 dark:bg-neutral-800" />
      <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        {[...Array(6)].map((_, i) => (
          <div key={i} className="animate-pulse">
            <div className="mb-3 h-48 rounded-lg bg-neutral-200 dark:bg-neutral-800" />
            <div className="mb-2 h-4 w-3/4 rounded bg-neutral-200 dark:bg-neutral-800" />
            <div className="h-4 w-1/2 rounded bg-neutral-200 dark:bg-neutral-800" />
          </div>
        ))}
      </div>
    </div>
  )
}

Loading with Layout

Loading UI is shown inside the layout hierarchy. Headers, sidebars, and nav stay visible and interactive while only the page content shows the fallback.

app
layout.tsx
loading.tsx
blog
layout.tsx
loading.tsx
page.tsx
/blog
app/blog/layout.tsx
export default function BlogLayout() {
  return (
    <div>
      <header className="mb-8">
        <h1>Blog</h1>
        <nav>{/* Navigation stays visible */}</nav>
      </header>
      <main>
        <Outlet />  {/* loading.tsx or page.tsx */}
      </main>
    </div>
  )
}
Loading UI replaces only the page (or segment) inside Suspense - not the surrounding layout.

Custom Spinners

Build branded spinners that match your design system.

app/loading.tsx
export default function Loading() {
  return (
    <div className="fixed inset-0 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/50">
      <div className="rounded-2xl border border-neutral-200 bg-white p-8 shadow-2xl dark:border-neutral-800 dark:bg-black">
        <div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-neutral-300 border-t-black dark:border-neutral-700 dark:border-t-white" />
        <p className="mt-3 text-center text-sm text-neutral-500">Loading...</p>
      </div>
    </div>
  )
}
app/loading.tsx
export default function Loading() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="flex space-x-2">
        <div className="h-3 w-3 animate-bounce rounded-full bg-black dark:bg-white" />
        <div className="h-3 w-3 animate-bounce rounded-full bg-black [animation-delay:0.15s] dark:bg-white" />
        <div className="h-3 w-3 animate-bounce rounded-full bg-black [animation-delay:0.3s] dark:bg-white" />
      </div>
    </div>
  )
}

Built-in Fallback

If no loading.tsx exists, Bini.js uses a built-in spinner. It follows the dark class and prefers-color-scheme.

  • Dark mode aware - adapts to theme
  • Centered on screen
  • Minimal design
  • Used automatically when no custom loading UI is defined
Prefer custom skeletons for content-heavy pages. Keep loading UIs lightweight so they render quickly.
Was this helpful?