Skip to main content
Version: latest

Loading Skeleton & Empty State Pattern

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.


  • Skeleton — rectangle, circle, and text line variants
  • Loading — spinner for inline and overlay contexts
  • Card — empty state container
  • Headline — semantic heading with size scale

Further reading


👉 Open Loading Skeleton in Storybook