AiTechWorlds
AiTechWorlds
loading.tsx and <Suspense> let a Next.js page render its fast parts immediately and stream in the slow parts as their data arrives, instead of showing a blank screen until everything is ready.
Think of a restaurant that seats you and brings the bread basket right away, then serves each course as it finishes cooking, rather than making you wait at the door until the entire meal is plated. Slow data fetching doesn't have to mean a blank screen — Next.js integrates React Suspense throughout the App Router so 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