← Back to Blogs

Implementing Efficient Skeletal Loading States in Next.js App Router

Sun Mar 29 2026

Hey everyone, Prashuk here.

If you’ve been following my work, you know I’m obsessed with two things: UI feel and performance. There is nothing that ruins a user’s vibe more than a clunky white screen or a "Loading..." text jumping around while your data fetches.

In one of my recent projects—a SaaS dashboard with about five different API calls on the home screen—I realized that just sticking a global spinner at the top was a terrible idea. It felt slow, even though the data was loading in under 800ms.

That’s when I really dove into how Next.js handles streaming and skeletons. Here’s how I actually implement these in my real-world projects.

1. The "Loading.js" Trap

When the App Router first dropped, everyone (including me) just threw a loading.tsx file into the route folder. It’s the easiest way, right?

But I ran into this issue when the layout was complex: loading.js replaces the entire page content. If you have a sidebar and a header, and only the middle content is loading, you don't want the whole screen flickering.

What worked for me was moving away from the file-based loading and using React Suspense boundaries directly around the components that actually fetch data. It keeps the UI stable.

2. Matching the "Vibe" of the Content

A common mistake I see (and I’ve done this too) is making a skeleton that looks nothing like the final UI. If your skeleton is a gray box but your card has rounded corners and a specific padding, the transition will look "jumpy."

My rule of thumb: Copy the actual component's JSX. Literally, copy the div structure, keep the classes for padding and margins, and just replace the text/images with a div that has a subtle pulse animation.

3. Granular Suspense over Bulk Loading

In that logistics dashboard I mentioned, I had a "Total Orders" stat and a "Recent Activity" list. The stats fetched instantly, but the activity list took a second.

Instead of waiting for both, I wrapped them in separate Suspense blocks. This is where Next.js shines. The user sees the stats immediately, which gives them something to look at while the heavier list slides in.

4. Let’s look at the code

I usually build a reusable Skeleton primitive using Tailwind. It’s cleaner than importing a heavy library.

// A simple, reusable skeleton block
const Skeleton = ({ className }: { className?: string }) => (
  <div className={`animate-pulse rounded-md bg-gray-200 dark:bg-zinc-800 ${className}`} />
);

// How I implement it in a real component
export default function ProductList() {
  return (
    <section className="p-4">
      <h2 className="text-xl font-bold mb-4">Featured Products</h2>
      
      {/* This part handles the streaming */}
      <Suspense fallback={<ProductSkeletonGrid />}>
        <ProductData /> 
      </Suspense>
    </section>
  );
}

function ProductSkeletonGrid() {
  return (
    <div className="grid grid-cols-3 gap-4">
      {[...Array(6)].map((_, i) => (
        <div key={i} className="space-y-3">
          <Skeleton className="h-40 w-full" />
          <Skeleton className="h-4 w-3/4" />
          <Skeleton className="h-4 w-1/2" />
        </div>
      ))}
    </div>
  );
}

5. My Best Practices (The "Prashuk" Way)

  • Don't over-animate: A fast pulse is annoying. I usually slow down the Tailwind animate-pulse or use a very subtle opacity shift.
  • Fixed Heights: Always give your skeletons a fixed height that matches your content. This prevents the "layout shift" which is a killer for SEO and user experience.
  • The 1-second rule: If a component loads in under 300ms, sometimes a skeleton is actually worse because it flashes too quickly. In those cases, I sometimes don't show a loading state at all.

Final Thoughts

Skeletons aren't just placeholders; they are part of the UI. When I started treating them with as much respect as the actual data-driven components, the "perceived speed" of my apps went through the roof.

To be honest, users don't care how fast your API is; they care how fast it feels.

What are you guys using for loading states? Are you still team loading.js or have you switched to granular Suspense? Let me know!

— Prashuk