You open an app. It loads 10,000 records all at once. Your browser freezes. Your users leave. š¬
Sound familiar? That's the pagination problem in reverse ā what happens when you don't use it. Pagination is one of those concepts that looks simple on the surface but quietly powers almost every real-world app you've ever used: Google Search, Twitter feeds, product listings, admin dashboards, and more.
So how does it actually work? And how do you build it? Let's break it all down from scratch ā with a real React component at the end.
What Is Pagination?
Imagine you walk into a library and ask the librarian for every book they have. She stares at you. There are 50,000 books. You clearly can't carry them all at once.
So instead, she says: "I'll bring you 10 books at a time. Tell me which shelf to start from."
That's pagination.
In software, pagination is the process of splitting a large dataset into smaller chunks (pages) and delivering one chunk at a time. Instead of loading 10,000 rows from your database into the browser at once, you load page 1 (rows 1ā10), then page 2 (rows 11ā20), and so on ā only when the user asks.
The user controls which "shelf" they want. The server (or your frontend) delivers just that shelf.
Why Pagination Matters
Without pagination, your app has a few serious problems:
Performance tanks. Fetching thousands of records at once is slow. Rendering them is even slower. The browser has to process, paint, and hold all of that in memory ā for data the user probably won't even scroll to.
APIs get overwhelmed. Your backend server has to query the entire database, serialize everything, and send it over the network. For large datasets, this can take seconds or even crash the server.
UX suffers. A page that loads slowly or shows an endless wall of data is a bad experience. Users bounce. They don't stay.
Costs go up. If you're using a cloud database or API with metered billing, loading unnecessary data wastes money directly.
Pagination solves all of this by asking a simple question on every data request: "Which page? How many items per page?"
Benefits of Pagination ā With Real Examples
ā” Faster load times ā An e-commerce store showing 50,000 products loads the first 20 instantly instead of stalling for 10 seconds. Users can start browsing right away.
š Lower server load ā A
SELECT * FROM ordersquery on a 2-million-row table is painful.SELECT * FROM orders LIMIT 20 OFFSET 0is fast and surgical.š¾ Lower memory usage ā Your React component doesn't need to store 10,000 items in state. It stores 10 or 20 at a time ā clean and efficient.
š Better UX ā Users know where they are ("Page 3 of 47"). They can navigate, go back, bookmark a page, and share a specific page URL ā like Google search results.
š Easier debugging ā When your data is paginated, you can isolate a specific page during testing instead of scrolling through thousands of rows.
š Shareable URLs ā With query-based pagination like
?page=3, users can share a direct link to a specific page. That's huge for support teams, admins, and product listings.
Types of Pagination
Not all pagination works the same way. There are three common approaches:
1. Offset-Based Pagination (Classic)
This is the most common and beginner-friendly method. You specify a page number and a limit.
GET /api/posts?page=2&limit=10
On the backend, this translates to:
SELECT * FROM posts LIMIT 10 OFFSET 10
OFFSET = (page - 1) Ć limit
Simple, predictable, and easy to implement. This is what most tutorials (and this post) use.
Downsides: If new items are inserted while a user is paginating, they might skip or see duplicate items. For most apps, this is fine.
2. Cursor-Based Pagination
Instead of a page number, you use a "cursor" ā usually the ID of the last item you saw.
GET /api/posts?after=post_id_99&limit=10
This is what Twitter, Instagram, and most social feeds use. It's more stable for real-time data because it doesn't break when new items are added.
Downsides: Users can't jump to "page 5" directly. It's sequential ā next/previous only.
3. Frontend (Client-Side) Pagination
All the data is fetched at once, but the display is split into pages on the frontend using JavaScript. This works well for small datasets (under a few hundred items) where a full API call is acceptable.
No backend changes needed ā just slice the array.
const currentPageData = allData.slice(startIndex, endIndex);
This is the approach we'll use in the component below.
Offset Pagination vs Cursor Pagination
| Feature | Offset Pagination | Cursor Pagination |
|---|---|---|
| Jump to any page | ā Yes | ā No |
| Works with real-time data | ā ļø Not ideal | ā Yes |
| Easy to implement | ā Very | ā ļø More complex |
| Good for static datasets | ā Great | ā Also fine |
| Common in admin panels | ā Very common | Rare |
| Common in social feeds | Rare | ā Very common |
Rule of thumb: Use offset pagination for dashboards, admin panels, and product listings. Use cursor pagination for live feeds and real-time data.
How to Build a Pagination Component in React
Let's build a clean, reusable pagination component with fake data ā no external library needed. Just React and a little logic.
The logic behind it
Given:
-
totalItemsā total number of records -
itemsPerPageā how many to show per page -
currentPageā the active page
You can calculate:
const totalPages = Math.ceil(totalItems / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const currentData = data.slice(startIndex, endIndex);
Simple math. That's the whole engine.
Full Pagination Component
import { useState } from "react";
// --- Fake data to simulate a list of blog posts ---
const generatePosts = () =>
Array.from({ length: 47 }, (_, i) => ({
id: i + 1,
title: `Blog Post #${i + 1}`,
category: ["React", "CSS", "JavaScript", "Next.js", "Node.js"][i % 5],
date: `April ${(i % 28) + 1}, 2025`,
}));
const ITEMS_PER_PAGE = 5;
// --- Pagination Controls Component ---
function PaginationControls({ currentPage, totalPages, onPageChange }) {
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
return (
<div style={{ display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap", marginTop: "24px" }}>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
style={btnStyle(currentPage === 1)}
>
ā Prev
</button>
{pages.map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
style={btnStyle(false, page === currentPage)}
>
{page}
</button>
))}
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
style={btnStyle(currentPage === totalPages)}
>
Next ā
</button>
</div>
);
}
// --- Button styles helper ---
function btnStyle(disabled = false, active = false) {
return {
padding: "8px 14px",
borderRadius: "6px",
border: "1px solid #ddd",
cursor: disabled ? "not-allowed" : "pointer",
backgroundColor: active ? "#fd6e4e" : disabled ? "#f0f0f0" : "#fff",
color: active ? "#fff" : disabled ? "#aaa" : "#333",
fontWeight: active ? "bold" : "normal",
transition: "all 0.2s",
};
}
// --- Main App ---
export default function PaginatedList() {
const [currentPage, setCurrentPage] = useState(1);
const allPosts = generatePosts();
const totalPages = Math.ceil(allPosts.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const currentPosts = allPosts.slice(startIndex, startIndex + ITEMS_PER_PAGE);
const handlePageChange = (page) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page);
}
};
return (
<div style={{ maxWidth: "640px", margin: "40px auto", fontFamily: "sans-serif", padding: "0 16px" }}>
<h2 style={{ marginBottom: "4px" }}>š Blog Posts</h2>
<p style={{ color: "#666", fontSize: "14px", marginBottom: "20px" }}>
Showing {startIndex + 1}ā{Math.min(startIndex + ITEMS_PER_PAGE, allPosts.length)} of {allPosts.length} posts
</p>
{currentPosts.map((post) => (
<div
key={post.id}
style={{
padding: "16px",
marginBottom: "12px",
border: "1px solid #eee",
borderRadius: "10px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<strong>{post.title}</strong>
<p style={{ margin: "4px 0 0", fontSize: "13px", color: "#888" }}>{post.date}</p>
</div>
<span
style={{
background: "#fff3ee",
color: "#fd6e4e",
padding: "4px 10px",
borderRadius: "20px",
fontSize: "12px",
fontWeight: "bold",
}}
>
{post.category}
</span>
</div>
))}
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
<p style={{ marginTop: "16px", fontSize: "13px", color: "#999" }}>
Page {currentPage} of {totalPages}
</p>
</div>
);
}
What's happening here?
-
generatePosts()creates 47 fake blog posts ā simulating real data from an API. -
ITEMS_PER_PAGE = 5controls how many items appear per page. Change it to 10 or 20 and it just works. -
slice(startIndex, endIndex)cuts the array to only the current page's data ā classic client-side pagination. -
PaginationControlsis a reusable component. Pass it anycurrentPage,totalPages, andonPageChangeā it works with any dataset. - Prev/Next buttons are disabled at boundaries so users can't go below page 1 or above the last page.
- The active page button highlights with your brand color
#fd6e4e. šØ
Best Tips for Pagination
ā Always show the user where they are. "Page 3 of 12" or "Showing 21ā30 of 120 results" gives real context.
ā Disable Prev/Next at boundaries. Don't let users click into a page that doesn't exist.
ā Keep items per page reasonable. 10ā25 is the sweet spot for most apps. Too few = too many page clicks. Too many = overload.
ā
Use query params for server-side pagination. ?page=3 makes pages bookmarkable and shareable.
ā Add a loading indicator when fetching from an API. Users should know data is on the way.
ā Reset to page 1 when filters change. If a user is on page 6 and applies a filter that returns only 3 results, jumping back to page 1 automatically prevents confusion.
Common Mistakes to Avoid
ā Forgetting to reset page on filter/search changes. This is the #1 pagination bug. Your users filter by "React" and land on page 8 ā which is empty because the filtered result only has 20 items. Always reset currentPage to 1 when search or filter state changes.
ā Hardcoding item count. Avoid Math.ceil(47 / 5) in your component. Your data length will change. Always compute totalPages dynamically from the actual data or the API response.
ā Building your own pagination for large datasets. Client-side pagination is great for small datasets. For 10,000+ rows, use server-side pagination ā always. Slicing a 10,000-item array in the browser is still loading 10,000 items into memory.
ā No "out of bounds" guard. What if someone manually types ?page=9999 in the URL and your app has only 10 pages? Guard against this. Redirect or clamp to the last valid page.
ā Skipping accessibility. Your pagination buttons should have proper aria-label values and keyboard navigation support. aria-label="Go to page 5" goes a long way.
Conclusion
Pagination isn't glamorous. Nobody gives awards for a well-paginated list. But it's one of those foundational patterns that quietly makes your apps faster, cheaper to run, and genuinely better to use.
Once you understand the core idea ā split big data into small, navigable chunks ā the rest is just implementation details. Whether you're slicing an array on the frontend, passing LIMIT and OFFSET to a SQL query, or using cursor-based pagination for a real-time feed, the goal is always the same: give the user only what they need, exactly when they need it.
Build the component above, play with ITEMS_PER_PAGE, and see how the pages shift. That hands-on click is worth more than any explanation.
For more practical frontend guides like this one, visit š hamidrazadev.com ā and if this post saved you from loading 10,000 rows at once, share it with a fellow dev who might need it. š
Top comments (0)