Next.js Loading States
Users expect feedback when a page or section of a page is loading. A blank screen with no indication of progress feels broken. Next.js provides a built-in way to show loading indicators that appear instantly while your pages fetch data and render.
The loading.js File
Create a file named loading.js inside any route folder. Next.js shows this file immediately when a user navigates to that route — before the page finishes loading. Once the page is ready, the loading UI is replaced by the actual page content.
// app/blog/loading.js
export default function Loading() {
return (
<div>
<p>Loading blog posts...</p>
</div>
);
}
Place this file in the same folder as your page.js. The layout in that folder stays visible while loading plays. Only the page content area shows the loading state.
Diagram: How loading.js Works
USER CLICKS LINK TO /blog
↓
Next.js shows immediately:
┌─────────────────────────────┐
│ Site Header (layout) │
│ ─────────────────────────── │
│ Loading blog posts... │ ← loading.js (instant)
│ │
│ Site Footer (layout) │
└─────────────────────────────┘
↓ (data fetches in background)
Data ready → loading.js replaced by:
┌─────────────────────────────┐
│ Site Header (layout) │
│ ─────────────────────────── │
│ Post 1: Hello World │ ← page.js content
│ Post 2: Learn Next.js │
│ Site Footer (layout) │
└─────────────────────────────┘
Instant Loading with Suspense Boundaries
Under the hood, loading.js uses React's Suspense feature. Next.js automatically wraps your page with a Suspense boundary when it finds a loading.js file. You can also use Suspense manually for more granular control.
import { Suspense } from 'react';
import PostsList from './PostsList';
import Sidebar from './Sidebar';
export default function BlogPage() {
return (
<div>
<Suspense fallback={<p>Loading sidebar...</p>}>
<Sidebar />
</Suspense>
<Suspense fallback={<p>Loading posts...</p>}>
<PostsList />
</Suspense>
</div>
);
}
The sidebar and posts list load independently. The part that loads first appears first. The user does not wait for both to finish before seeing anything.
Diagram: Independent Loading with Suspense
WITHOUT SUSPENSE (waits for everything): Page blank until BOTH sidebar AND posts load ───────────────────────────────────────── Sidebar: [========= 800ms =========] Posts: [=================== 1200ms ====] User sees content at: 1200ms WITH SUSPENSE (loads independently): ───────────────────────────────────────── Sidebar: [=== 800ms ===] → appears at 800ms Posts: [====== 1200ms =====] → appears at 1200ms User sees partial content at: 800ms
Skeleton Screens
A skeleton screen is a placeholder that mimics the layout of the content that will load. It looks like a greyed-out version of the real page. Users find skeleton screens less jarring than a spinner because the layout does not shift when real content arrives.
// app/blog/loading.js (skeleton version)
export default function Loading() {
return (
<div>
<div style={{ background: '#e0e0e0', height: '32px', width: '60%', marginBottom: '16px' }}></div>
<div style={{ background: '#e0e0e0', height: '16px', width: '100%', marginBottom: '8px' }}></div>
<div style={{ background: '#e0e0e0', height: '16px', width: '90%', marginBottom: '8px' }}></div>
<div style={{ background: '#e0e0e0', height: '16px', width: '80%' }}></div>
</div>
);
}
SKELETON PREVIEW (what user sees while loading): ┌───────────────────────────────────────┐ │ ████████████████████████ │ ← grey box (title) │ ████████████████████████████████████ │ ← grey box (text) │ ████████████████████████████████ │ ← grey box (text) │ ████████████████████████████ │ ← grey box (text) └───────────────────────────────────────┘ REAL CONTENT (after load): ┌───────────────────────────────────────┐ │ How to Build with Next.js │ │ Next.js is a powerful framework for │ │ building modern web applications... │ └───────────────────────────────────────┘
Loading States for Client-Side Fetching
When you fetch data inside a client component, manage the loading state manually with useState.
'use client';
import { useState, useEffect } from 'react';
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading profile...</p>;
return <div>{user.name}</div>;
}
Using useTransition for Smooth Navigation
The useTransition hook from React lets you mark a state update as non-urgent. While the transition runs, you can show a loading indicator without blocking the current UI.
'use client';
import { useState, useTransition } from 'react';
export default function SearchPage() {
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleSearch(query) {
startTransition(async () => {
const data = await searchPosts(query);
setResults(data);
});
}
return (
<div>
<input onChange={e => handleSearch(e.target.value)} />
{isPending && <p>Searching...</p>}
<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
</div>
);
}
Key Takeaway
Create a loading.js file next to your page.js to show an instant loading indicator during navigation. Use manual Suspense boundaries to load different parts of a page independently. Skeleton screens give users a preview of the page structure and reduce the feeling of waiting. For client-side fetches, manage loading state with useState.
