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/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/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/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.
| Spinner | Skeleton | |
|---|---|---|
| Reserves space | No | Yes |
| Hints at layout | No | Yes |
| Best for | Small, short waits | Anything 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
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.
Frequently Asked Questions
Should a skeleton match the real content?
Where do I put a skeleton?
Is a skeleton accessible?
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.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.