cd /news/developer-tools/skeleton-loading-screens-in-next-js-… · home topics developer-tools article
[ARTICLE · art-41607] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer building Pixova's free AI image generator shares best practices for implementing skeleton loading screens in Next.js App Router. The approach uses React Suspense with loading.js or inline boundaries, matching skeleton dimensions exactly to prevent layout shift, and recommends showing nothing for loads under 200ms.

read6 min views1 publishedJun 27, 2026

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 states are visible on almost every interaction.

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.

App Router's native approach uses React Suspense with a .js

file or inline Suspense boundaries:

// app/dashboard/.js — automatic Suspense wrapper
export default function Dashboard() {
  return <DashboardSkeleton />;
}
js
// 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>
  );
}

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

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>
  );
}

Key things to match exactly:

p-4

and gap-3

)w-10 h-10

for the avatar, specific widths for text lines)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;
}
/* 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 */
}

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

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>
  );
}

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.

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 useDeferred(is, delay = 200) {
  const [showSkeleton, setShowSkeleton] = useState(false);

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

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

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

  return showSkeleton;
}

// Usage
function Component() {
  const { data, is } = useSomeData();
  const showSkeleton = useDeferred(is);

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

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

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>
  );
}

The cn

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

The skeleton patterns above are running in production handling the generation 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 hook prevents a flash when generation completes faster than expected.

Questions on specific skeleton scenarios? Comments open.

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.

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

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

── more in #developer-tools 4 stories · sorted by recency
── more on @next.js 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/skeleton-loading-scr…] indexed:0 read:6min 2026-06-27 ·