Loading UI with loading.tsx and Skeleton Screens

Step 24 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

In Step 20, we used <Suspense> manually to show a "Loading..." message while posts load. That works, but Next.js offers an even simpler approach: the loading.tsx file convention.

Drop a loading.tsx file in a route directory, and Next.js automatically wraps the page in <Suspense> with your loading component as the fallback. No manual <Suspense> needed.

In this step, you will:

  1. Create loading.tsx files that show skeleton UIs instead of plain "Loading..." text
  2. Understand how loading.tsx relates to <Suspense>
  3. Learn when to use loading.tsx vs manual <Suspense>

Goal: Learn the loading.tsx file convention and build skeleton loading states that give users instant visual feedback.


Table of Contents

  1. What Is loading.tsx?
  2. loading.tsx vs Manual Suspense
  3. Creating a Posts Loading Skeleton
  4. Creating a Post Detail Loading Skeleton
  5. How Skeletons Improve UX
  6. When to Use loading.tsx vs Suspense
  7. Testing the Loading States
  8. Summary & Key Takeaways

What Is loading.tsx?

↑ Index

loading.tsx is a special file that Next.js recognizes. When you place it in a route directory, Next.js automatically wraps the page.tsx in that directory with <Suspense>, using your loading.tsx component as the fallback.

app/posts/
├── loading.tsx    ← shown instantly while page.tsx loads
├── page.tsx       ← the actual page (may be slow due to data fetching)
└── PostList.tsx

When a user navigates to /posts, Next.js:

  1. Immediately renders loading.tsx — the user sees something right away
  2. In the background, renders page.tsx (which fetches data from the database)
  3. Swaps the loading UI with the actual page once rendering completes

The user never sees a blank white screen. They see a loading state instantly, then the real content streams in.

What Next.js does behind the scenes

When you create loading.tsx, Next.js essentially generates this wrapper:

<Suspense fallback={<Loading />}>
  <Page />
</Suspense>

You don't write this yourself — Next.js does it automatically. This is why loading.tsx is the simplest way to add loading states.


loading.tsx vs Manual Suspense

↑ Index

In Step 20, we manually used <Suspense> in our posts page:

// app/posts/page.tsx (current)
<Suspense
  key={page}
  fallback={<p className="text-gray-500">Loading...</p>}
>
  <PostList page={page} />
</Suspense>

With loading.tsx, the same result is achieved by just creating a file:

ApproachHowBest for
loading.tsxDrop a file — automatic <Suspense>Whole-page loading states
Manual <Suspense>Explicit wrapping in JSXSpecific sections, multiple boundaries, key prop

They are not mutually exclusive. You can use loading.tsx for the initial page load and manual <Suspense> for specific components within the page.

In our app: We keep the manual <Suspense> with key={page} in /posts/page.tsx because it re-triggers the loading state when the page number changes. The loading.tsx files we add here provide loading states for initial navigation — when you first visit the page or navigate from another route.


Creating a Posts Loading Skeleton

↑ Index

Create app/posts/loading.tsx:

export default function PostsLoading() {
  return (
    <>
      <div className="h-9 w-40 mb-4 bg-gray-200 rounded animate-pulse" />

      <div className="h-5 w-48 mb-8 bg-gray-100 rounded animate-pulse" />

      <div className="space-y-4">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="p-6 bg-white rounded-lg shadow-md">
            <div className="h-6 w-3/4 mb-2 bg-gray-200 rounded animate-pulse" />
            <div className="h-4 w-1/3 mb-3 bg-gray-100 rounded animate-pulse" />
            <div className="space-y-2">
              <div className="h-4 w-full bg-gray-100 rounded animate-pulse" />
              <div className="h-4 w-5/6 bg-gray-100 rounded animate-pulse" />
            </div>
          </div>
        ))}
      </div>
    </>
  );
}

What this does

  • No "use client" needed — this is a Server Component (loading skeletons don't need interactivity)
  • 5 skeleton cards match the shape of the real post cards (title bar, author/date bar, content lines)
  • animate-pulse is a Tailwind utility that creates a pulsing animation — the standard skeleton loading effect
  • Array.from({ length: 5 }) creates 5 placeholder cards, matching our POSTS_PER_PAGE = 5

The skeleton mirrors the real UI

The key to a good skeleton is matching the layout of the real content:

Real post card:              Skeleton card:
┌─────────────────────┐      ┌─────────────────────┐
│ Post Title           │      │ ████████████████     │  ← gray bar (h-6, w-3/4)
│ By Alice · Jan 1     │      │ ████████             │  ← smaller bar (h-4, w-1/3)
│                      │      │                      │
│ Post content text    │      │ ████████████████████ │  ← full-width bar
│ that continues...    │      │ ██████████████████   │  ← slightly shorter bar
└─────────────────────┘      └─────────────────────┘

Creating a Post Detail Loading Skeleton

↑ Index

Create app/posts/[id]/loading.tsx:

export default function PostDetailLoading() {
  return (
    <>
      <div className="h-4 w-32 mb-6 bg-gray-200 rounded animate-pulse" />

      <div className="p-8 bg-white rounded-lg shadow-md">
        <div className="h-8 w-2/3 mb-2 bg-gray-200 rounded animate-pulse" />
        <div className="h-4 w-1/4 mb-6 bg-gray-100 rounded animate-pulse" />

        <div className="space-y-3">
          <div className="h-4 w-full bg-gray-100 rounded animate-pulse" />
          <div className="h-4 w-full bg-gray-100 rounded animate-pulse" />
          <div className="h-4 w-5/6 bg-gray-100 rounded animate-pulse" />
          <div className="h-4 w-full bg-gray-100 rounded animate-pulse" />
          <div className="h-4 w-3/4 bg-gray-100 rounded animate-pulse" />
        </div>
      </div>
    </>
  );
}

This skeleton mirrors the post detail page:

  • A back link bar at the top
  • A card with a title bar, a date/author bar, and several content lines

How Skeletons Improve UX

↑ Index

The three loading patterns

PatternWhat the user seesUser experience
Blank screenNothingWorst — user thinks the app is broken
SpinnerA spinning iconBetter — user knows something is happening
SkeletonGray shapes matching the final layoutBest — user knows what content is coming

Skeletons are better than spinners because they set expectations. The user can see the shape of the page before the content arrives. This is called perceived performance — the app feels faster even if the actual load time is the same.

Why animate-pulse?

Tailwind's animate-pulse applies this CSS animation:

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

It fades the element in and out continuously, creating the "breathing" effect associated with loading states. This subtle animation tells the user that content is actively loading — not that the page is broken.


When to Use loading.tsx vs Suspense

↑ Index

Use caseBest approach
Whole page needs a loading stateloading.tsx
Specific section of a page needs loadingManual <Suspense>
Loading state needs key to retriggerManual <Suspense> with key
Multiple independent loading states on one pageMultiple manual <Suspense> boundaries
Simple app, want least codeloading.tsx

In our app

We use both:

  • loading.tsx in app/posts/ — provides a skeleton for the initial navigation to the posts page
  • loading.tsx in app/posts/[id]/ — provides a skeleton for the initial navigation to a post detail page
  • Manual <Suspense> with key={page} in app/posts/page.tsx — re-triggers loading when pagination changes
  • Manual <Suspense> in app/posts/[id]/page.tsx — wraps the <AuthorPosts> component separately

They complement each other. loading.tsx handles the full-page loading on first navigation, and <Suspense> handles granular loading states within the page.


Testing the Loading States

↑ Index

Skeleton loading happens so fast on localhost that you might not see it. Here are ways to test:

Method 1: Slow down the network

In Chrome DevTools:

  1. Open DevTools → Network tab
  2. Change "No throttling" to "Slow 3G"
  3. Navigate to /posts — you should see the skeleton briefly before the real content loads

Method 2: Add a delay (temporary)

Temporarily add a delay to PostList.tsx:

export default async function PostList({ page }: Props) {
  await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second delay
  // ... rest of the component
}

Navigate to /posts — you'll see the skeleton for 2 seconds, then the real posts. Remove the delay after testing.

Method 3: Check production

Deploy to Vercel and test on a real network. The loading states are more visible on real connections, especially on mobile.


Summary & Key Takeaways

↑ Index

ConceptDetails
loading.tsxSpecial file that Next.js uses as a <Suspense> fallback for the entire page
Automatic SuspenseNext.js wraps your page.tsx in <Suspense fallback={<Loading />}> automatically
Server Componentloading.tsx does not need "use client" — it's a static UI
Skeleton screensGray shapes matching the final layout — better UX than spinners or blank screens
animate-pulseTailwind utility for the pulsing skeleton animation
ComplementaryUse loading.tsx for whole-page loading, manual <Suspense> for specific sections
HierarchyPlaced at the route level — app/posts/loading.tsx covers /posts

What is Next

In Step 25, we will add dynamic metadata and SEO using the generateMetadata function — giving each page its own <title>, description, and Open Graph tags for better search engine visibility and social media sharing.