Next.js Loading UI and Suspense
Advertisement
Next.js Loading UI and Suspense
React Suspense and Next.js loading conventions provide fine-grained control over which parts of your page show loading states while data loads.
- Next.js Loading UI and Suspense
- Creating Loading UI
- Suspense Boundaries
- Async Server Components
- Multiple Suspense Boundaries
- Real-World: Blog with Comments
- Error Boundaries with Suspense
- Server Component Patterns with Suspense
- Pattern 1: Sequential Loading
- Pattern 2: Parallel Loading
- Custom Skeletons
- Nested Suspense
- FAQ
Creating Loading UI
Add loading.tsx to show a spinner while the page loads:
// app/blog/loading.tsx
export default function BlogLoading() {
return (
<div className="space-y-4">
<div className="h-8 bg-gray-200 rounded animate-pulse" />
<div className="h-8 bg-gray-200 rounded animate-pulse" />
<div className="h-8 bg-gray-200 rounded animate-pulse" />
</div>
)
}
When users navigate to /blog, they see this skeleton while the real page loads.
Suspense Boundaries
Use Suspense for granular loading control:
// app/blog/page.tsx
import { Suspense } from 'react'
import PostList from './post-list'
import PostListSkeleton from './post-list-skeleton'
export default function BlogPage() {
return (
<div>
<h1>Blog</h1>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
)
}
PostList can be async and fetch data. While loading, PostListSkeleton shows.
Async Server Components
Create async Server Components that work with Suspense:
// app/blog/post-list.tsx
async function getPosts() {
await new Promise(resolve => setTimeout(resolve, 2000)) // Simulate loading
const posts = await db.posts.findMany()
return posts
}
export default async function PostList() {
const posts = await getPosts()
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// app/blog/post-list-skeleton.tsx
export default function PostListSkeleton() {
return (
<ul className="space-y-4">
{[1, 2, 3].map(i => (
<li key={i} className="h-12 bg-gray-200 rounded animate-pulse" />
))}
</ul>
)
}
Multiple Suspense Boundaries
Show different loading states for different parts:
// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserCard from './user-card'
import AnalyticsChart from './analytics-chart'
import Skeleton from './skeleton'
export default function DashboardPage() {
return (
<div className="grid gap-4">
<Suspense fallback={<Skeleton />}>
<UserCard />
</Suspense>
<Suspense fallback={<Skeleton />}>
<AnalyticsChart />
</Suspense>
</div>
)
}
Users see each section's skeleton independently as it loads.
Real-World: Blog with Comments
// app/blog/[slug]/page.tsx
import { Suspense } from 'react'
import PostContent from './post-content'
import CommentSection from './comments'
import CommentSkeleton from './comment-skeleton'
export default async function BlogPostPage({ params }) {
return (
<article>
<PostContent slug={params.slug} />
<hr />
<Suspense fallback={<CommentSkeleton />}>
<CommentSection slug={params.slug} />
</Suspense>
</article>
)
}
// app/blog/[slug]/post-content.tsx
async function getPost(slug: string) {
const post = await db.posts.findUnique({ where: { slug } })
return post
}
export default async function PostContent({ slug }: { slug: string }) {
const post = await getPost(slug)
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
</div>
)
}
// app/blog/[slug]/comments.tsx
async function getComments(slug: string) {
await new Promise(r => setTimeout(r, 2000)) // Simulate slow API
const comments = await db.comments.findMany({ where: { postSlug: slug } })
return comments
}
export default async function CommentSection({ slug }: { slug: string }) {
const comments = await getComments(slug)
return (
<div>
<h2>Comments ({comments.length})</h2>
<ul>
{comments.map(comment => (
<li key={comment.id}>
<p className="font-bold">{comment.author}</p>
<p>{comment.text}</p>
</li>
))}
</ul>
</div>
)
}
Error Boundaries with Suspense
Combine error boundaries with Suspense:
// app/products/page.tsx
import { Suspense } from 'react'
import ProductList from './product-list'
import { ErrorBoundary } from '@/components/error-boundary'
export default function ProductsPage() {
return (
<ErrorBoundary fallback={<ProductListError />}>
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
</ErrorBoundary>
)
}
If an error occurs, ProductListError shows. While loading, ProductListSkeleton shows.
Server Component Patterns with Suspense
Pattern 1: Sequential Loading
export default function Dashboard() {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
</div>
)
}
Each section loads independently.
Pattern 2: Parallel Loading
export default async function Dashboard() {
// Fetch in parallel
const [user, stats] = await Promise.all([
fetchUser(),
fetchStats()
])
return (
<div>
<UserCard user={user} />
<StatsCard stats={stats} />
</div>
)
}
All data loads simultaneously.
Custom Skeletons
Create reusable skeleton components:
// components/skeletons.tsx
export function PostSkeleton() {
return (
<div className="space-y-4">
<div className="h-8 bg-gray-200 rounded w-3/4 animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-full animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-5/6 animate-pulse" />
</div>
)
}
export function CardSkeleton() {
return (
<div className="bg-white p-4 rounded-lg shadow">
<div className="h-40 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded mt-4 animate-pulse" />
</div>
)
}
Nested Suspense
Create hierarchies of loading states:
export default function Page() {
return (
<Suspense fallback={<PageSkeleton />}>
<MainContent />
</Suspense>
)
}
async function MainContent() {
return (
<div>
<Suspense fallback={<SectionSkeleton />}>
<Section1 />
</Suspense>
<Suspense fallback={<SectionSkeleton />}>
<Section2 />
</Suspense>
</div>
)
}
If Section1 is slower than Section2, users see SectionSkeleton only for Section1.
FAQ
Q: What's the difference between loading.tsx and Suspense? A: loading.tsx shows for entire route segments. Suspense provides fine-grained control for specific components.
Q: Can I use Suspense in Client Components? A: Yes, but the Server Component inside must be lazy-loaded. Suspense works best with async Server Components.
Q: Should I show a skeleton or a generic loader? A: Content-aware skeletons (showing the shape of content coming) are better UX than generic spinners.
Master Suspense and loading states for polished user experiences.
Advertisement