Next.js getServerSideProps Loading State: How to Implement Client-Side Like Skeletons (e.g., react-loading-skeleton)
When building Next.js applications, getServerSideProps is a powerful tool for server-side rendering (SSR), enabling dynamic data fetching on each request. However, this server-side data fetching introduces a challenge: delayed client-side feedback. Unlike client-side fetching (e.g., with useEffect), where you can immediately show a spinner or loading state, getServerSideProps runs on the server before sending the page to the client. This can leave users staring at a blank screen or unresponsive interface while waiting for the server to respond—hurting user experience (UX).
The solution? Skeleton loaders. Skeletons are lightweight UI placeholders that mimic the structure of your content, giving users a preview of what’s to come. They reduce perceived load time and make the app feel more responsive compared to generic spinners. In this guide, we’ll walk through implementing client-side-like skeleton loading states for pages using getServerSideProps, with a focus on the popular react-loading-skeleton library.
Table of Contents#
- Understanding
getServerSidePropsand Loading States - Why Skeletons Over Spinners?
- Setting Up the Project
- Implementing Skeleton Loading States: Step-by-Step
- Advanced: Conditional Skeleton Rendering
- Styling Skeletons to Match Your UI
- Testing the Loading State
- Common Pitfalls and Solutions
- Conclusion
- References
Understanding getServerSideProps and Loading States#
getServerSideProps is a Next.js function that runs on the server for every incoming request to a page. It fetches data, passes it as props to the page component, and the server renders the page with this data before sending the HTML to the client. This ensures the client receives fully populated content, which is great for SEO and initial load performance.
However, there’s a catch for client-side navigation (e.g., clicking a link using next/link):
When navigating between pages client-side, Next.js triggers getServerSideProps on the server again. The client must wait for the server to fetch data and send the new page HTML. During this wait, the client has no built-in way to signal "loading"—leading to a blank or unresponsive screen.
In contrast, client-side fetching (e.g., useEffect + fetch) lets you show a spinner immediately because the loading state is controlled entirely on the client. With getServerSideProps, we need a way to bridge this gap and provide similar feedback.
Why Skeletons Over Spinners?#
Skeletons are far more effective than generic spinners for improving UX:
- Contextual Preview: Skeletons mimic the layout of your content (e.g., post titles, images, buttons), giving users a sense of what to expect.
- Reduced Perceived Load Time: Research shows skeletons make load times feel shorter by keeping users engaged with a preview of the final UI.
- Alignment with Web Vitals: They minimize Cumulative Layout Shift (CLS) by reserving space for content, unlike spinners that disappear and cause layout jumps.
Spinners, while simple, are generic and don’t provide context—leaving users uncertain about what’s loading or how long it will take.
Setting Up the Project#
Let’s start by setting up a Next.js project (if you don’t have one) and installing dependencies.
Prerequisites#
- Node.js 14+ installed.
Step 1: Create a Next.js Project#
npx create-next-app@latest nextjs-skeleton-demo
cd nextjs-skeleton-demoStep 2: Install react-loading-skeleton#
We’ll use react-loading-skeleton, a lightweight library for creating accessible, customizable skeletons:
npm install react-loading-skeleton
# Or with Yarn:
yarn add react-loading-skeletonImportant: Import the skeleton CSS in your _app.js to style the skeletons:
// pages/_app.js
import 'react-loading-skeleton/dist/skeleton.css';
import '../styles/globals.css';
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}
export default MyApp;Implementing Skeleton Loading States#
We’ll build a "Blog Posts" page that uses getServerSideProps to fetch data and shows skeletons during client-side navigation. Here’s the step-by-step workflow:
Step 1: Create a Reusable Skeleton Component#
First, build a skeleton component to mimic a blog post. This will be reused during loading.
Create components/PostSkeleton.js:
// components/PostSkeleton.js
import { Skeleton } from 'react-loading-skeleton';
// Renders a skeleton for a single blog post
export default function PostSkeleton() {
return (
<div className="post-skeleton" style={{ margin: '20px 0', padding: '20px', border: '1px solid #eaeaea', borderRadius: '8px' }}>
{/* Title skeleton: wide, taller than body text */}
<Skeleton height={40} width="80%" style={{ marginBottom: '16px' }} />
{/* Excerpt skeletons: 3 lines of body text */}
<Skeleton height={20} count={3} style={{ marginBottom: '8px' }} />
{/* Author/date skeleton: shorter and narrower */}
<Skeleton height={16} width="40%" />
</div>
);
}Key Props Used:
height/width: Define the skeleton’s size to match your real content.count: Renders multiple skeletons (e.g.,count={3}for 3 lines of text).
Step 2: Fetch Data with getServerSideProps#
Create a page that fetches blog posts using getServerSideProps. We’ll use JSONPlaceholder as a mock API.
Create pages/posts.js:
// pages/posts.js
import Link from 'next/link';
export default function PostsPage({ posts }) {
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
<h1 style={{ fontSize: '2rem', marginBottom: '30px' }}>Latest Blog Posts</h1>
{/* Link back to homepage for testing navigation */}
<Link href="/">
<a style={{ color: '#0070f3', textDecoration: 'none', marginBottom: '20px', display: 'inline-block' }}>
← Back to Home
</a>
</Link>
{/* Render posts once loaded */}
<div className="posts">
{posts.map((post) => (
<div key={post.id} style={{ margin: '20px 0', padding: '20px', border: '1px solid #eaeaea', borderRadius: '8px' }}>
<h2 style={{ fontSize: '1.5rem', marginBottom: '10px' }}>{post.title}</h2>
<p style={{ color: '#666', marginBottom: '15px' }}>{post.body.substring(0, 100)}...</p>
<div style={{ fontSize: '0.9rem', color: '#888' }}>
By Author {post.userId} • {new Date().toLocaleDateString()}
</div>
</div>
))}
</div>
</div>
);
}
// Server-side data fetching
export async function getServerSideProps() {
// Simulate a slow API call (remove in production!)
await new Promise((resolve) => setTimeout(resolve, 1500));
// Fetch posts from JSONPlaceholder
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
const posts = await res.json();
return { props: { posts: posts.slice(0, 5) } }; // Return first 5 posts
}This page fetches 5 mock blog posts and renders them. We added a 1.5-second delay in getServerSideProps to simulate a slow network (for testing skeletons).
Step 3: Track Client-Side Navigation Loading State#
To show skeletons during client-side navigation, we need to detect when a route change starts and ends. We’ll create a custom hook to track this using Next.js Router events.
Create hooks/useLoading.js:
// hooks/useLoading.js
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
export default function useLoading() {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
useEffect(() => {
// Track route change start
const handleRouteStart = () => setIsLoading(true);
// Track route change completion (success or error)
const handleRouteComplete = () => setIsLoading(false);
// Add event listeners
router.events.on('routeChangeStart', handleRouteStart);
router.events.on('routeChangeComplete', handleRouteComplete);
router.events.on('routeChangeError', handleRouteComplete); // Handle errors
// Cleanup listeners on unmount
return () => {
router.events.off('routeChangeStart', handleRouteStart);
router.events.off('routeChangeComplete', handleRouteComplete);
router.events.off('routeChangeError', handleRouteComplete);
};
}, [router.events]); // Re-run effect if router.events changes
return isLoading;
}How It Works:
routeChangeStart: Triggered when navigation begins (e.g., clicking a link).routeChangeComplete: Triggered when navigation finishes successfully.routeChangeError: Triggered if navigation fails (e.g., network error).
The hook returns isLoading, a boolean we’ll use to show/hide skeletons.
Step 4: Render Skeletons During Loading#
Update pages/posts.js to use useLoading and render PostSkeleton when loading:
// pages/posts.js
import { useRouter } from 'next/router';
import Link from 'next/link';
import useLoading from '../hooks/useLoading';
import PostSkeleton from '../components/PostSkeleton'; // Import the skeleton
export default function PostsPage({ posts }) {
const isLoading = useLoading(); // Track loading state
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
<h1 style={{ fontSize: '2rem', marginBottom: '30px' }}>Latest Blog Posts</h1>
<Link href="/">
<a style={{ color: '#0070f3', textDecoration: 'none', marginBottom: '20px', display: 'inline-block' }}>
← Back to Home
</a>
</Link>
{/* Show skeletons if loading; else show real posts */}
{isLoading ? (
// Render 5 skeletons (match the number of real posts)
Array.from({ length: 5 }).map((_, index) => <PostSkeleton key={index} />)
) : (
<div className="posts">
{posts.map((post) => (
<div key={post.id} style={{ margin: '20px 0', padding: '20px', border: '1px solid #eaeaea', borderRadius: '8px' }}>
<h2 style={{ fontSize: '1.5rem', marginBottom: '10px' }}>{post.title}</h2>
<p style={{ color: '#666', marginBottom: '15px' }}>{post.body.substring(0, 100)}...</p>
<div style={{ fontSize: '0.9rem', color: '#888' }}>
By Author {post.userId} • {new Date().toLocaleDateString()}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ... (getServerSideProps remains the same)Key Changes:
- We import
useLoadingandPostSkeleton. isLoadingfromuseLoadingdetermines whether to render skeletons or real posts.Array.from({ length: 5 })renders 5 skeletons (matching the 5 real posts to avoid layout shifts).
Step 5: Test the Flow#
- Start the dev server:
npm run dev - Visit
http://localhost:3000. - Create a simple homepage (
pages/index.js) with a link to/posts:// pages/index.js import Link from 'next/link'; export default function Home() { return ( <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}> <h1>Next.js Skeleton Demo</h1> <Link href="/posts"> <a style={{ display: 'inline-block', padding: '10px 20px', backgroundColor: '#0070f3', color: 'white', textDecoration: 'none', borderRadius: '4px', marginTop: '20px' }}> View Blog Posts </a> </Link> </div> ); } - Click "View Blog Posts"—you’ll see skeletons for 1.5 seconds (thanks to our simulated delay), then the real posts!
Advanced: Conditional Skeleton Rendering#
For more control, render skeletons conditionally based on content type. For example, a "Product" skeleton vs. a "Blog Post" skeleton.
Example: Reusable Skeleton Container#
Create components/SkeletonContainer.js to wrap skeletons for different content types:
// components/SkeletonContainer.js
import { Skeleton } from 'react-loading-skeleton';
export default function SkeletonContainer({ type }) {
switch (type) {
case 'post':
return (
<div className="post-skeleton" style={{ margin: '20px 0', padding: '20px', border: '1px solid #eaeaea', borderRadius: '8px' }}>
<Skeleton height={40} width="80%" style={{ marginBottom: '16px' }} />
<Skeleton height={20} count={3} style={{ marginBottom: '8px' }} />
<Skeleton height={16} width="40%" />
</div>
);
case 'product':
return (
<div className="product-skeleton" style={{ margin: '20px', padding: '16px', border: '1px solid #eaeaea', borderRadius: '8px', maxWidth: '200px' }}>
<Skeleton height={150} width="100%" style={{ marginBottom: '16px' }} />
<Skeleton height={30} width="90%" style={{ marginBottom: '8px' }} />
<Skeleton height={24} width="60%" style={{ marginBottom: '8px' }} />
<Skeleton height={36} width="100%" />
</div>
);
default:
return <Skeleton height={100} width="100%" />;
}
}Use it in a page:
{isLoading ? (
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{Array.from({ length: 3 }).map((_, index) => (
<SkeletonContainer key={index} type="product" />
))}
</div>
) : (
// Render real products
)}Styling Skeletons to Match Your UI#
Customize skeletons to blend with your app’s design using react-loading-skeleton props:
| Prop | Purpose | Example |
|---|---|---|
color | Background color of the skeleton | color="#f0f0f0" |
highlightColor | Animation highlight color | highlightColor="#e0e0e0" |
borderRadius | Rounded corners | borderRadius="8px" |
duration | Animation speed (in seconds) | duration={1.5} |
width/height | Exact dimensions (e.g., 100% or 200px) | width="50%" height={24} |
Example: Custom Styled Skeleton
<Skeleton
height={40}
width="80%"
color="#f5f5f5"
highlightColor="#e5e5e5"
borderRadius="4px"
duration={1}
style={{ marginBottom: '16px' }}
/>Testing the Loading State#
To ensure skeletons work reliably:
- Slow Network Simulation: Use Chrome DevTools > Network > Throttle > "Slow 3G" to test low-bandwidth scenarios.
- Error Handling: Simulate an API error in
getServerSideProps(e.g., throw an error) and verify the skeleton hides (thanks torouteChangeErrorinuseLoading). - CLS Check: Use Lighthouse or DevTools’ "Performance" tab to ensure skeletons don’t cause layout shifts.
Common Pitfalls and Solutions#
| Pitfall | Solution |
|---|---|
| Skeleton flashes (too fast load) | Add a minimum delay (e.g., setTimeout with 300ms) to avoid flickering. |
| Layout shifts (CLS) | Match skeleton dimensions exactly to real content. |
| Skeleton stuck on error | Always handle routeChangeError to reset isLoading. |
| Overly complex skeletons | Keep skeletons simple—mimic structure, not every detail. |
Conclusion#
Skeletons drastically improve UX for pages using getServerSideProps by providing contextual loading feedback during client-side navigation. By combining react-loading-skeleton with Next.js Router events, you can create a seamless experience that rivals client-side fetching.
Key takeaways:
- Use
useLoadingto track route changes and trigger skeletons. - Match skeleton dimensions to real content to avoid layout shifts.
- Customize skeletons to blend with your UI for a polished look.