DEV Community

Cover image for Skeleton Loading Screens in Next.js App Router — The Right Way to Handle Async UI
Aon infotech
Aon infotech

Posted on

Skeleton Loading Screens in Next.js App Router — The Right Way to Handle Async UI

Skeleton screens are one of those things that seem simple until you actually implement them well. The basic idea is straightforward: show a placeholder shaped like the content while it loads. The execution has a lot of ways to go wrong.

Here's what actually works in Next.js App Router, from the patterns I've landed on after a lot of iteration building free AI image generator high quality where loading states are visible on almost every interaction.


Why Skeletons Beat Spinners for Most Cases

A spinner communicates "something is happening." A skeleton communicates "here's roughly what you're about to see." That distinction matters more than it sounds.

Users who see a skeleton can start mentally orienting to the layout before content arrives. They're not staring at an empty space trying to remember what was supposed to appear there. The cognitive load is lower, and the perceived wait time is shorter — not because the content actually loads faster, but because the user's brain is doing useful work during the wait.

The exception: if content will arrive in under 200ms, show nothing. A skeleton that flashes briefly is more disorienting than just waiting for the content.


The Suspense Boundary Pattern

App Router's native approach uses React Suspense with a loading.js file or inline Suspense boundaries:

// app/dashboard/loading.js — automatic Suspense wrapper
export default function DashboardLoading() {
  return <DashboardSkeleton />;
}
Enter fullscreen mode Exit fullscreen mode
// Inline Suspense for granular control
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The inline approach lets different sections load independently — the stats can show while the activity feed is still loading. This is often better UX than waiting for everything.


Building the Skeleton Components

The skeleton needs to match the real component's dimensions exactly. This is where most implementations go wrong — a skeleton that's 40px shorter than the content it replaces causes layout shift when content loads.

// The real component
function UserCard({ user }) {
  return (
    <div className="flex items-center gap-3 p-4 rounded-xl border border-border">
      <img 
        src={user.avatar} 
        alt={user.name}
        className="w-10 h-10 rounded-full"
      />
      <div className="flex flex-col">
        <span className="text-sm font-medium text-foreground">{user.name}</span>
        <span className="text-xs text-muted">{user.email}</span>
      </div>
    </div>
  );
}

// The skeleton — identical structure, shimmer instead of content
function UserCardSkeleton() {
  return (
    <div className="flex items-center gap-3 p-4 rounded-xl border border-border">
      <div className="w-10 h-10 rounded-full bg-neutral-200 dark:bg-neutral-700 animate-pulse" />
      <div className="flex flex-col gap-1.5">
        <div className="h-4 w-32 rounded bg-neutral-200 dark:bg-neutral-700 animate-pulse" />
        <div className="h-3 w-24 rounded bg-neutral-200 dark:bg-neutral-700 animate-pulse" />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key things to match exactly:

  • Padding and margins (the p-4 and gap-3)
  • Element dimensions (w-10 h-10 for the avatar, specific widths for text lines)
  • Border and border-radius

- The flex/grid layout structure

The Shimmer Animation

animate-pulse from Tailwind is the quickest approach — it fades the element opacity up and down. For a more polished shimmer effect:

/* globals.css */
@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

.skeleton-shimmer {
  background: linear-gradient(
    90deg,
    rgb(var(--skeleton-base)) 25%,
    rgb(var(--skeleton-highlight)) 50%,
    rgb(var(--skeleton-base)) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}
Enter fullscreen mode Exit fullscreen mode
/* In your theme */
:root {
  --skeleton-base: 229 231 235;      /* neutral-200 */
  --skeleton-highlight: 243 244 246; /* neutral-100 */
}

.dark {
  --skeleton-base: 38 38 38;         /* neutral-800 */
  --skeleton-highlight: 55 55 55;    /* neutral-700 */
}
Enter fullscreen mode Exit fullscreen mode

The directional shimmer feels more intentional than the pulse — it suggests something is actively loading rather than just waiting.


Lists of Unknown Length

When you don't know how many items will load, skeletons need a sensible count. I use three as the default:

function ListSkeleton({ count = 3 }) {
  return (
    <div className="flex flex-col gap-3">
      {Array.from({ length: count }).map((_, i) => (
        <ItemSkeleton key={i} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pick a number that represents a typical result set for your content. If users usually see 8-10 items, show 8 skeleton items. Showing 3 when 10 arrive causes a larger layout shift than showing a count closer to the actual result.


Avoiding the Double Flash

One subtle issue: if data loads very fast (under 200ms), the user sees the skeleton flash briefly before content appears. This can actually feel worse than no skeleton.

'use client';
import { useState, useEffect } from 'react';

function useDeferredLoading(isLoading, delay = 200) {
  const [showSkeleton, setShowSkeleton] = useState(false);

  useEffect(() => {
    if (!isLoading) {
      setShowSkeleton(false);
      return;
    }

    const timer = setTimeout(() => {
      setShowSkeleton(true);
    }, delay);

    return () => clearTimeout(timer);
  }, [isLoading, delay]);

  return showSkeleton;
}

// Usage
function Component() {
  const { data, isLoading } = useSomeData();
  const showSkeleton = useDeferredLoading(isLoading);

  if (showSkeleton) return <ComponentSkeleton />;
  if (!data) return null;
  return <ComponentContent data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

This only shows the skeleton if loading takes longer than 200ms. Fast loads show nothing, and the content just appears. Slower loads get the skeleton treatment.


The Skeleton Component Library Pattern

For larger applications, a shared skeleton primitive keeps things consistent:

// components/ui/Skeleton.jsx
function Skeleton({ className, ...props }) {
  return (
    <div
      className={cn(
        "animate-pulse rounded-md bg-neutral-200 dark:bg-neutral-700",
        className
      )}
      {...props}
    />
  );
}

// Usage in feature skeletons
function ProductCardSkeleton() {
  return (
    <div className="space-y-3 p-4">
      <Skeleton className="h-48 w-full rounded-xl" />
      <Skeleton className="h-4 w-3/4" />
      <Skeleton className="h-3 w-1/2" />
      <div className="flex gap-2">
        <Skeleton className="h-8 w-20" />
        <Skeleton className="h-8 w-16" />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The cn utility (clsx + tailwind-merge) handles the class merging. This pattern makes it easy to compose skeletons from a consistent primitive.


What I Built This On

The skeleton patterns above are running in production handling the generation loading state at pixova.io — the placeholder appears at the exact dimensions of the output image (aspect ratio selected before generation), so there's zero layout shift when the image arrives. The deferred loading hook prevents a flash when generation completes faster than expected.

Questions on specific skeleton scenarios? Comments open.


Common Mistakes I've Made With Skeletons

Matching width but not height. Text lines in skeletons are often set to h-4 (16px) but the actual rendered text at that font size is more like 20px with line height. Measure the real component, don't estimate.

Forgetting dark mode. bg-neutral-200 looks fine in light mode and nearly invisible against a dark background. Always add the dark mode variant: bg-neutral-200 dark:bg-neutral-700.

Using skeletons for errors. A skeleton that never resolves because there's an error underneath is confusing — users sit watching a shimmer that will never become content. Always have a clear error state that replaces the skeleton when something fails.

Too many simultaneous animations. A page with 20 elements all pulsing at slightly different rates creates visual noise. Either sync the animation timing or use a single shimmer direction across all skeletons so they feel coordinated.

Skeletons that don't match responsive behavior. Your card might be full-width on mobile and 50% on desktop. Your skeleton should match both. Use the same responsive classes on the skeleton wrapper that you use on the real component.


A Testing Approach That Actually Helps

Throttle your network in DevTools (Network tab → No throttling dropdown → Slow 3G) and reload pages with your skeleton implementation. Things to check:

  • Does the skeleton appear before content? (It should)
  • Does content replace the skeleton without any layout shift? (Measure with Chrome's CLS recording)
  • Does the skeleton look obviously different from the real content's shape? (It shouldn't — adjust dimensions)
  • Does it look okay in dark mode? (Switch and check)

Slow 3G throttling surfaces layout shift issues that you'd miss at normal speeds. Worth doing before shipping any new skeleton implementation.

Top comments (0)