Loading Skeleton & Empty State Pattern
Live PreviewOpen in Storybook ↗
Perceived performance is just as important as real performance. This pattern shows how to add loading skeletons, empty states, and error feedback to any data-fetching page in Next.js App Router using Skeleton, Loading, and Suspense boundaries.
When to use this pattern
- Any page or section that fetches remote data
- Replacing janky layout shifts with smooth skeleton screens
- Communicating empty search results or first-use states clearly
Suspense with skeleton fallback
Wrap async Server Components in <Suspense> and provide a skeleton UI as the fallback. React streams the shell immediately, then replaces it with real content when the data resolves.
app/users/page.tsx
import { Suspense } from "react"
import { UserList } from "./UserList"
import { UserListSkeleton } from "./UserListSkeleton"
export default function UsersPage() {
return (
<main>
<h1>Users</h1>
<Suspense fallback={<UserListSkeleton />}>
<UserList />
</Suspense>
</main>
)
}
Skeleton component
app/users/UserListSkeleton.tsx
import { Skeleton } from "@prokodo/ui/skeleton"
import "@prokodo/ui/skeleton.css"
export function UserListSkeleton() {
return (
<ul style={{ listStyle: "none", padding: 0, display: "grid", gap: "1rem" }}>
{Array.from({ length: 5 }).map((_, i) => (
<li
key={i}
style={{ display: "flex", gap: "1rem", alignItems: "center" }}
>
<Skeleton variant="circular" width="40px" height="40px" />
<div style={{ flex: 1, display: "grid", gap: "0.5rem" }}>
<Skeleton variant="text" width="60%" height="1rem" />
<Skeleton variant="text" width="40%" height="0.875rem" />
</div>
<Skeleton variant="rectangular" width="80px" height="32px" />
</li>
))}
</ul>
)
}
Async server component
app/users/UserList.tsx
import { fetchUsers } from "@/lib/users"
import { EmptyState } from "@/components/EmptyState"
export async function UserList() {
const users = await fetchUsers()
if (users.length === 0) {
return (
<EmptyState
title="No users yet"
description="Invite a team member to get started."
action={{ label: "Invite user", href: "/users/invite" }}
/>
)
}
return (
<ul style={{ listStyle: "none", padding: 0, display: "grid", gap: "1rem" }}>
{users.map(user => (
<li key={user.id}>{/* … */}</li>
))}
</ul>
)
}
Empty state component
components/EmptyState.tsx
import { Card } from "@prokodo/ui/card"
import { Headline } from "@prokodo/ui/headline"
import { Button } from "@prokodo/ui/button"
import "@prokodo/ui/card.css"
import "@prokodo/ui/headline.css"
import "@prokodo/ui/button.css"
interface EmptyStateProps {
title: string
description: string
action?: { label: string; href: string }
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<Card
style={{
textAlign: "center",
padding: "3rem",
maxWidth: "480px",
margin: "2rem auto",
}}
>
<Headline type="h2" style={{ marginBottom: "0.5rem" }}>
{title}
</Headline>
<p
style={{
color: "var(--pk-color-text-secondary)",
marginBottom: "1.5rem",
}}
>
{description}
</p>
{action && (
<Button
title={action.label}
variant="contained"
color="primary"
redirect={{ href: action.href }}
/>
)}
</Card>
)
}
Error boundary
app/users/error.tsx
"use client"
export default function UserListError({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div style={{ textAlign: "center", padding: "3rem" }}>
<p>Failed to load users: {error.message}</p>
<button onClick={reset}>Retry</button>
</div>
)
}
INP tip
Avoid mounting large skeleton trees synchronously on the critical path. If the page has multiple independent sections, nest a <Suspense> + skeleton around each one separately so React can stream and hydrate them independently.
Related components
Skeleton— rectangle, circle, and text line variantsLoading— spinner for inline and overlay contextsCard— empty state containerHeadline— semantic heading with size scale
Further reading
- Next.js Streaming with Suspense
- React
<Suspense> - prokodo Next.js agency — we help teams implement streaming SSR at scale