Loading & Error UI
Loading skeletons, error boundaries, and streaming states in Next.js
Loading.tsx
Create loading.tsx next to page.tsx to show an immediate loading state:
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="space-y-4">
<div className="h-8 w-48 animate-pulse rounded bg-gray-200" />
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-32 animate-pulse rounded bg-gray-200" />
))}
</div>
</div>
)
}
Error.tsx
Errors are caught by error boundaries. The error.tsx file lets the user retry:
"use client"
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="p-6 text-center">
<h2 className="text-xl font-bold text-red-600">Something went wrong</h2>
<p className="mt-2 text-gray-600">{error.message}</p>
<button
onClick={() => reset()}
className="mt-4 rounded bg-blue-600 px-4 py-2 text-white"
>
Try again
</button>
</div>
)
}
error.tsx must be a Client Component ("use client"). The error boundary only catches errors in its segment and below — not in the layout that wraps it.
Global Error
For root-level layout errors, use global-error.tsx:
"use client"
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h1>Critical Error</h1>
<button onClick={() => reset()}>Reload</button>
</body>
</html>
)
}
Skeleton Patterns
Suspense Within Pages
Use React Suspense for streaming parts of a page independently:
import { Suspense } from "react"
import { PostSkeleton } from "./skeletons"
export default function BlogPage() {
return (
<div>
<h1>Blog</h1>
<Suspense fallback={<PostSkeleton />}>
<BlogPosts />
</Suspense>
</div>
)
}
async function BlogPosts() {
const posts = await fetch("https://api.example.com/posts").then((r) =>
r.json()
)
return <ul>{/* render posts */}</ul>
}
See It In Action
1"use client"2import { useState, useEffect } from "react"3 4function Skeleton() {5return (6 7<div className="space-y-3 animate-pulse">8<div className="h-8 w-48 bg-gray-200 rounded" />9<div className="h-4 w-64 bg-gray-200 rounded" />10<div className="h-4 w-32 bg-gray-200 rounded" />11<div className="h-24 w-full bg-gray-200 rounded" />12</div>13)14}15 16export default function UserProfile({ userId }: { userId: string }) {17const [user, setUser] = useState<{ name: string; email: string; bio: string } | null>(null)18const [loading, setLoading] = useState(true)19const [error, setError] = useState<string | null>(null)20 21async function fetchUser() {22setLoading(true)23setError(null)24try {25const res = await fetch("https://jsonplaceholder.typicode.com/users/" + userId)26if (!res.ok) throw new Error("Failed to fetch user")27const data = await res.json()28setUser({ name: data.name, email: data.email, bio: data.company?.catchPhrase ?? "" })29} catch (err) {30setError(err instanceof Error ? err.message : "Something went wrong")31} finally {32setLoading(false)33}34}35 36useEffect(() => { fetchUser() }, [userId])37 38if (loading) return <Skeleton />39if (error)40return (41 42<div className="rounded-lg border border-red-200 p-4 text-center">43<p className="font-medium text-red-600">{error}</p>44<button45 onClick={fetchUser}46 className="mt-3 rounded bg-blue-600 px-4 py-2 text-white"47>48 Retry49</button>50</div>51) if (!user) return <p className="text-gray-500">No user data.</p>52 53return (54 55<div className="space-y-2 p-4 border rounded-lg">56<h2 className="text-xl font-bold">{user.name}</h2>57<p className="text-gray-600">{user.email}</p>58<p className="text-gray-700">{user.bio}</p>59</div>60)61}Demonstrates three UI states in a single client component: a skeleton placeholder renders while data loads, user details display on success, and an error state with a retry button handles fetch failures. Uses useEffect with a dependency on userId to re-fetch when the prop changes.