The MERN Blog will eventually have hundreds or thousands of posts. Loading them all at once would be slow and wasteful. Pagination is the solution โ loading a small set of posts (a page) at a time and letting the user navigate between pages. In this lesson you will build two pagination approaches: traditional page-based pagination with page numbers and Previous/Next buttons, and an infinite scroll implementation that automatically loads the next page as the user scrolls toward the bottom โ the pattern used by Twitter, Instagram, and most modern content feeds.
Traditional Pagination โ Page-Based
// src/pages/BlogPage.jsx โ page-based pagination
import { useState, useEffect } from 'react';
import postService from '@/services/postService';
import PostList from '@/components/posts/PostList';
import Pagination from '@/components/ui/Pagination';
const POSTS_PER_PAGE = 10;
function BlogPage() {
const [posts, setPosts] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const totalPages = Math.ceil(total / POSTS_PER_PAGE);
useEffect(() => {
const controller = new AbortController();
const fetchPage = async () => {
try {
setLoading(true);
setError(null);
const res = await postService.getAll({
page,
limit: POSTS_PER_PAGE,
published: true,
});
setPosts(res.data.data);
setTotal(res.data.total);
// Scroll to top when page changes
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (err) {
if (!err.name?.includes('Cancel')) setError(err.response?.data?.message || 'Failed to load posts');
} finally {
setLoading(false);
}
};
fetchPage();
return () => controller.abort();
}, [page]);
return (
<div className="blog-page">
<PostList posts={posts} loading={loading} error={error} />
{!loading && !error && (
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
)}
</div>
);
}
export default BlogPage;
setPosts(res.data.data), not setPosts(prev => [...prev, ...res.data.data]).const [searchParams, setSearchParams] = useSearchParams() and read/write page from it. This lets users bookmark page 5 of results and share the URL โ when they return, the page opens at the same position. Without URL sync, navigating away and back resets to page 1.The Pagination Component
// src/components/ui/Pagination.jsx
import PropTypes from 'prop-types';
function Pagination({ page, totalPages, onPageChange }) {
if (totalPages <= 1) return null; // no controls if there is only one page
// Build page number array: [1, ..., 4, 5, 6, ..., 20]
const getPageNumbers = () => {
const pages = [];
const showEllipsis = totalPages > 7;
if (!showEllipsis) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (page > 3) pages.push('...');
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) pages.push(i);
if (page < totalPages - 2) pages.push('...');
pages.push(totalPages);
}
return pages;
};
return (
<nav className="pagination" aria-label="Page navigation">
<button
className="pagination__btn"
onClick={() => onPageChange(page - 1)}
disabled={page === 1}
aria-label="Previous page"
>
โ Previous
</button>
<div className="pagination__pages">
{getPageNumbers().map((num, idx) =>
num === '...'
? <span key={`ellipsis-${idx}`} className="pagination__ellipsis">โฆ</span>
: <button
key={num}
className={`pagination__page${num === page ? ' pagination__page--active' : ''}`}
onClick={() => onPageChange(num)}
aria-current={num === page ? 'page' : undefined}
>
{num}
</button>
)}
</div>
<button
className="pagination__btn"
onClick={() => onPageChange(page + 1)}
disabled={page === totalPages}
aria-label="Next page"
>
Next โ
</button>
</nav>
);
}
Pagination.propTypes = {
page: PropTypes.number.isRequired,
totalPages: PropTypes.number.isRequired,
onPageChange: PropTypes.func.isRequired,
};
export default Pagination;
Infinite Scroll with Intersection Observer
// src/pages/FeedPage.jsx โ infinite scroll
import { useState, useEffect, useRef, useCallback } from 'react';
import postService from '@/services/postService';
import PostCard from '@/components/posts/PostCard';
import Spinner from '@/components/ui/Spinner';
const LIMIT = 10;
function FeedPage() {
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Ref to the sentinel element at the bottom of the list
const sentinelRef = useRef(null);
const fetchPage = useCallback(async (pageNum) => {
if (loading || !hasMore) return;
try {
setLoading(true);
setError(null);
const res = await postService.getAll({ page: pageNum, limit: LIMIT, published: true });
const newPosts = res.data.data;
// APPEND new posts to existing list (unlike page-based which replaces)
setPosts(prev => pageNum === 1 ? newPosts : [...prev, ...newPosts]);
setHasMore(newPosts.length === LIMIT && res.data.total > pageNum * LIMIT);
} catch (err) {
setError(err.response?.data?.message || 'Failed to load posts');
} finally {
setLoading(false);
}
}, [loading, hasMore]);
// Initial load
useEffect(() => {
fetchPage(1);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Intersection Observer โ triggers when sentinel enters the viewport
useEffect(() => {
if (!sentinelRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore && !loading) {
setPage(prev => {
const nextPage = prev + 1;
fetchPage(nextPage);
return nextPage;
});
}
},
{ threshold: 0.1 } // trigger when 10% of sentinel is visible
);
observer.observe(sentinelRef.current);
return () => observer.disconnect(); // cleanup
}, [hasMore, loading, fetchPage]);
return (
<div className="feed-page">
<h1>Blog Feed</h1>
<div className="post-feed">
{posts.map(post => <PostCard key={post._id} post={post} />)}
</div>
{/* Sentinel โ invisible element that triggers loading when scrolled into view */}
<div ref={sentinelRef} className="feed-sentinel" aria-hidden />
{loading && <Spinner message="Loading more posts..." />}
{error && <p className="error">{error}</p>}
{!hasMore && posts.length > 0 && (
<p className="feed-end">You have reached the end.</p>
)}
</div>
);
}
export default FeedPage;
Comparing the Two Approaches
| Page-Based Pagination | Infinite Scroll | |
|---|---|---|
| How data loads | Replaces current page | Appends to existing list |
| User navigation | Explicit โ click page numbers | Automatic โ scroll to load |
| URL bookmarkable | Yes โ sync page to URL | No โ position not preserved in URL |
| Back button behaviour | Returns to same page | Returns to top (position lost) |
| Best for | Search results, product lists, dashboards | Social feeds, news, image galleries |
| MERN Blog use | Blog archive, search results | Home feed, tag filtered feed |
Common Mistakes
Mistake 1 โ Appending posts on page-based pagination
โ Wrong โ page 2 shows posts from page 1 AND page 2:
setPosts(prev => [...prev, ...res.data.data]); // in page-based pagination
โ Correct โ replace, not append, for page-based:
setPosts(res.data.data); // โ shows only the current page's posts
Mistake 2 โ Not disconnecting the Intersection Observer on cleanup
โ Wrong โ observer continues watching even after component unmounts:
useEffect(() => {
const observer = new IntersectionObserver(callback);
observer.observe(sentinelRef.current);
// No cleanup โ observer fires after unmount โ state update on dead component
}, []);
โ Correct โ always disconnect in cleanup:
return () => observer.disconnect(); // โ
Mistake 3 โ Not handling the “no more data” state
โ Wrong โ infinite scroll keeps triggering even after all posts are loaded:
// setHasMore never set to false โ makes empty API calls forever
โ Correct โ set hasMore false when the last page is detected:
setHasMore(newPosts.length === LIMIT && res.data.total > page * LIMIT); // โ
Quick Reference
| Task | Code |
|---|---|
| Page-based state | const [page, setPage] = useState(1) |
| Total pages | Math.ceil(total / LIMIT) |
| Replace posts on page change | setPosts(res.data.data) |
| Append posts on scroll | setPosts(prev => [...prev, ...res.data.data]) |
| Has more sentinel | setHasMore(newPosts.length === LIMIT) |
| Intersection Observer | new IntersectionObserver(cb, { threshold: 0.1 }) |
| Observe element | observer.observe(ref.current) |
| Cleanup observer | return () => observer.disconnect() |