Next.js getServerSideProps: How to Show Loading State During Client-Side Navigation

Next.js has revolutionized React-based web development with its powerful features for server-side rendering (SSR), static site generation (SSG), and client-side navigation. Among these features, getServerSideProps stands out for dynamic, on-demand data fetching—ideal for user-specific content, real-time analytics, or personalized dashboards. However, one common pain point arises during client-side navigation to pages using getServerSideProps: the lack of immediate feedback to users while the server processes the request. Without a loading state, users may perceive delays as unresponsiveness, leading to a poor user experience (UX).

In this blog, we’ll demystify how getServerSideProps works with client-side navigation, why loading states are critical, and provide step-by-step solutions to implement loading indicators (e.g., spinners, skeletons) in your Next.js app. Whether you’re using the classic Pages Router or the modern App Router, we’ve got you covered.

Table of Contents#

  1. Understanding getServerSideProps
  2. Client-Side Navigation in Next.js
  3. The Problem: Missing Loading State
  4. Solutions to Show Loading State
  5. Common Pitfalls to Avoid
  6. Conclusion
  7. References

1. Understanding getServerSideProps#

getServerSideProps is a Next.js function (exclusive to the Pages Router) that fetches data on the server for every incoming request. Unlike getStaticProps (which pre-renders pages at build time), getServerSideProps ensures the page always displays fresh data—making it perfect for:

  • User-specific content (e.g., dashboards, account pages).
  • Real-time data (e.g., stock prices, live metrics).
  • Content that changes frequently (e.g., news feeds).

How It Works:#

When a user requests a page with getServerSideProps:

  1. The server runs getServerSideProps to fetch data.
  2. The data is passed as props to the page component.
  3. The server renders the page HTML with the data and sends it to the client.

Example:#

// pages/dashboard.js (Pages Router)
export async function getServerSideProps(context) {
  // Fetch user-specific data (e.g., from an API or database)
  const userData = await fetch(`https://api.example.com/user/${context.req.cookies.userId}`);
  const data = await userData.json();
 
  // Pass data to the page via props
  return { props: { data } };
}
 
export default function Dashboard({ data }) {
  return (
    <div>
      <h1>Your Dashboard</h1>
      <p>Welcome, {data.username}!</p>
    </div>
  );
}

2. Client-Side Navigation in Next.js#

Next.js optimizes navigation with client-side routing, which avoids full page reloads. Instead of reloading the entire page, Next.js uses:

  • The Link component (from next/link) for declarative navigation.
  • The useRouter hook (from next/router) for programmatic navigation.

Why It’s Great:#

Client-side navigation leverages the browser’s history API to update the URL and render new content without reloading the page, making apps feel faster and more responsive.

// pages/index.js
import Link from 'next/link';
 
export default function Home() {
  return (
    <nav>
      <Link href="/dashboard">
        <a>Go to Dashboard</a>
      </Link>
    </nav>
  );
}

Example with useRouter:#

// pages/profile.js
import { useRouter } from 'next/router';
 
export default function Profile() {
  const router = useRouter();
 
  const goToDashboard = () => {
    router.push('/dashboard'); // Programmatic navigation
  };
 
  return <button onClick={goToDashboard}>Dashboard</button>;
}

3. The Problem: Missing Loading State#

While client-side navigation is fast, pages using getServerSideProps introduce a hidden delay:
When navigating to such a page, the client sends a request to the server, waits for getServerSideProps to fetch data, and then renders the page. By default, there’s no visual feedback during this wait.

Scenario:#

A user clicks a Link to /dashboard (which uses getServerSideProps). If the server takes 2–3 seconds to fetch data, the user sees no change—they might click again, thinking the app is unresponsive. This leads to frustration and poor UX.

4. Solutions to Show Loading State#

Let’s fix this by adding loading states. We’ll cover solutions for both the Pages Router (with getServerSideProps) and the modern App Router.

Solution 1: Track Navigation with Router Events (Pages Router)#

Next.js’s useRouter hook exposes navigation events (e.g., routeChangeStart, routeChangeComplete) that we can use to track when navigation begins and ends. We’ll use these events to toggle a loading state.

Step 1: Create a Custom useLoading Hook#

This hook will listen to router events and return a isLoading state.

// hooks/useLoading.js
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
 
export function useLoading() {
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();
 
  useEffect(() => {
    // Triggered when navigation starts
    const handleRouteChangeStart = () => setIsLoading(true);
 
    // Triggered when navigation completes (successfully or with error)
    const handleRouteChangeComplete = () => setIsLoading(false);
    const handleRouteChangeError = () => setIsLoading(false);
 
    // Listen to router events
    router.events.on('routeChangeStart', handleRouteChangeStart);
    router.events.on('routeChangeComplete', handleRouteChangeComplete);
    router.events.on('routeChangeError', handleRouteChangeError);
 
    // Cleanup: Remove event listeners on unmount
    return () => {
      router.events.off('routeChangeStart', handleRouteChangeStart);
      router.events.off('routeChangeComplete', handleRouteChangeComplete);
      router.events.off('routeChangeError', handleRouteChangeError);
    };
  }, [router.events]);
 
  return isLoading;
}

Step 2: Use the Hook to Show a Loading Indicator#

Add the hook to your root _app.js (or a layout component) to show a global loading spinner during navigation.

// pages/_app.js
import { useLoading } from '../hooks/useLoading';
import Spinner from '../components/Spinner';
 
function MyApp({ Component, pageProps }) {
  const isLoading = useLoading();
 
  return (
    <>
      {/* Show spinner when loading */}
      {isLoading && <Spinner />}
      {/* Page content */}
      <Component {...pageProps} />
    </>
  );
}
 
export default MyApp;

Step 3: Create a Spinner Component#

A simple CSS spinner to indicate loading:

// components/Spinner.js
export default function Spinner() {
  return (
    <div className="spinner-overlay">
      <div className="spinner"></div>
    </div>
  );
}

Add CSS (e.g., in styles/globals.css):

.spinner-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(255, 255, 255, 0.8);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}
 
.spinner {
  width: 50px;
  height: 50px;
  border: 5px solid #f3f3f3;
  border-top: 5px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}
 
@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

Now, when navigating, the spinner will appear until the page loads!

Solution 2: Per-Page Loading States with Path Detection#

For a more polished UX, show path-specific loading states (e.g., a skeleton screen for /dashboard instead of a generic spinner).

Update the useLoading Hook:#

Track the path being navigated to:

// hooks/useLoading.js (updated)
export function useLoading() {
  const [isLoading, setIsLoading] = useState(false);
  const [loadingPath, setLoadingPath] = useState(''); // Track the path
  const router = useRouter();
 
  useEffect(() => {
    const handleRouteChangeStart = (url) => {
      // Extract the pathname from the navigation URL
      const path = new URL(url, window.location.origin).pathname;
      setIsLoading(true);
      setLoadingPath(path); // Set the path being loaded
    };
 
    const handleRouteChangeComplete = () => {
      setIsLoading(false);
      setLoadingPath('');
    };
 
    // ... (same event listeners and cleanup)
 
  }, [router.events]);
 
  return { isLoading, loadingPath };
}

Use Path-Specific Loading States:#

In _app.js, render different loaders based on loadingPath:

// pages/_app.js (updated)
import { useLoading } from '../hooks/useLoading';
import Spinner from '../components/Spinner';
import DashboardSkeleton from '../components/DashboardSkeleton';
 
function MyApp({ Component, pageProps }) {
  const { isLoading, loadingPath } = useLoading();
 
  return (
    <>
      {isLoading && (
        <div className="loading-overlay">
          {/* Show skeleton for /dashboard, else spinner */}
          {loadingPath === '/dashboard' ? <DashboardSkeleton /> : <Spinner />}
        </div>
      )}
      <Component {...pageProps} />
    </>
  );
}

Example DashboardSkeleton:

// components/DashboardSkeleton.js
export default function DashboardSkeleton() {
  return (
    <div className="skeleton-container">
      <div className="skeleton-header"></div>
      <div className="skeleton-card"></div>
      <div className="skeleton-card"></div>
    </div>
  );
}

Solution 3: Modern Alternative: Next.js 13+ App Router (Loading.js)#

If you’re using Next.js 13+ with the App Router (recommended for new projects), loading states are simpler. The App Router uses React Server Components and loading.js files to automatically show loading states during navigation.

How It Works:#

Create a loading.js file in the same directory as your page. Next.js will show this component immediately while the page loads (even for server-side data fetching).

Example:#

// app/dashboard/loading.js (App Router)
export default function Loading() {
  // Skeleton screen for the dashboard
  return (
    <div className="skeleton-container">
      <div className="skeleton-header"></div>
      <div className="skeleton-card"></div>
    </div>
  );
}

No need for router events—Next.js handles the loading state automatically using React Suspense!

5. Common Pitfalls to Avoid#

  • Forgetting Cleanup: Always remove router event listeners in useEffect cleanup to prevent memory leaks.
  • Shallow Routing: Ignore loading states for shallow routing (updating URL without reloading data) using router.events’s shallow flag.
  • Overlapping Loaders: Ensure loading overlays have a high z-index to appear above content.

6. Conclusion#

Loading states are critical for a smooth UX, especially when using getServerSideProps with client-side navigation. For the Pages Router, use router events to track navigation and show spinners or skeletons. For the App Router, leverage loading.js for effortless Suspense-based loading states.

By implementing these solutions, you’ll keep users informed and reduce frustration during data-fetching delays.

7. References#