AiTechWorlds
AiTechWorlds
Slow data fetching doesn't have to mean a blank screen or a page that loads all-at-once after everything finishes. Next.js integrates React Suspense throughout the App Router, giving you fine-grained control over what shows while data is loading. You can render parts of the page instantly and stream in the rest.
Create a loading.tsx file alongside any page.tsx to automatically show a skeleton while the page data loads:
// src/app/courses/loading.tsx
export default function CoursesLoading() {
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-8" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-gray-200 overflow-hidden animate-pulse">
<div className="h-44 bg-gray-200" />
<div className="p-5 space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-3 bg-gray-200 rounded w-full" />
<div className="h-3 bg-gray-200 rounded w-2/3" />
<div className="flex justify-between items-center mt-4">
<div className="h-5 bg-gray-200 rounded w-16" />
<div className="h-8 bg-gray-200 rounded w-24" />
</div>
</div>
</div>
))}
</div>
</div>
);
}
Under the hood, loading.tsx wraps your page.tsx in a Suspense boundary automatically. When the page's async data resolves, React replaces the skeleton with the real content.
loading.tsx loads the whole page or nothing. For finer control — show part of the page immediately while other parts are still loading — use <Suspense> directly:
// src/app/courses/[slug]/page.tsx
import { Suspense } from "react";
export default function CoursePage({ params }: { params: { slug: string } }) {
return (
<div className="max-w-5xl mx-auto px-4 py-12">
{/* Static header — no data needed, renders immediately */}
<nav className="breadcrumb">Courses / {params.slug}</nav>
{/* Course info — needs data, shows skeleton while loading */}
<Suspense fallback={<CourseHeaderSkeleton />}>
<CourseHeader slug={params.slug} />
</Suspense>
{/* Reviews are independent — stream in separately */}
<Suspense fallback={<ReviewsSkeleton />}>
<CourseReviews slug={params.slug} />
</Suspense>
{/* Recommendations — lowest priority, loads last */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RelatedCourses slug={params.slug} />
</Suspense>
</div>
);
}
// Each component fetches its own data
async function CourseHeader({ slug }: { slug: string }) {
const course = await getCourse(slug); // awaited here
return (
<div>
<h1>{course.title}</h1>
<p>{course.description}</p>
</div>
);
}
async function CourseReviews({ slug }: { slug: string }) {
const reviews = await getReviews(slug); // potentially slow query
return <ReviewList reviews={reviews} />;
}
The header renders as soon as its data arrives. Reviews stream in independently when their query completes. The user sees content progressively rather than waiting for the slowest query.
A good skeleton mimics the shape of the real content — same layout, same proportions, just grey boxes:
function CourseHeaderSkeleton() {
return (
<div className="animate-pulse">
<div className="h-10 bg-gray-200 rounded w-2/3 mb-4" />
<div className="flex gap-4 mb-6">
<div className="h-6 bg-gray-200 rounded w-24" />
<div className="h-6 bg-gray-200 rounded w-32" />
<div className="h-6 bg-gray-200 rounded w-20" />
</div>
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-3/4" />
</div>
</div>
);
}
function ReviewsSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-4 animate-pulse">
<div className="w-10 h-10 rounded-full bg-gray-200 flex-shrink-0" />
<div className="flex-1">
<div className="h-4 bg-gray-200 rounded w-32 mb-2" />
<div className="h-3 bg-gray-200 rounded w-full mb-1" />
<div className="h-3 bg-gray-200 rounded w-2/3" />
</div>
</div>
))}
</div>
);
}
When React encounters a Suspense boundary around an async component, it:
The browser shows something meaningful instantly, then updates as more data arrives. No waiting for the slowest part.
Nest Suspense boundaries to create a waterfall of progressively revealed content:
export default function Dashboard() {
return (
<div>
{/* Renders instantly — no data */}
<DashboardHeader />
{/* First thing to stream in — summary stats */}
<Suspense fallback={<StatsSkeleton />}>
<QuickStats /> {/* fast query */}
</Suspense>
{/* Streams in after stats — recent activity */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity /> {/* medium query */}
</Suspense>
{/* Last to load — detailed analytics */}
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart /> {/* slow query */}
</Suspense>
</div>
);
}
Users see the dashboard structure immediately, then data fills in as it arrives — exactly like a professional dashboard should behave.
For client-initiated loading (button click, form submission), use the useTransition hook:
"use client";
import { useTransition } from "react";
function DeleteButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
function handleDelete() {
startTransition(async () => {
await deleteItem(id);
});
}
return (
<button
onClick={handleDelete}
disabled={isPending}
className={isPending ? "opacity-50 cursor-not-allowed" : ""}
>
{isPending ? "Deleting..." : "Delete"}
</button>
);
}
useTransition marks the state update as non-urgent. React keeps the current UI interactive while the update processes — no spinner needed for fast operations.
Next lesson: Error boundaries and not-found — handling errors gracefully in the App Router.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises