50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 20, 2026·8 min read

Loading States in Next.js: loading.tsx, Suspense, and Skeleton UIs That Don't Suck

A practical guide to loading.tsx, Suspense boundaries, and skeleton UIs in Next.js App Router — when to use each and how to not drive your users insane.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Loading States in Next.js: loading.tsx, Suspense, and Skeleton UIs That Don't Suck

Here's a thing we noticed after shipping a few Next.js apps: the difference between a product that feels fast and one that feels janky is almost never actual performance — it's how you handle the time between 'user clicked something' and 'data is ready'. Get that wrong and your perfectly optimized app still feels like garbage.

Next.js App Router gives you three main tools for this: the `loading.tsx` convention, React Suspense boundaries, and skeleton UIs. They overlap in ways that are genuinely confusing at first. We've gotten them wrong enough times to have opinions about when each one actually belongs.

loading.tsx: The Blunt Instrument

The `loading.tsx` file is the simplest thing in the App Router. Drop it next to a `page.tsx` and Next.js automatically wraps that page in a Suspense boundary, showing your loading file while the page is streaming in. It's basically React Suspense without you having to write the boundary yourself.

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="p-6 space-y-4">
      <div className="h-8 w-48 bg-gray-200 rounded animate-pulse" />
      <div className="grid grid-cols-3 gap-4">
        {[...Array(3)].map((_, i) => (
          <div key={i} className="h-32 bg-gray-200 rounded-lg animate-pulse" />
        ))}
      </div>
    </div>
  )
}

The catch with `loading.tsx` is that it triggers on the entire route segment — the whole page goes into loading state. That's fine for initial page loads but it means you can't use it for partial updates within a page. If you have a dashboard where the stats load fast but the activity feed is slow, a single `loading.tsx` forces you to either block everything on the slow part or show nothing useful until all of it is ready.

loading.tsx is for route-level loading states. If you need anything more granular than that, you need actual Suspense boundaries.

Suspense Boundaries: Where It Gets Interesting

Once you start putting async Server Components inside explicit Suspense boundaries, you unlock the real streaming behavior. Different parts of your page can load independently, and users see content as it becomes available rather than waiting for the slowest thing.

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { StatsCards } from './stats-cards'
import { ActivityFeed } from './activity-feed'
import { RecentOrders } from './recent-orders'
import { StatsSkeleton, ActivitySkeleton, OrdersSkeleton } from './skeletons'

export default function DashboardPage() {
  return (
    <div className="p-6 space-y-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>
      
      {/* Stats load fast — DB query with index */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsCards />
      </Suspense>

      <div className="grid grid-cols-2 gap-6">
        {/* These two load independently */}
        <Suspense fallback={<ActivitySkeleton />}>
          <ActivityFeed />
        </Suspense>

        <Suspense fallback={<OrdersSkeleton />}>
          <RecentOrders />
        </Suspense>
      </div>
    </div>
  )
}

The `StatsCards`, `ActivityFeed`, and `RecentOrders` components are all async Server Components that fetch their own data. They run in parallel — Next.js doesn't wait for one to finish before starting the next. The page streams HTML to the browser, and each section pops in as it resolves. The user sees the heading immediately, then the stats, then whichever of the two bottom sections finishes first.

This is a genuinely different mental model from the old Pages Router pattern where you'd fetch everything in `getServerSideProps` and render nothing until it was all done. We wasted months not fully embracing this because it felt weird to have data fetching scattered through the component tree. Don't make that mistake.

Writing Skeleton UIs That Actually Match

A skeleton that looks nothing like the content it's replacing is worse than a spinner. The brain pattern-matches on layout — when the real content arrives and the layout shifts dramatically, it creates a jarring experience. The goal is for the loaded content to feel like it 'fills in' the skeleton, not replace it.

// app/dashboard/skeletons.tsx
export function StatsSkeleton() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {[...Array(3)].map((_, i) => (
        <div key={i} className="border rounded-lg p-4 space-y-2">
          <div className="h-4 w-24 bg-gray-200 rounded animate-pulse" />
          <div className="h-8 w-16 bg-gray-200 rounded animate-pulse" />
          <div className="h-3 w-32 bg-gray-200 rounded animate-pulse" />
        </div>
      ))}
    </div>
  )
}

export function ActivitySkeleton() {
  return (
    <div className="border rounded-lg p-4 space-y-3">
      <div className="h-5 w-32 bg-gray-200 rounded animate-pulse" />
      {[...Array(5)].map((_, i) => (
        <div key={i} className="flex items-center gap-3">
          <div className="h-8 w-8 bg-gray-200 rounded-full animate-pulse shrink-0" />
          <div className="flex-1 space-y-1">
            <div className="h-3 w-full bg-gray-200 rounded animate-pulse" />
            <div className="h-3 w-2/3 bg-gray-200 rounded animate-pulse" />
          </div>
        </div>
      ))}
    </div>
  )
}

A few things we've learned about skeletons: match the padding and spacing exactly — if your card has `p-4`, your skeleton card should too. Use `shrink-0` on fixed-width elements so the skeleton doesn't collapse in flex containers. And the `animate-pulse` from Tailwind is genuinely good enough — you don't need a library for this.

  • Match the number of items to the expected real count (5 skeleton rows for a list that will show 5 items)
  • Use the same border radius and shadow as the real component
  • Don't animate text lines — just blocks where text will appear
  • Keep the aspect ratio of images correct so nothing shifts on load
  • Test your skeleton by throttling network in DevTools — see if it actually looks intentional or just broken

The loading.tsx vs Suspense Decision Tree

So when do you use which? Here's the rule we follow: `loading.tsx` handles route navigation loading states — when a user clicks a link and you're waiting for the new page's data. Suspense boundaries handle in-page parallelism — when different sections of a single page have different data dependencies and you don't want them blocking each other.

They also compose. You can have a `loading.tsx` that shows a page-level skeleton during navigation, and then within the page use Suspense boundaries to progressively reveal sections. The `loading.tsx` fires first (navigation), then as the page streams in, the individual Suspense fallbacks show until each section resolves.

// This is the async Server Component that Suspense wraps
// app/dashboard/activity-feed.tsx
async function ActivityFeed() {
  // This fetch runs on the server, streaming is handled by Suspense
  const activity = await getRecentActivity() // your DB call
  
  return (
    <div className="border rounded-lg p-4">
      <h2 className="font-semibold mb-3">Recent Activity</h2>
      {activity.map((item) => (
        <ActivityItem key={item.id} item={item} />
      ))}
    </div>
  )
}

// The boundary in the parent catches this while it resolves
// <Suspense fallback={<ActivitySkeleton />}>
//   <ActivityFeed />
// </Suspense>

Client Components and useTransition

All of the above is for Server Components. Once you're in Client Component territory — form submissions, filter changes, pagination — you need `useTransition` or `useState` with manual loading flags. Suspense doesn't automatically catch async operations in event handlers; it only works with the promise-based data fetching in Server Components (and data libraries like React Query that support it explicitly).

"use client"
import { useTransition, useState } from 'react'
import { useRouter } from 'next/navigation'

export function FilterBar({ currentFilter }: { currentFilter: string }) {
  const [isPending, startTransition] = useTransition()
  const router = useRouter()

  function handleFilterChange(filter: string) {
    startTransition(() => {
      router.push(`/dashboard?filter=${filter}`)
    })
  }

  return (
    <div className="flex gap-2">
      {['all', 'active', 'completed'].map((filter) => (
        <button
          key={filter}
          onClick={() => handleFilterChange(filter)}
          disabled={isPending}
          className={`px-3 py-1 rounded ${
            currentFilter === filter ? 'bg-blue-500 text-white' : 'bg-gray-100'
          } ${isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
        >
          {filter}
        </button>
      ))}
      {isPending && (
        <span className="text-sm text-gray-500 self-center">Loading...</span>
      )}
    </div>
  )
}

`useTransition` tells React that the state update inside `startTransition` is non-urgent — keep showing the current UI while the navigation/update is happening instead of immediately showing a loading state. The `isPending` flag lets you add subtle feedback (disabled buttons, opacity changes) without a full loading overlay. This pattern is way better than the old approach of manually tracking loading state with `useState`.

Mistakes We've Made (So You Don't Have To)

One early mistake was putting Suspense boundaries too high in the tree. If you wrap the entire dashboard in a single Suspense, you get the same blocking behavior as no Suspense at all — everything waits for the slowest component. The value comes from granular boundaries around the slow parts specifically.

Another one: forgetting that `loading.tsx` only handles the initial server render. If you're using client-side navigation (which Next.js does by default after the first load), the `loading.tsx` will show during route transitions. But if you do a hard refresh, you're in full SSR territory and the page blocks until the data is ready before sending HTML — `loading.tsx` won't help there. For that you need proper streaming with Suspense.

The third mistake is skeleton UIs that are too elaborate. We once spent an afternoon building a pixel-perfect animated skeleton for a section that typically loads in 80ms. The user never sees it long enough to appreciate it. Spend your skeleton time on the slow things — external API calls, complex aggregation queries, anything that regularly takes 500ms+.

Profile before you polish. A skeleton for something that loads in 80ms is wasted effort. Measure your actual load times first.

Putting It Together in a Real App

Here's how we typically structure this in production. The `loading.tsx` at the top of each major route section handles navigation transitions with a rough page-level skeleton. Within the page, we use targeted Suspense boundaries only around the components that actually have slow dependencies — usually things hitting external APIs or doing expensive aggregations. Fast DB queries with proper indexes can usually just be awaited without Suspense.

Our templates at peal.dev are set up with this pattern by default — the dashboard starter includes a working Suspense-based layout with matching skeletons so you're not building the skeleton infrastructure from scratch every time. The skeleton components live in a `_skeletons.tsx` file next to the actual components they mirror, which makes it obvious when you update the real component that the skeleton also needs updating.

The last practical thing: add a minimum skeleton display time if your data is sometimes very fast. Nothing looks weirder than a skeleton that flashes for 50ms. You can do this with a small wrapper that ensures the fallback shows for at least 300ms — though honestly, if your data is that fast, just skip the skeleton and render the real content immediately.

  • Use loading.tsx for route-level navigation loading states
  • Use Suspense for in-page parallel data loading with different timings
  • Keep skeleton components co-located with the components they mirror
  • Use useTransition for client-side state updates that trigger navigation
  • Only build elaborate skeletons for things that are genuinely slow
  • Granular Suspense boundaries > one big boundary around everything

Loading states are one of those things where the right approach is genuinely invisible — users just experience the app as fast, without really noticing why. That's the goal. Not impressive spinners. Not skeleton UIs that get a Dribbble post. Just content that appears fast enough that nobody complains.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders