Loading UI with loading.tsx and Skeleton Screens
Step 24 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
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:
- Create
loading.tsxfiles that show skeleton UIs instead of plain "Loading..." text - Understand how
loading.tsxrelates to<Suspense> - Learn when to use
loading.tsxvs manual<Suspense>
Goal: Learn the loading.tsx file convention and build skeleton loading states that give users instant visual feedback.
Table of Contents
- What Is loading.tsx?
- loading.tsx vs Manual Suspense
- Creating a Posts Loading Skeleton
- Creating a Post Detail Loading Skeleton
- How Skeletons Improve UX
- When to Use loading.tsx vs Suspense
- Testing the Loading States
- Summary & Key Takeaways
What Is loading.tsx?
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:
- Immediately renders
loading.tsx— the user sees something right away - In the background, renders
page.tsx(which fetches data from the database) - 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
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:
| Approach | How | Best for |
|---|---|---|
loading.tsx | Drop a file — automatic <Suspense> | Whole-page loading states |
Manual <Suspense> | Explicit wrapping in JSX | Specific 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>withkey={page}in/posts/page.tsxbecause it re-triggers the loading state when the page number changes. Theloading.tsxfiles 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
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-pulseis a Tailwind utility that creates a pulsing animation — the standard skeleton loading effectArray.from({ length: 5 })creates 5 placeholder cards, matching ourPOSTS_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
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
The three loading patterns
| Pattern | What the user sees | User experience |
|---|---|---|
| Blank screen | Nothing | Worst — user thinks the app is broken |
| Spinner | A spinning icon | Better — user knows something is happening |
| Skeleton | Gray shapes matching the final layout | Best — 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
| Use case | Best approach |
|---|---|
| Whole page needs a loading state | loading.tsx |
| Specific section of a page needs loading | Manual <Suspense> |
Loading state needs key to retrigger | Manual <Suspense> with key |
| Multiple independent loading states on one page | Multiple manual <Suspense> boundaries |
| Simple app, want least code | loading.tsx |
In our app
We use both:
loading.tsxinapp/posts/— provides a skeleton for the initial navigation to the posts pageloading.tsxinapp/posts/[id]/— provides a skeleton for the initial navigation to a post detail page- Manual
<Suspense>withkey={page}inapp/posts/page.tsx— re-triggers loading when pagination changes - Manual
<Suspense>inapp/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
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:
- Open DevTools → Network tab
- Change "No throttling" to "Slow 3G"
- 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
| Concept | Details |
|---|---|
loading.tsx | Special file that Next.js uses as a <Suspense> fallback for the entire page |
| Automatic Suspense | Next.js wraps your page.tsx in <Suspense fallback={<Loading />}> automatically |
| Server Component | loading.tsx does not need "use client" — it's a static UI |
| Skeleton screens | Gray shapes matching the final layout — better UX than spinners or blank screens |
animate-pulse | Tailwind utility for the pulsing skeleton animation |
| Complementary | Use loading.tsx for whole-page loading, manual <Suspense> for specific sections |
| Hierarchy | Placed 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.