Co-locate skeletons with components, match layout structure, prevent CLS.
Spinners say "something is happening." Skeletons say "this is what's coming." They preview the content structure, reducing perceived loading time and preventing layout shift when data arrives.
A spinner offers no information about what will appear. A skeleton shows the shape of content—users mentally prepare for what's coming, and the transition from placeholder to real content feels natural rather than jarring.
Export skeletons alongside their components. When you change a component's layout, the skeleton is right there to update:
// PostList.tsx
export async function PostList({ searchParams }) {
const posts = await getPosts(searchParams);
return (
<div className="space-y-4">
{posts.map(post => (
<Card key={post.slug}>
<CardTitle>{post.title}</CardTitle>
<p>{post.description}</p>
</Card>
))}
</div>
);
}
// Co-located skeleton in the same file
export function PostListSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<Card key={i}>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-full" />
</Card>
))}
</div>
);
}The skeleton should mirror the real component's structure exactly:
space-y-4)Card, CardHeader)When data loads, content replaces skeleton with zero layout shift.
Import both the component and its skeleton:
import { PostList, PostListSkeleton } from './_components/PostList';
export default function DashboardPage({ searchParams }) {
return (
<Suspense fallback| Element | Skeleton |
|---|---|
| Title | Rectangle, ~60% width |
| Date/metadata | Shorter rectangle, ~30% width |
| Description | Full width, shorter height |
| Avatar | Circle |
| Button | Rectangle with rounded corners |
Use subtle animation (animate-pulse) to indicate loading, but keep it gentle—aggressive animation is distracting.