Building Skeleton Loading UI in Next.js

Skeletons mirror the shape of loading content to prevent layout shift. Learn to build them in Next.js with loading files and Suspense fallbacks.

7 min read

A skeleton loading UI in Next.js is a lightweight placeholder that mirrors the shape of the content that is still loading. You show one through a loading file or a Suspense fallback, so the user sees a stable layout instead of a blank area that jumps when the data arrives.

Good skeletons prevent layout shift. When the real content appears, it fits into the space the skeleton already reserved, so nothing below it moves. Skeletons are the difference between a page that feels broken and one that feels fast.

App.tsxApp.tsx
// app/dashboard/loading.tsx
export default function Loading() {
  return <p aria-live="polite">Loading dashboard</p>;
}

Here the fallback is a simple message, but a real skeleton uses gray bars that look like the eventual content. The aria-live attribute announces the change to screen readers, which a purely visual skeleton cannot do.

Designing a skeleton that matches the content

A skeleton should copy the rough layout of the real UI. For a list, render a few gray rows, and for a profile, render a circle for the avatar and a bar for the name.

Vary the bar widths slightly to hint at the content, and match the line height of real text so the swap is seamless.

App.tsxApp.tsx
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div role="status" aria-label="Loading posts">
      <ul aria-hidden="true" className="space-y-2">
        {[1, 2, 3].map((row) => (
          <li key={row} className="h-4 w-full rounded bg-gray-200" />
        ))}
      </ul>
    </div>
  );
}

Three gray bars stand in for a three-row list. The real rows replace them without changing the page height, because each skeleton row already has the same height as the content.

The wrapper carries role and aria-label so screen readers announce the wait, while aria-hidden hides the decorative bars from them. Without that pair, the skeleton is silence for anyone not looking at the screen.

Skeletons inside Suspense

A loading file gives the whole route one skeleton. For finer control, put a skeleton in a Suspense fallback so only one section of the page shows a placeholder.

App.tsxApp.tsx
// app/dashboard/page.tsx
import { Suspense } from "react";
import { PostFeed } from "./post-feed";
import { PostSkeleton } from "./post-skeleton";
 
export default function Dashboard() {
  return (
    <section>
      <h1>Dashboard</h1>
      <Suspense fallback={<PostSkeleton />}><PostFeed /></Suspense>
    </section>
  );
}

The heading renders immediately, while the feed section shows its skeleton. When the feed data resolves, the skeleton swaps for the real list.

Skeleton vs spinner

A spinner says something is loading but reserves no space, so the page reflows when content arrives. A skeleton reserves the space and looks like the content, so the swap is invisible.

SpinnerSkeleton
Reserves spaceNoYes
Hints at layoutNoYes
Best forSmall, short waitsAnything with structure

Common mistakes

A skeleton that does not match the real content causes the exact jump it was meant to prevent. Match heights and widths, and keep the skeleton server-rendered so it appears in the first response rather than after a client-side effect.

Another common mistake is animating every bar with a pulsing effect. A subtle pulse is fine, but several animated bars distract more than a blank screen, so keep animation minimal.

For the route-level file, see loading.js, and for controlling the boundaries see Suspense boundaries.

Rune AI

Rune AI

Key Insights

  • A skeleton is a placeholder that matches the shape of loading content.
  • Matching dimensions prevents layout shift when content arrives.
  • Use a loading file for the route, Suspense fallbacks for sections.
  • Add aria-live or hidden text so the state is announced.
  • Keep skeletons server-rendered so they appear immediately.
RunePowered by Rune AI

Frequently Asked Questions

Should a skeleton match the real content?

Yes. Matching heights and widths prevents the layout shift that a skeleton is meant to avoid.

Where do I put a skeleton?

In a loading file for the whole route, or in a Suspense fallback to cover one section of a page.

Is a skeleton accessible?

Only if you pair it with text or aria attributes. Add aria-live or a visually hidden label so screen readers announce the loading state.

Conclusion

A skeleton mirrors the shape of loading content so the swap to real data does not shift the page. Put it in a loading file or Suspense fallback, match the real layout, and keep it server-rendered.