Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
16 minLesson 15 of 33
Next.js App Router

Loading UI & Suspense

Loading UI & Suspense in Next.js

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.

The loading.tsx Convention

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.

Suspense for Granular Loading

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.

Skeleton Components

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>
    );
}

Streaming with Suspense — How it Works

When React encounters a Suspense boundary around an async component, it:

  1. Sends the surrounding HTML immediately (including the fallback UI)
  2. Continues rendering in the background
  3. When the async part resolves, sends a small JavaScript chunk that swaps the fallback for the real content

The browser shows something meaningful instantly, then updates as more data arrives. No waiting for the slowest part.

Progressive Enhancement with Suspense

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.

Client-Side Loading States

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

Get Notes Free →
!