Building Loading States with loading.tsx
A great user experience doesn't wait for data in silence. The App Router's loading.tsx file creates Suspense boundaries that show a placeholder UI (skeleton screens, spinners, progress bars) while your page component fetches data. Once the data arrives, the loading UI is replaced with the real page. This pattern, called streaming, provides immediate visual feedback and improves perceived performance—users see something happening right away instead of a blank screen.
What is loading.tsx and How Does it Work?
loading.tsx is a special file that Next.js renders while its sibling or child page.tsx is still loading. When you create a loading.tsx file in a folder, Next.js automatically wraps that folder's page.tsx in a React Suspense boundary. The loading component displays until the page resolves, then the page renders.
The key insight: loading happens on the server before the page is sent to the browser. The browser receives the loading UI immediately, then the full page arrives and replaces it. This is called streaming—you send UI to the browser in stages.
File Structure
app/
└── blog/
├── loading.tsx # Suspense boundary for blog pages
├── page.tsx # /blog (renders when data loads)
└── [slug]/
├── loading.tsx # Suspense boundary for individual posts
└── page.tsx # /blog/:slug (renders when post data loads)
Building a Skeleton Screen Loader
Skeleton screens are placeholder UIs that mimic the shape of real content—empty boxes and lines that animate, showing users "something is loading here". Here's a blog listing page with a skeleton loader:
Step 1: Create the Skeleton Loader
// app/blog/loading.tsx
export default function BlogLoading() {
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-8 bg-gray-300 h-10 w-48 rounded animate-pulse"></h1>
<div className="space-y-6">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="p-6 border rounded-lg bg-gray-50 space-y-3"
>
<div className="h-6 bg-gray-300 w-3/4 rounded animate-pulse"></div>
<div className="h-4 bg-gray-300 w-full rounded animate-pulse"></div>
<div className="h-4 bg-gray-300 w-2/3 rounded animate-pulse"></div>
</div>
))}
</div>
</div>
);
}
The animate-pulse class makes boxes fade in and out, creating the illusion of loading.
Step 2: Create the Real Page with Async Data
// app/blog/page.tsx
import { Suspense } from "react";
async function getBlogPosts() {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 2000));
return [
{ id: 1, title: "React Hooks Deep Dive", excerpt: "Understanding hooks..." },
{ id: 2, title: "Next.js 15 Features", excerpt: "What's new in..." },
{
id: 3,
title: "Server Components Guide",
excerpt: "Master Server Components...",
},
];
}
export default async function BlogPage() {
const posts = await getBlogPosts();
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="space-y-6">
{posts.map((post) => (
<article
key={post.id}
className="p-6 border rounded-lg hover:shadow-lg transition"
>
<h2 className="text-2xl font-bold mb-2">{post.title}</h2>
<p className="text-gray-600">{post.excerpt}</p>
<a href={`/blog/${post.id}`} className="text-blue-600 mt-4 block">
Read more →
</a>
</article>
))}
</div>
</div>
);
}
When you visit /blog, the browser first shows the skeleton from loading.tsx (instantly), waits 2 seconds, then replaces it with the real page. No blank screens—just smooth loading feedback.
Nested Loading States
Each folder can have its own loading.tsx. For example, a blog with post details:
app/
└── blog/
├── loading.tsx # Skeleton for /blog
├── page.tsx
└── [slug]/
├── loading.tsx # Skeleton for /blog/:slug
└── page.tsx
// app/blog/[slug]/loading.tsx
export default function PostLoading() {
return (
<div className="max-w-2xl mx-auto p-8">
<div className="h-12 bg-gray-300 w-3/4 rounded animate-pulse mb-4"></div>
<div className="h-4 bg-gray-300 w-1/2 rounded animate-pulse mb-8"></div>
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<div
key={i}
className="h-4 bg-gray-300 w-full rounded animate-pulse"
></div>
))}
</div>
</div>
);
}
// app/blog/[slug]/page.tsx
async function getBlogPost(slug: string) {
await new Promise((resolve) => setTimeout(resolve, 1500));
return {
title: "My Post: " + slug,
date: "2026-06-02",
content:
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt...",
};
}
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getBlogPost(params.slug);
return (
<article className="max-w-2xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-2">{post.title}</h1>
<p className="text-gray-600 mb-8">{post.date}</p>
<div className="prose max-w-none">
<p>{post.content}</p>
</div>
</article>
);
}
Navigate to a post, and you see the post skeleton while it loads, then the full post renders.
Advanced: Suspense for Granular Loading
For more control, use React's Suspense component to wrap individual sections with their own loaders:
// app/dashboard/page.tsx
import { Suspense } from "react";
async function getUserStats() {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { users: 1234, revenue: 45000 };
}
async function getRecentActivity() {
await new Promise((resolve) => setTimeout(resolve, 2000));
return [
{ id: 1, action: "User signup", time: "5 mins ago" },
{ id: 2, action: "Purchase", time: "10 mins ago" },
];
}
function StatsLoader() {
return <div className="h-16 bg-gray-300 animate-pulse rounded"></div>;
}
function ActivityLoader() {
return <div className="space-y-2">{Array.from({ length: 3 }).map(() => <div className="h-12 bg-gray-300 animate-pulse rounded"></div>)}</div>;
}
export default function DashboardPage() {
return (
<div className="max-w-6xl mx-auto p-8 space-y-8">
<h1 className="text-4xl font-bold">Dashboard</h1>
<section>
<h2 className="text-2xl font-bold mb-4">Stats</h2>
<Suspense fallback={<StatsLoader />}>
<Stats />
</Suspense>
</section>
<section>
<h2 className="text-2xl font-bold mb-4">Recent Activity</h2>
<Suspense fallback={<ActivityLoader />}>
<Activity />
</Suspense>
</section>
</div>
);
}
async function Stats() {
const data = await getUserStats();
return (
<div className="grid grid-cols-2 gap-4">
<div className="p-6 border rounded-lg bg-blue-50">
<p className="text-sm text-gray-600">Total Users</p>
<p className="text-3xl font-bold">{data.users}</p>
</div>
<div className="p-6 border rounded-lg bg-green-50">
<p className="text-sm text-gray-600">Revenue</p>
<p className="text-3xl font-bold">${data.revenue}</p>
</div>
</div>
);
}
async function Activity() {
const data = await getRecentActivity();
return (
<ul className="space-y-2">
{data.map((item) => (
<li key={item.id} className="p-4 border rounded-lg">
<p className="font-semibold">{item.action}</p>
<p className="text-sm text-gray-600">{item.time}</p>
</li>
))}
</ul>
);
}
Now the stats section and activity section load independently—whichever finishes first renders first. Users see the interface build incrementally, never waiting for everything at once.
Best Practices
1. Match Skeleton to Content Shape – Make skeletons visually similar to the real content so the transition feels natural.
// Good: skeleton mimics the blog card layout
<div className="p-6 border rounded-lg">
<div className="h-6 bg-gray-300 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 rounded w-full mt-2"></div>
</div>
2. Use Subtly Different Skeleton Sizes – Varying widths and heights makes skeletons less robotic:
<div className="h-4 bg-gray-300 w-5/6 rounded animate-pulse"></div>
<div className="h-4 bg-gray-300 w-full rounded animate-pulse mt-2"></div>
<div className="h-4 bg-gray-300 w-4/5 rounded animate-pulse mt-2"></div>
3. Keep Loaders Fast – Skeletons should render instantly (inline, no data fetching). Real content can take time; the skeleton buys you 1-3 seconds while it loads.
Key Takeaways
loading.tsxdefines a Suspense boundary that shows while pages load, then replaces with real content.- Streaming sends the skeleton UI to the browser immediately, then the data and full page arrive asynchronously.
- Nested loaders let each route level have its own loading state; a post detail loader is different from a blog listing loader.
- Granular Suspense boundaries (via the
Suspensecomponent) allow sections to load independently, improving perceived performance. - Skeleton screens mimic the real content shape, making transitions smoother and less jarring.
Frequently Asked Questions
Does loading.tsx work for API routes?
No. API routes don't use loading.tsx. They return data directly via JSON or streams. Use loading.tsx only for page renders.
Can I use different loaders for different pages in the same folder?
No. One loading.tsx per folder wraps all child page.tsx files. If you need different loaders for different pages, use the Suspense component explicitly in each page.
How long should loaders display?
As long as your data fetch takes. Keep data fetches under 3 seconds; anything longer frustrates users. Use caching and preloading to speed them up.
Can I customize the animation of the skeleton?
Yes. Use any CSS animation, Tailwind classes, or libraries like react-loading-skeleton. The animate-pulse class is just one option.
Does loading.tsx affect SEO?
No. Search engines see the final, fully-rendered page. Loaders are only visible to users in browsers.
Further Reading
- Next.js Loading UI Documentation – official guide to Suspense and streaming.
- React Suspense Reference – deep dive into Suspense mechanics.
- Perceived Performance Principles – why skeletons and loaders matter.