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.

Leave a Comment