Pagination and Infinite Scroll with the Express API

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;
Note: With page-based pagination, replace the posts on each page change โ€” do not append. When the user goes to page 2, they want to see posts 11โ€“20, not posts 1โ€“20. Only infinite scroll appends. Page-based pagination always replaces setPosts(res.data.data), not setPosts(prev => [...prev, ...res.data.data]).
Tip: Sync the current page to the URL query string: 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.
Warning: Infinite scroll and the Intersection Observer API both require cleanup. If the user navigates away while a fetch is in progress, the fetch callback may try to update state on an unmounted component. Always use AbortController to cancel in-flight requests in the cleanup function, and check the isMounted ref before calling setState in the observer callback.

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()

🧠 Test Yourself

Your infinite scroll FeedPage uses setPosts(prev => [...prev, ...newPosts]). The user filters by tag โ€” the tag changes and a new page 1 is fetched. The new posts are appended to the old tag’s posts instead of replacing them. How do you fix this?