Next.js Loading State Management: loading.tsx & Suspense Practical Guide

You’ve probably experienced this: a user clicks a link, then the page shows a blank white screen for 3 whole seconds with zero feedback. The user starts wondering: “Is it frozen?” Then frantically hits F5 to refresh, and just as the page finishes loading, it gets refreshed away…
To be honest, I used to do the same thing. Every time I built a new page, I had to add this to the component:
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
setLoading(true);
fetchData()
.then(setData)
.finally(() => setLoading(false));
}, []);
if (loading) return <Spinner />;The code was messy and long, and I had to write it for every single page. Even worse, everyone on the team wrote their loading logic differently - some used global state, some used Context, and some just did their own thing in each component. Maintenance became a nightmare as the project grew.
Then one day, while reading the Next.js official docs, I discovered: Next.js actually has a built-in, more elegant loading management solution - loading.tsx and Suspense.
After using it, I realized managing loading states could be this simple. Not only did the code shrink by half, but the user experience also improved significantly. In this article, I’ll share my practical experience with this approach.
Why Use loading.tsx and Suspense
Pain Points of Traditional Approaches
Let me show you a real example first. Suppose we want to build a blog list page. The traditional approach would look something like this:
// app/blog/page.tsx
'use client';
import { useState, useEffect } from 'react';
export default function BlogPage() {
const [loading, setLoading] = useState(true);
const [posts, setPosts] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/posts')
.then(res => res.json())
.then(data => {
setPosts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) {
return <div className="spinner">Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}Looks okay, right? But the problems are:
- Code redundancy: You have to write this chunk of state management code for every page
- State fragmentation: loading, data, and error are scattered across three separate states, making it easy to run into state synchronization bugs
- Must be Client Component: With useState and useEffect, the entire component can only run on the client, losing the benefits of server-side rendering
- Poor user experience: There’s a noticeable white screen stutter between clicking and seeing the loading state
And if you’ve ever done code reviews, you know different developers handle loading in wildly different ways. Some lift loading state to Context, some use Zustand for global management, and some just write their own logic in each component. When the project scales up, it becomes nearly unmaintainable.
Next.js Solution
Next.js App Router gives us three core features to solve these problems:
1. loading.tsx - Convention over Configuration
You just need to create a loading.tsx file in your route folder, and Next.js will automatically use it as the loading state UI for that route. No manual useState, no state management, and you don’t even need to wrap Suspense yourself.
2. Suspense - React 18 Native Support
React 18’s Suspense lets you precisely control loading states at the component level. Whichever data is slow gets a Suspense boundary, while other parts display normally without waiting for the entire page.
3. Streaming - Load and Display Progressively
Combined with Next.js’s streaming rendering, pages can display piece by piece. The header appears first, then the sidebar, and finally the slower data sections. Users don’t have to stare at a blank screen - the experience is much better.
And here’s an interesting data point: after implementing skeleton screens and Streaming, you can noticeably reduce FCP (First Contentful Paint) and LCP (Largest Contentful Paint) times, improving your Google PageSpeed Insights score by several points.
loading.tsx Basic Usage
Quick Start: Your First loading.tsx
Alright, let’s cut to the chase and write the simplest loading.tsx.
Suppose you have this directory structure:
app/
blog/
page.tsxYou just need to add a loading.tsx file in the blog folder:
app/
blog/
loading.tsx ← newly added
page.tsxThen write a simple loading UI in loading.tsx:
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
<p className="ml-4">Loading...</p>
</div>
);
}That’s it - done in 10 lines of code. Now when users visit /blog, before page.tsx finishes loading, Next.js will automatically display this loading component.
Here’s the key point: You don’t need to manually wrap anything with Suspense - Next.js does it automatically. During actual rendering, it wraps your page.tsx in a <Suspense fallback={<Loading />}>.
When I first saw this, I was quite confused: “This is too simple, does it actually work?” Then I tried it, and it really did. Plus, the code became so much cleaner - no more writing that pile of useState in every page.
loading.tsx Scope
loading.tsx has an important concept called Route Segment. Simply put: it applies to the page.tsx in the same folder and all child routes.
Here’s an example:
app/
blog/
loading.tsx ← applies to /blog and /blog/[id]
page.tsx ← /blog list page
[id]/
page.tsx ← /blog/123 detail pageThis loading.tsx will display in the following situations:
- User visits
/blog(while list page loads) - User clicks from list into
/blog/123(while detail page loads)
But! It won’t affect the layout. If your blog/layout.tsx has a navigation bar, that nav bar will stay visible the whole time - only the page.tsx part gets replaced by the loading state.
This is what the Next.js official docs mean by “shared layouts remain interactive”. While waiting for the new page to load, users can still click the nav bar to switch to other pages - the entire interface doesn’t freeze up.
Here’s a visualization that makes it clearer:
Layout (always visible)
├─ Navigation Bar
└─ Suspense Boundary
├─ Loading UI (shown while data loads)
└─ Page (shown after data loads)Server Component vs Client Component
loading.tsx is a Server Component by default. In most cases, this is sufficient - you just return some JSX.
But sometimes you want to add animation effects, like using Framer Motion for a fade-in/fade-out, or using libraries that require client-side JavaScript. Then you need to add 'use client':
// app/blog/loading.tsx
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center min-h-screen"
>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
</motion.div>
);
}My general rule is: use Server Component whenever possible, only add 'use client' when you really need client-side interaction. After all, Server Components don’t get bundled into the client bundle, making page loads faster.
Skeleton Screen Practices
Why Skeleton Screens Beat Spinners
You’ve definitely seen those spinning loading spinners, right? Actually, from a user experience perspective, skeleton screens are far better than spinners.
Why? There’s a user psychology study that found: when users see a skeleton screen, their brain automatically expects “content is coming soon,” making the perceived wait time shorter. When they see a spinner, users only know “it’s loading,” but don’t know what’s loading or how long they’ll wait, creating more anxiety.
Plus, skeleton screens have another benefit: they can tell users roughly what the page layout will look like. For example, when users see three horizontal bars in the skeleton, they know there will be three articles. Having that expectation reduces panic.
Three Implementation Approaches
There are many ways to implement skeleton screens. I’ll introduce the three most common ones, and you can choose based on your project needs:
Approach 1: Pure CSS (Most Lightweight)
If your project doesn’t want extra dependencies, pure CSS can handle it:
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8 animate-pulse">
{/* Title skeleton */}
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
{/* Excerpt skeleton */}
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
{/* Metadata skeleton */}
<div className="flex gap-4 mt-4">
<div className="h-3 bg-gray-200 rounded w-20"></div>
<div className="h-3 bg-gray-200 rounded w-24"></div>
</div>
</div>
))}
</div>
);
}The advantage is zero dependencies and best performance. The downside is you have to write the styles yourself, which is slightly more work.
Approach 2: react-loading-skeleton Library (Fastest)
If you want to get it done quickly without writing too many styles, use react-loading-skeleton:
npm install react-loading-skeleton// app/blog/loading.tsx
'use client';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8">
<Skeleton height={32} width="75%" className="mb-4" />
<Skeleton count={2} />
<div className="flex gap-4 mt-4">
<Skeleton width={80} />
<Skeleton width={100} />
</div>
</div>
))}
</div>
);
}This library is very convenient to use, and the animation effects are well done. I often use this for small projects.
Approach 3: shadcn/ui (Most Professional)
If your project is already using shadcn/ui, just use its Skeleton component directly:
npx shadcn-ui@latest add skeleton// app/blog/loading.tsx
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8">
<Skeleton className="h-8 w-3/4 mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-5/6 mb-4" />
<div className="flex gap-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
);
}The benefit here is the styles are completely consistent with your design system, no extra styling adjustments needed.
Skeleton Screen Design Principles
Regardless of which approach you use, keep these design principles in mind:
Match the actual layout: The skeleton structure should match your real content layout. If article lists have title, excerpt, and tags, then the skeleton should have corresponding three sections.
Subtle animation: Don’t make animations too flashy - a gentle shimmer is enough. Too fancy animations distract users and actually make the wait feel longer.
Reasonable quantity: Generally showing 3-5 skeleton items is enough, don’t need to fill the entire screen. Too many feels cumbersome.
Real Example: Complete Blog List Page Implementation
Alright, now let’s tie together the previous knowledge points and build a complete blog list page.
First, the loading.tsx:
// app/blog/loading.tsx
export default function BlogLoading() {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="h-12 bg-gray-200 rounded w-1/3 mb-8 animate-pulse"></div>
<div className="space-y-8">
{[1, 2, 3].map((i) => (
<article key={i} className="border-b pb-8 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-3"></div>
<div className="space-y-2 mb-4">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-11/12"></div>
<div className="h-4 bg-gray-200 rounded w-4/5"></div>
</div>
<div className="flex gap-3">
<div className="h-6 bg-gray-200 rounded-full w-16"></div>
<div className="h-6 bg-gray-200 rounded-full w-20"></div>
</div>
</article>
))}
</div>
</div>
);
}Then the actual page.tsx (using Server Component):
// app/blog/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store' // ensure fresh data every time
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Blog Posts</h1>
<div className="space-y-8">
{posts.map((post) => (
<article key={post.id} className="border-b pb-8">
<h2 className="text-2xl font-semibold mb-3">
<a href={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</a>
</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex gap-3">
{post.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
{tag}
</span>
))}
</div>
</article>
))}
</div>
</div>
);
}See that? page.tsx becomes an async function that directly awaits data inside the component. No useState, no useEffect - the code is so much cleaner.
And because it’s a Server Component, all this code runs on the server and doesn’t increase client bundle size. Faster initial page load.
Debugging Tip: Test with React DevTools
During development, you might want to test the loading effect, but data loads too fast and the loading state flashes by before you can see it clearly.
Here’s a trick: use React DevTools to manually toggle Suspense boundaries.
- Install the React DevTools browser extension
- Open developer tools, switch to the Components tab
- Find the
<Suspense>component - Right-click and select “Suspend this Suspense boundary”
This way the loading UI will stay visible, and you can style it at your leisure. When done, just unsuspend.
To be honest, I discovered this feature only after hitting several roadblocks. If I’d known about it earlier, would’ve saved so much time.
Advanced Suspense Techniques
Manually Setting Suspense Boundaries
loading.tsx is convenient, but sometimes you need finer control. For example, when a page has multiple independent data sources, you want them to display loading separately instead of waiting for all data before showing everything.
That’s when you need to manually set Suspense boundaries.
First, a common mistake: many people (including me initially) put Suspense inside the data-fetching component, like this:
// ❌ Wrong example - Suspense placed too low
async function PostList() {
const posts = await fetchPosts();
return (
<Suspense fallback={<Loading />}> {/* This doesn't work! */}
<div>
{posts.map(post => <Post key={post.id} {...post} />)}
</div>
</Suspense>
);
}This won’t work. Why? Because Suspense needs to be higher up in the component tree to “catch” async operations from components below.
The correct approach is to put Suspense in the parent component:
// ✅ Correct example - Suspense in parent component
export default function BlogPage() {
return (
<div>
<h1>Blog Posts</h1>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
);
}
// Child component does data fetching
async function PostList() {
const posts = await fetchPosts();
return (
<div>
{posts.map(post => <Post key={post.id} {...post} />)}
</div>
);
}You can think of Suspense as a gate. It stands at a certain position in the component tree, monitoring all async operations below. As long as any component below is waiting for data, the gate closes and shows the fallback. Once all data arrives, the gate opens and shows the real content.
Special Handling for Dynamic Routes
I fell deep into this trap, so I must emphasize it.
Suppose you have a product detail page /products/[id], and a user switches from Product A (id=1) to Product B (id=2). You’ll find: loading.tsx doesn’t show anymore!
The page content switches directly from Product A to Product B with no loading transition in between - very jarring experience.
This is because React has an optimization mechanism: if the component type is the same (both ProductPage), it reuses that component instance and just updates props. So Suspense thinks “the component hasn’t changed, no need to re-suspend.”
Solution: Add a key prop to Suspense, telling React “this is a new component, re-render it.”
// app/products/[id]/page.tsx
import { Suspense } from 'react';
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<Suspense key={params.id} fallback={<ProductSkeleton />}>
<ProductDetail id={params.id} />
</Suspense>
);
}
async function ProductDetail({ id }: { id: string }) {
const product = await fetchProduct(id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
);
}Notice this line: <Suspense key={params.id} ...>
Now when id changes, React will destroy the old Suspense instance and create a new one. The new instance will re-enter the suspend state, and the loading UI will display normally.
I was stuck on this problem for half a day. Finally saw someone mention the key trick in a GitHub issue, tried it and it worked immediately. Sometimes solutions are that simple, but you just don’t know until you know.
Coordinating Multiple Loading States
Finally, a slightly more complex scenario: a page loading multiple data sources simultaneously.
For example, a dashboard page with user info, statistics, and recent activity sections, each calling a different API. You have two strategies:
Strategy 1: Wait for all to load (one Suspense wraps everything)
export default function Dashboard() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<UserInfo /> {/* API call 1 */}
<Statistics /> {/* API call 2 */}
<RecentActivity /> {/* API call 3 */}
</Suspense>
);
}Pros: Simple implementation, shows complete content at once
Cons: Slowest API drags everything down, user wait time = slowest API time
Strategy 2: Progressive display (multiple Suspense boundaries)
export default function Dashboard() {
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<Statistics />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}Pros: Whichever section is ready shows first, shorter perceived wait time
Cons: Page “jumps” as layout shifts with loading content, sometimes jarring
My personal choice is: depends on data importance.
- Core data (like user info) uses one Suspense, ensures display together
- Secondary data (like recommendations, ads) gets separate Suspense, loads asynchronously
This balances core experience while not making users wait for all data.
Common Problems and Solutions
When Suspense Doesn’t Work
If you find Suspense not working, check these points:
1. Is the data fetching method correct?
Suspense only works with “Suspense-compatible data fetching methods.” In Next.js App Router, this means:
- ✅ Direct await in Server Component (recommended)
- ✅ Libraries that support Suspense (like SWR, React Query)
- ❌ fetch in useEffect (not supported)
- ❌ Traditional Promise.then (not supported)
2. Is the component position correct?
Suspense must be placed above the component that fetches data, not at the same level or below.
3. Version compatibility
Make sure you’re using:
- React 18+
- Next.js 13+ (App Router)
4. Debugging method
Use React DevTools to manually toggle Suspense boundaries. If manual toggle doesn’t respond either, Suspense isn’t working at all - check the previous points.
The useFormStatus Hook Gotcha
If you’re using Server Actions for form submission, you might use the useFormStatus hook to show submission state.
There’s a gotcha here: useFormStatus only works in Client Components.
But! The form itself must be rendered in a Server Component, otherwise Server Actions can’t bind.
So the correct approach is: Server Component renders form, Client Component shows state.
// app/actions.ts
'use server';
export async function submitForm(formData: FormData) {
// Handle form...
await saveToDatabase(formData);
}// app/page.tsx (Server Component)
import { submitForm } from './actions';
import { SubmitButton } from './submit-button';
export default function Page() {
return (
<form action={submitForm}>
<input name="email" type="email" />
<SubmitButton />
</form>
);
}// app/submit-button.tsx (Client Component)
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}Notice the form is in Server Component, button is in Client Component. This way both Server Action and loading state work correctly.
Impact of Prefetch on Loading
Next.js’s <Link> component prefetches linked pages by default (when the link appears in the viewport).
This causes sometimes when you click a link, loading flashes by or doesn’t show at all. That’s because data was already prefetched, no loading needed.
If you want to test the loading effect, you can temporarily disable prefetch:
<Link href="/blog" prefetch={false}>
Blog
</Link>But in production, keep prefetch enabled for better user experience. If worried about loading displaying too briefly for users to notice, you can add a minimum display time (like 300ms) to the loading, or use skeleton screens instead of spinners.
Summary
Alright, let’s quickly recap the core points:
loading.tsx is the best practice for route-level loading: Just put the file in the route folder, Next.js handles everything automatically. Say goodbye to manual useState, code becomes half as long.
Skeleton screens beat spinners for experience: Shows layout ahead of time, reduces user anxiety. Use pure CSS, react-loading-skeleton, or UI libraries - depends on project needs.
Suspense must be placed higher in component tree: It’s a gate, monitoring all async operations below. Wrong placement means it won’t work.
Remember to add key for dynamic routes: Otherwise loading won’t show when switching IDs. Don’t forget this line:
<Suspense key={params.id}>.Split Suspense by data source as needed: Core data displays together, secondary data loads asynchronously, balancing experience and performance.
Honestly, going from manual useState to loading.tsx isn’t more work - it’s working smarter. Less code, fewer bugs, better user experience - why not?
Next Steps
If you want to try this right now, I suggest:
Take action immediately: Find an existing project, pick a simple list page, and convert the loading to loading.tsx. Trying it once yourself is more useful than reading ten articles.
Advanced learning: After mastering loading, next step is studying Error Boundaries. They go hand in hand - one manages loading states, one manages error states. I’ll write a practical Error Boundaries article later, and we’ll continue the discussion then.
Share your experience: How do you handle loading in your projects? What solutions have you used? What pitfalls have you hit? Feel free to share in the comments, let’s learn together.
References:
- Next.js Official Docs - loading.js
- Next.js Official Docs - Loading UI and Streaming
- React Official Docs - Suspense
FAQ
What's the difference between loading.tsx and manual useState?
Manual useState requires writing it for every page, code repetition and error-prone.
loading.tsx reduces code by 50%, better UX, supports streaming rendering.
When does loading.tsx show?
1) When user navigates to that route
2) When dynamic route parameters change
3) When parent route loads
Next.js automatically manages display timing, no manual control needed. When page data finishes loading, loading.tsx automatically hides.
What's the difference between Suspense and loading.tsx?
Suspense is component-level, can wrap specific async components for more granular loading control.
Can use both: route uses loading.tsx, components use Suspense.
How do I implement skeleton screens?
Use Tailwind's animate-pulse class for animation effects.
Keep skeleton screen layout consistent with actual content, so there's no layout shift after loading completes.
How do I handle loading for dynamic routes?
Example: app/products/[id]/loading.tsx
When user navigates from /products/1 to /products/2, Next.js automatically shows loading.tsx, no manual parameter change management needed.
Can I customize loading styles?
Can use Tailwind CSS, CSS Modules, styled-components, or any styling solution.
Recommendation: Use skeleton screens instead of simple Spinner for better UX.
Does loading.tsx affect performance?
Fast parts display first, slow parts display later, overall experience is better.
13 min read · Published on: Jan 5, 2026 · Modified on: Jan 22, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation

Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload

Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup


Comments
Sign in with GitHub to leave a comment