Introduction: The Need for Pagination

Welcome back! In the previous lessons, you learned how to fetch and display book data in your React catalog app and how to let users sort the results. As your catalog grows, you might notice that loading and displaying all books at once can slow down your app and make it harder for users to find what they want.

This is where pagination comes in. Pagination means breaking up a large list of items into smaller, more manageable pages. Instead of loading hundreds or thousands of books at once, you only load a few at a time — just what the user needs to see. This makes your app faster and easier to use.

In this lesson, you will learn how to add server-side pagination to your catalog. This means the server will only send a small chunk of books for each page, and your frontend will let users move between pages.

How Server-Side Pagination Works

With server-side pagination, the client (your React app) asks the server for just one page of data at a time. The server responds with only the books for that page, along with information about the total number of books and how many are shown per page.

This is usually done by sending query parameters like page, sortBy, and order in the API request. For example:

GET /books?sortBy=title&order=asc&page=2

The server then responds with something like:

{
  "items": [ /* books for page 2 */ ],
  "total": 100,
  "page": 2,
  "pageSize": 10
}
  • items: The books for the current page.
  • total: The total number of books in the catalog.
  • page: The current page number.
  • pageSize: How many books are shown per page.

This way, your app only needs to handle a small set of books at a time, making it faster and more responsive.

Fetching and Displaying Paginated Data

Let’s look at how you can fetch paginated data and display it in your catalog. Here’s the updated code for your API call and catalog page:

// src/api/books.ts
import { apiClient } from "./client";
import { Book } from "../lib/types";

export interface PaginatedBooksResponse {
  items: Book[];
  total: number;
  page: number;
  pageSize: number;
}

export interface GetBooksParams {
  sortBy?: string;
  order?: "asc" | "desc";
  page?: number;
}

export const getBooks = async (
  { sortBy, order, page, q, pageSize }: GetBooksParams & { pageSize?: number } = {}
): Promise<PaginatedBooksResponse> => {
  const res = await apiClient.get<any>(
    `/books?${new URLSearchParams({
      ...(sortBy ? { sortBy } : {}),
      ...(order ? { order } : {}),
      ...(page ? { page: String(page) } : {}),
      ...(q ? { q } : {}),
      ...(pageSize ? { pageSize: String(pageSize) } : {}),
    }).toString()}`
  );
  
  // unwrap the backend envelope
  return res.data.data as PaginatedBooksResponse;
};

Explanation: Let’s look at getBooks. It constructs a query string using URLSearchParams so the server receives exactly the sorting, paging, and search options you specify.

  • Purpose: Build /books?... with any mix of:
    • sortBy (e.g., "title" or "author"),
    • order ("asc" or "desc"),
    • page (page number, 1-based),
    • q (free-text search),
    • pageSize (how many items per page — a key focus in this lesson).
  • How it’s built:
    • Conditional object spreads ...(page ? { page: String(page) } : {}) include a key only when a value is present.
    • String(page) and String(pageSize) ensure values are strings, as required by the URL format.
    • new URLSearchParams({...}).toString() encodes and joins the keys/values into a valid query string (e.g., sortBy=title&order=asc&page=2&pageSize=5).
  • Why URLSearchParams matters: It safely URL-encodes special characters (like spaces in q), avoids manual & concatenation bugs, and guarantees predictable ordering and formatting.
  • Why emphasize pageSize: Setting pageSize lets the server trim responses to just N items per page. Here we’ll request 5 in the UI to demonstrate predictable pagination math and navigation performance.

In the next section, we'll see how the frontend uses this.

Catalog Screen: syncing URL, debounced search, and paged queries

Let’s walk through the key behaviors in CatalogPage and why they matter.

// src/features/catalog/CatalogPage.tsx
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { getBooks } from '../../api/books';
import BookCard from './BookCard';
import Spinner from '../../components/Spinner';
import Pagination from '../../components/Pagination';
import SearchInput from '../../components/SearchInput';

export default function CatalogPage() {
  const [searchParams, setSearchParams] = useSearchParams();
  const sortBy = searchParams.get('sortBy') || 'title';
  const order = (searchParams.get('order') as 'asc' | 'desc') || 'asc';
  const page = parseInt(searchParams.get('page') || '1', 10);
  const q = searchParams.get('q') || '';

  const [input, setInput] = useState(q);

  useEffect(() => {
    const id = setTimeout(() => {
      setSearchParams(prev => {
        const next = new URLSearchParams(prev);
        if (input) next.set('q', input); else next.delete('q');
        next.set('sortBy', sortBy);
        next.set('order', order);
        next.set('page', '1');
        return next;
      });
    }, 300);
    return () => clearTimeout(id);
  }, [input]);

  const { data, isLoading, isError, error, isFetching } = useQuery({
    queryKey: ['books', { sortBy, order, page, q, pageSize: 5 }],
    queryFn: () => getBooks({ sortBy, order, page, q, pageSize: 5 }),
    keepPreviousData: true,
  });

  const handleSort = (newSortBy: string) => {
    const newOrder = sortBy === newSortBy && order === 'asc' ? 'desc' : 'asc';
    setSearchParams({ sortBy: newSortBy, order: newOrder, page: '1', ...(q ? { q } : {}) });
  };

  const handlePageChange = (newPage: number) => {
    setSearchParams({ sortBy, order, page: String(newPage), ...(q ? { q } : {}) });
  };

  if (isLoading && !data) {
    return <Spinner />;
  }

  if (isError) {
    return <p className="text-red-400">Error fetching books: {(error as Error).message}</p>;
  }

  const totalPages = data ? Math.ceil(data.total / data.pageSize) : 0;

  return (
    <section>
      <div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4 mb-6">
        <div>
          <h1 className="text-3xl font-bold">Book Catalog</h1>
          <p className="mt-2 text-slate-400">Browse our collection of books.</p>
        </div>
        <div className="w-full md:w-96 flex items-center gap-3">
          <SearchInput
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Search by title or author"
            aria-label="Search books"
          />
          {isFetching && <Spinner />}
        </div>
      </div>

      <div className="flex gap-2 mb-4">
        <button onClick={() => handleSort('title')} className="bg-slate-700 px-3 py-1 rounded-md">Sort by Title</button>
        <button onClick={() => handleSort('author')} className="bg-slate-700 px-3 py-1 rounded-md">Sort by Author</button>
      </div>

      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
        {data?.items.map((book) => (
          <BookCard key={book.id} id={book.id} title={book.title} author={book.author} />
        ))}
      </div>

      <Pagination currentPage={page} totalPages={totalPages} onPageChange={handlePageChange} />
    </section>
  );
}

Explanation:

  • A. Reading and writing URL params (deep linkable state)
    • useSearchParams() gives you a read/write handle to the query string.
    • We read sortBy, order, page, and q. Defaults: sortBy="title", order="asc", page=1, q="".
    • Keeping state in the URL allows bookmarks, back/forward navigation, and shareable links (e.g., ?q=clean+code&page=3&sortBy=author&order=desc).
  • B. Let’s look at the search effect. It debounces typing and syncs input → URL after 300ms:
    • What const next = new URLSearchParams(prev); does:
      • It clones the existing query parameters (prev) into a new, mutable URLSearchParams instance.
      • We then edit this copy: set or delete q, and (re)apply sortBy, order, and page='1'. Returning next replaces the current URL with the new query string.
    • Why we reset page to '1': Changing the search term should show results from the first page, or the user might land on an empty page for the new filter.
    • The return () => clearTimeout(id) cleanup:
      • If the user keeps typing, we cancel the previous scheduled update. This prevents excessive URL churn and redundant network requests.
    • Dependency array [input]:
      • The effect runs whenever the input value changes (not on sort or page changes). This isolates search behavior from other concerns.
      • Debouncing here keeps the UI snappy and reduces server load.
  • C. Fetching a specific page with useQuery (and pageSize: 5)
    • What happens:
    • The cache key includes sortBy, order, page, q, and pageSize: 5. Each unique combination maps to its own cached result.
    • queryFn calls getBooks with pageSize: 5, ensuring the server returns exactly 5 items per page (or fewer on the last page). This is central to predictable pagination.
    • keepPreviousData: true keeps showing the prior page’s data while the next page is loading — no flicker or jarring blank states. The isFetching flag drives a subtle loading hint next to the search field.
  • D. Page navigation with handlePageChange
  • What it does:
    • Updates the URL to the requested page, preserving the current sortBy, order, and q (only if q exists).
    • Because the URL (and thus the queryKey) changes, React Query refetches the appropriate page automatically.
  • E. Calculating totalPages for the UI
  • What it means:
    • data.total is the count of all matching books on the server (after filters).
    • data.pageSize is how many items the server returns per page (we asked for 5).
    • Math.ceil(total / pageSize) rounds up to ensure partial pages still count.
    • If data isn’t available yet, default to 0 to avoid rendering pagination prematurely.

Putting it together: The URL is the single source of truth for q, sortBy, order, and page. The query consumes those values along with a fixed pageSize: 5, and the UI reflects both results and pagination controls consistently.

Pagination UI: simple, accessible, and state-driven

Let’s look at the Pagination component. It receives currentPage, totalPages, and a callback:

// src/components/Pagination.tsx
interface PaginationProps {
  currentPage: number;
  totalPages: number;
  onPageChange: (page: number) => void;
}

export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
  if (totalPages <= 1) return null;

  const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);

  return (
    <nav className="flex justify-center items-center space-x-2 mt-8" aria-label="Pagination">
      <button onClick={() => onPageChange(currentPage - 1)} disabled={currentPage === 1} className="px-4 py-2 rounded-md bg-slate-800 hover:bg-slate-700 disabled:opacity-50">
        Previous
      </button>
      {pageNumbers.map((number) => (
        <button key={number} onClick={() => onPageChange(number)} className={`px-4 py-2 rounded-md ${currentPage === number ? 'bg-sky-500 text-white font-bold' : 'bg-slate-800 hover:bg-slate-700'}`}>
          {number}
        </button>
      ))}
      <button onClick={() => onPageChange(currentPage + 1)} disabled={currentPage === totalPages} className="px-4 py-2 rounded-md bg-slate-800 hover:bg-slate-700 disabled:opacity-50">
        Next
      </button>
    </nav>
  );
}

Explanation:

  • Rendering strategy:
    • If there’s only 1 page, render nothing.
    • Otherwise, compute [1, 2, ..., totalPages] and render buttons for each, plus Previous/Next.
    • Note: For very large catalogs, rendering all page numbers may be unwieldy. Consider showing a smaller range (like 1 ... 4 5 6 ... 20) in a real-world app to improve readability and performance.
  • Disabled states:
    • Previous is disabled on the first page; Next on the last page — this prevents invalid navigation.
  • Styling & accessibility:
    • The current page button gets a highlighted style.
    • The container uses aria-label="Pagination" to help assistive tech.

When a button is clicked, onPageChange(number) updates the URL, which in turn refreshes the query and updates the UI.

Example Output:

When you visit the catalog, you might see something like this at the bottom:

[Previous] 1 2 3 4 5 [Next]

If you click "Next," the app fetches the next page of books and updates the display.

Deep Dive: URLSearchParams (what it is and why we use it)

URLSearchParams is a built-in Web API for building and manipulating query strings:

  • Creation: new URLSearchParams({ page: '2', q: 'clean code' })
  • Encoding: Automatically escapes special characters (q=clean+code), so you don’t have to encodeURIComponent manually.
  • Editing:
    • .set('key', 'value') adds/replaces a parameter.
    • .delete('key') removes it.
    • .get('key') reads it; returns null if missing.
  • Serialization: .toString() returns key=value&key2=value2, ready to append after a ?.
  • Cloning from existing params: new URLSearchParams(prev) lets you copy and modify current URL parameters cleanly (exactly what we do in the debounced search effect).

Using URLSearchParams keeps your URLs well-formed, your code concise, and your state shareable.

Keeping the UI and URL in Sync

A key part of this setup is keeping the UI state (which page you’re on) in sync with the URL. This way, users can bookmark or share a link to a specific page, and the app will always show the correct results.

  • The useSearchParams hook from React Router reads and updates the URL’s query parameters.
  • When you change the page, the URL updates (for example, ?sortBy=title&order=asc&page=2).
  • The useQuery hook automatically fetches new data when the page or sorting changes.

This approach makes your app more user-friendly and easier to navigate.

Summary And Practice Preview

In this lesson, you learned how to add server-side pagination to your catalog app. You saw how to:

  • Request a specific page of data from the server using query parameters.
  • Update your API call and frontend to handle paginated responses.
  • Use a pagination component to let users move between pages.
  • Keep the UI and URL in sync for a smooth user experience.

To sum up:

  • API: getBooks builds /books?... with sortBy, order, page, q, and pageSize using URLSearchParams, then returns the typed payload.
  • Screen: CatalogPage keeps URL state in sync, debounces search input, fetches the correct page with useQuery (fixed pageSize: 5), and computes totalPages from the server response.
  • UI: Pagination renders accessible controls and calls back to update the URL, which triggers a refetch.

With these pieces, your catalog now handles search + sort + server-side pagination cleanly and efficiently — and every state is shareable via the URL. Next, you’ll get a chance to practice these concepts with hands-on exercises. This will help you reinforce what you’ve learned and make sure you can implement server-side pagination on your own. Great work — let’s keep going!

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal