Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 035 / 100

loading.tsx: concepts, trade-offs and production lessons

Instant loading states per route segment.

Expona Admin9 min read

The Route-Level Loading Pattern

We've all felt the friction: users navigate to a new page, the browser goes blank for a moment, and suddenly content appears. In Next.js 13+, loading.tsx: concepts, trade-offs and production lessons teaches us how to eliminate that jarring gap with route-segment-level loading states. This file wraps your page in a Suspense boundary automatically, showing instant feedback before data arrives. It's a powerful tool, but getting it right requires understanding what it does, what it doesn't, and where it falls short in production.

Wraps the Page in a Suspense Boundary Automatically

When you create a loading.tsx file in a route segment, Next.js automatically places your page.tsx inside a Suspense boundary. This means your page component and all its async data dependencies are wrapped, and the loading UI displays until those promises resolve.

Here's the structure:

tsx
// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="space-y-4 p-4">
      <div className="h-8 bg-slate-200 rounded animate-pulse" />
      <div className="h-48 bg-slate-200 rounded animate-pulse" />
    </div>
  );
}
tsx
// app/dashboard/page.tsx
async function getDashboardData() {
  const res = await fetch('https://api.example.com/dashboard', {
    next: { revalidate: 60 }
  });
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}

export default async function DashboardPage() {
  const data = await getDashboardData();
  return (
    <>
      <h1>{data.title}</h1>
      <div>{data.content}</div>
    </>
  );
}

Next.js wraps the page with a Suspense component internally. When getDashboardData() is still pending, the loading UI renders. Once the promise settles, the page content replaces it. This happens without you writing Suspense boilerplate.

The key insight: loading.tsx only wraps its immediate route segment and child segments that don't have their own loading.tsx. If you have app/dashboard/analytics/loading.tsx, it takes precedence for that deeper route, allowing granular control.

Shown Immediately on Navigation

One of the biggest advantages of loading.tsx is latency perception. When a user clicks a link to a new route, the loading UI appears within milliseconds—before the data fetch even starts. This is instant visual feedback, and it matters for perceived performance.

Consider an e-commerce scenario:

tsx
// app/products/[id]/loading.tsx
export default function ProductLoading() {
  return (
    <div className="max-w-4xl mx-auto">
      <div className="animate-pulse">
        <div className="w-full h-96 bg-slate-300 rounded mb-4" />
        <div className="h-8 bg-slate-200 rounded w-3/4 mb-2" />
        <div className="h-6 bg-slate-200 rounded w-1/2" />
      </div>
    </div>
  );
}
tsx
// app/products/[id]/page.tsx
async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 300 }
  });
  if (!res.ok) throw new Error('Product not found');
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const product = await getProduct(params.id);
  return (
    <div className="max-w-4xl mx-auto">
      <img src={product.image} alt={product.name} />
      <h1>{product.name}</h1>
      <p>${product.price}</p>
    </div>
  );
}

The user navigates from a product list to a detail page. Instantly, the skeleton layout appears—big image area, title bar, price line. While the API call runs (say, 400ms), the skeleton keeps the user oriented. When data arrives, it swaps in smoothly. No blank screen, no disorientation.

This is server-rendered navigation, so the entire flow happens on the server before HTML reaches the browser. The loading state is baked into that initial HTML response.

Keep Layouts Interactive While Content Loads

A critical production insight: loading.tsx shows loading state for page-level content, but sibling layout components remain interactive. Your navigation, sidebar, or header stay clickable and responsive.

Example:

tsx
// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <nav className="bg-blue-600 text-white p-4 flex gap-4">
          <a href="/">Home</a>
          <a href="/dashboard">Dashboard</a>
          <a href="/reports">Reports</a>
        </nav>
        <main className="p-6">{children}</main>
      </body>
    </html>
  );
}
tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex gap-4">
      <aside className="w-48 bg-slate-100 p-4">
        <h3>Filters</h3>
        <ul className="space-y-2">
          <li><label><input type="checkbox" /> Active</label></li>
          <li><label><input type="checkbox" /> Pending</label></li>
        </ul>
      </aside>
      <section className="flex-1">{children}</section>
    </div>
  );
}
tsx
// app/dashboard/page.tsx
async function getDashboardMetrics() {
  await new Promise(resolve => setTimeout(resolve, 1500)); // Simulate slow API
  return { users: 2340, revenue: 125600, growth: 12.3 };
}

export default async function DashboardPage() {
  const metrics = await getDashboardMetrics();
  return (
    <>
      <h1>Dashboard</h1>
      <div className="grid grid-cols-3 gap-4">
        <div className="bg-white p-4 rounded shadow">
          <p className="text-sm text-gray-600">Users</p>
          <p className="text-2xl font-bold">{metrics.users}</p>
        </div>
        <div className="bg-white p-4 rounded shadow">
          <p className="text-sm text-gray-600">Revenue</p>
          <p className="text-2xl font-bold">${metrics.revenue}</p>
        </div>
        <div className="bg-white p-4 rounded shadow">
          <p className="text-sm text-gray-600">Growth</p>
          <p className="text-2xl font-bold">{metrics.growth}%</p>
        </div>
      </div>
    </>
  );
}
tsx
// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <>
      <h1>Dashboard</h1>
      <div className="grid grid-cols-3 gap-4">
        {[1, 2, 3].map(i => (
          <div key={i} className="bg-white p-4 rounded shadow">
            <div className="h-4 bg-slate-200 rounded mb-2 w-20 animate-pulse" />
            <div className="h-8 bg-slate-200 rounded animate-pulse" />
          </div>
        ))}
      </div>
    </>
  );
}

When the user lands on the dashboard, the top navigation is fully interactive. The sidebar filters are clickable. Only the <section> child—the main content area—shows the loading skeleton. This prevents the entire page from feeling frozen and respects the user's ability to interact with other parts of the UI.

Use Skeletons, Not Spinners

This is where theory meets reality. Spinners are tempting—a single <div> with a rotating icon. But they hurt perceived performance. Skeletons that match the shape of incoming content feel faster and more intentional.

Why? Cognitive science tells us that matching layout structure reduces cognitive load. Users instantly recognize what's coming, and when content lands, it feels like a smooth replace rather than a jarring pop-in.

Compare:

tsx
// Bad: Generic spinner
export default function LoadingSpinner() {
  return (
    <div className="flex items-center justify-center h-96">
      <div className="animate-spin">
        <svg className="w-12 h-12" />
      </div>
    </div>
  );
}
tsx
// Good: Content-shaped skeleton
export default function ArticleLoading() {
  return (
    <article className="max-w-2xl mx-auto">
      <div className="mb-6">
        <div className="h-10 bg-slate-200 rounded animate-pulse w-3/4 mb-2" />
        <div className="h-5 bg-slate-200 rounded animate-pulse w-1/2" />
      </div>
      <div className="mb-6">
        <div className="h-80 bg-slate-200 rounded animate-pulse" />
      </div>
      <div className="space-y-3">
        {[1, 2, 3, 4].map(i => (
          <div key={i} className="h-4 bg-slate-200 rounded animate-pulse" />
        ))}
      </div>
    </article>
  );
}

The skeleton echoes the real component structure: title, byline, hero image, paragraphs. When content loads and replaces the skeleton, the layout doesn't shift. The user's mental model remains consistent.

Common Mistakes

Mistake 1: Making loading.tsx too complex. We've seen teams add multiple data fetches, heavy animations, or complex state logic inside loading components. Keep it lightweight. It renders on the server and shouldn't do work that delays the entire response. Use CSS animations like animate-pulse instead of JavaScript.

Mistake 2: Forgetting about error states. loading.tsx handles pending state. For errors, create an error.tsx` file in the same segment. They work together: loading shows while fetching, error shows if the fetch fails. Treat them as a pair.

Mistake 3: Nesting too many loading boundaries. Each route segment can have its own loading.tsx. But over-nesting creates a waterfall of loading states—each child waits for its parent to load, then shows its own loading state. For many nested routes, consider fetching at a higher level or using streaming with granular Suspense boundaries.

Mistake 4: Not matching aspect ratios or spacing. Skeletons that don't match the real content's dimensions feel janky. If your article title is usually 2 lines, make the skeleton 2 lines. If there's padding, include it. Use real margins and gaps.

Mistake 5: Ignoring cache headers. A fast API response means the loading state vanishes in 100ms, defeating its purpose. Coordinate with your backend on cache strategies. ISR (Incremental Static Regeneration) with next: { revalidate: ... } can reduce load times significantly.

Production Lessons

From teams running loading.tsx in e-commerce and SaaS products, a few patterns emerge:

Segment by importance. Not all routes need loading states. A product detail page? Yes. A settings form that rarely changes? Probably unnecessary. Focus loading.tsx on routes where users expect fresh data or where latency is noticeable.

Use streaming for large content trees. If your page has multiple independent data sources (sidebar recommendations, main feed, comments), consider using <Suspense> boundaries directly in your page component instead of relying solely on loading.tsx. This lets different sections load at different times, delivering content faster.

Test on slower networks. Your local dev environment is fast. Load throttling in DevTools reveals whether your skeleton actually improves perceived performance or if the gap between skeleton and content is too jarring. Aim for a 200–600ms window where the skeleton is visible.

Monitor Core Web Vitals. loading.tsx doesn't directly improve Largest Contentful Paint (LCP), but it can reduce layout shift (CLS) if skeletons are shaped correctly. Keep an eye on these metrics in production to ensure your loading states aren't introducing jank.

Key Takeaways

  • loading.tsx automatically wraps your page in a Suspense boundary at the route segment level.
  • Loading UI appears instantly on navigation, before data fetches begin.
  • Sibling layout components remain interactive while page content loads.
  • Skeletons that match content shape and spacing outperform generic spinners for perceived performance.
  • Keep loading components lightweight—no complex fetches or heavy logic.
  • Pair loading.tsx with error.tsx for complete state coverage.
  • Use CSS animations (animate-pulse) rather than JavaScript in loading UIs.
  • Consider granular Suspense boundaries for multi-source data pages instead of relying solely on route-level loading.tsx.
  • Test skeleton visibility on throttled networks to confirm they're actually helping users.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles