Pagination
Pagination divides a large result set into smaller batches so the application transfers and renders only a manageable amount at a time.
Complete result set
↓
Apply stable sort and filters
↓
Select one bounded page
↓
Return items plus continuation metadata
It is common in product listings, search results, comments, dashboards, reports, and administrative tables.
Core idea: Pagination is both a data-access strategy and a navigation experience; the API and UI must agree on ordering, page size, boundaries, and continuation.
1. Why Pagination Matters
Loading every record at once creates several costs:
- larger API responses;
- slower initial rendering;
- higher browser and server memory usage;
- more DOM nodes;
- unnecessary database and network work;
- poor behavior on slow networks and lower-powered devices.
Pagination bounds the work performed for one user interaction.
50,000 products
Without pagination → transfer and render 50,000
With page size 20 → transfer and render 20 initially
Pagination does not make every query fast automatically. The database still needs suitable indexes, stable ordering, reasonable filters, and an efficient continuation strategy.
2. Client-Side vs Server-Side Pagination
Client-Side Pagination
The browser fetches the complete dataset once and displays a slice.
const start = (page - 1) * pageSize;
const visibleItems = items.slice(start, start + pageSize);
API returns all records
↓
Browser stores all records
↓
JavaScript slices the current page
Use it when the dataset is small, bounded, and already required by the client.
Server-Side Pagination
The browser requests one bounded page and the server queries only the necessary records.
GET /api/products?page=3&pageSize=20
Browser requests one page
↓
Server validates pagination and filters
↓
Database returns a bounded result
↓
Server returns items and page metadata
| Concern | Client-side | Server-side |
|---|---|---|
| Initial transfer | Entire dataset | One page |
| Page changes | Usually instant after loading | Usually require a request |
| Browser memory | Grows with full dataset | Bounded by cached pages |
| Filtering and sorting | Performed locally | Usually performed by server |
| Suitable data size | Small and known | Large or changing |
| Implementation | Simpler | Requires API and database support |
The decision depends on data size and ownership, not merely whether the application is “small” or “large.”
3. Separate UI Page Numbers from Internal Indexes
Users expect page numbers to start at 1. JavaScript arrays often start at 0.
Visible page 1 → internal page index 0
Visible page 2 → internal page index 1
Both conventions work, but mixing them causes off-by-one errors. Prefer a clear contract:
// UI and URL are 1-based.
const page = 3;
// Offset is calculated at the data boundary.
const offset = (page - 1) * pageSize;
Validate external values before using them:
const safePage = Math.max(1, Number.parseInt(pageParam, 10) || 1);
const safePageSize = Math.min(
100,
Math.max(1, Number.parseInt(pageSizeParam, 10) || 20)
);
The server must enforce its own page-size maximum. Client validation is not a security or resource-control boundary.
4. Page Count and Boundaries
When the API returns an exact total:
const totalPages = Math.ceil(totalItems / pageSize);
Example:
totalItems = 95
pageSize = 10
95 / 10 = 9.5
Math.ceil(9.5) = 10 pages
The final page contains the remainder.
const canGoPrevious = currentPage > 1;
const canGoNext = currentPage < totalPages;
Exact totals can be expensive or briefly stale in large, changing datasets. A cursor API may return only hasNextPage and hasPreviousPage instead of a total page count.
5. Offset Pagination
Offset pagination skips a number of ordered records and returns a limit.
offset = (page - 1) × pageSize
GET /api/products?limit=20&offset=40
SELECT id, name, price
FROM products
WHERE category_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;
The unique id is a tie-breaker. Without deterministic ORDER BY, consecutive requests are not guaranteed to produce a stable sequence.
Benefits
- simple API and SQL;
- easy numbered pages;
- supports jumping to an arbitrary page;
- total count can support “Page 3 of 24.”
Limitations
- the database may still process skipped rows;
- large offsets can become slow;
- inserts or deletions before the offset can cause duplicates or missing records;
- exact count queries may be expensive.
Offset pagination works well for moderate, relatively stable result sets and experiences where direct page navigation matters.
6. Page-Based Pagination
Page-based APIs expose page and pageSize instead of offset and limit.
GET /api/products?page=3&pageSize=20
The server commonly converts them:
const offset = (page - 1) * pageSize;
const limit = pageSize;
This is still usually offset pagination internally. It improves human readability but has the same consistency and large-offset tradeoffs.
A useful response contract is:
{
"items": [],
"page": 3,
"pageSize": 20,
"totalItems": 468,
"totalPages": 24,
"hasPreviousPage": true,
"hasNextPage": true
}
The server should remain the authority for normalized page values and totals.
7. Cursor Pagination
Cursor pagination asks for records after or before a stable position.
GET /api/feed?limit=20&after=eyJjcmVhdGVkQXQiOiIuLi4ifQ
{
"items": [],
"pageInfo": {
"endCursor": "eyJjcmVhdGVkQXQiOiIuLi4ifQ",
"hasNextPage": true
}
}
For records sorted by creation time and ID, the next query can use keyset conditions:
SELECT id, body, created_at
FROM comments
WHERE
(created_at, id) < ($cursorCreatedAt, $cursorId)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Benefits
- efficient continuation with the right index;
- more stable when records are inserted or deleted;
- a good fit for feeds and infinite scrolling.
Limitations
- jumping directly to page 500 is difficult;
- cursors depend on a stable ordering;
- backwards navigation needs additional cursor design;
- users cannot always know the total page count.
Treat cursors as opaque client values. The server may encode ordering fields, direction, filters, or a signed token, but clients should not construct or modify them.
8. Offset vs Cursor Decision
| Requirement | Offset/page | Cursor/keyset |
|---|---|---|
| Numbered pages | Excellent fit | Usually not natural |
| Jump to arbitrary page | Supported | Usually unsupported |
| Frequently changing feed | Can drift | More stable |
| Very deep traversal | Large offset may be costly | Efficient with indexed keyset |
| Exact total pages | Natural with a count | Often omitted |
| Simple implementation | Easier | More design work |
Neither is universally better. Select the strategy that matches navigation requirements, data volatility, and database behavior.
9. Filters, Search, and Sorting Are Part of Pagination
The complete query identity includes more than the page number.
const queryKey = [
"products",
{ page, pageSize, search, category, sort },
];
GET /api/products?page=2&pageSize=20&q=phone&category=electronics&sort=price_asc
When filters, search, sort, or page size change, reset to the first page unless there is a clear reason not to.
function changeSort(nextSort) {
setSort(nextSort);
setPage(1);
}
Otherwise the user may remain on page 12 even though the new filter has only two pages.
The server must apply filtering and sorting before selecting the page.
Filter → Sort → Paginate
Paginating first and filtering afterward creates incomplete and misleading pages.
10. Keep Pagination in the URL
For navigable result pages, the URL is usually the appropriate source of truth.
/products?page=3&pageSize=20&category=books&sort=price
Benefits include:
- refresh preserves the current result;
- Back and Forward navigation works;
- users can bookmark or share the view;
- analytics can identify page and filter state;
- server rendering and SEO become easier.
Parse and normalize invalid URL values. If a requested page exceeds the last page, choose a documented behavior such as redirecting to the last page, returning an empty result, or showing not found for public documents.
11. React Server-Side Pagination Example
This example uses one-based pages, handles cancellation, and separates initial loading from stale content.
import { useEffect, useState } from "react";
const PAGE_SIZE = 20;
export default function ProductResults() {
const [page, setPage] = useState(1);
const [result, setResult] = useState({
items: [],
totalItems: 0,
});
const [status, setStatus] = useState("loading");
const [error, setError] = useState(null);
const [requestAttempt, setRequestAttempt] = useState(0);
useEffect(() => {
const controller = new AbortController();
async function loadPage() {
setStatus("loading");
setError(null);
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(PAGE_SIZE),
});
const response = await fetch(`/api/products?${params}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error("Could not load products");
}
const nextResult = await response.json();
setResult(nextResult);
setStatus("success");
} catch (requestError) {
if (requestError.name !== "AbortError") {
setError(requestError);
setStatus("error");
}
}
}
loadPage();
return () => controller.abort();
}, [page, requestAttempt]);
const totalPages = Math.ceil(result.totalItems / PAGE_SIZE);
return (
<main>
{status === "loading" && result.items.length === 0 && (
<ProductSkeletons count={PAGE_SIZE} />
)}
{error && (
<div role="alert">
<p>{error.message}</p>
<button
type="button"
onClick={() => setRequestAttempt(value => value + 1)}
>
Try again
</button>
</div>
)}
<ProductGrid products={result.items} />
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={status === "loading"}
/>
</main>
);
}
12. Prevent Request Races
Users may change pages quickly:
Request page 2 starts
Request page 3 starts
Page 3 returns first
Page 2 returns last
Without protection, the late page 2 response can overwrite the visible page 3 data.
Use one or more of:
AbortControllercleanup;- a server-state library keyed by the complete query;
- a request generation ID;
- ignoring responses that no longer match the active query.
Do not use one unkeyed global result for every filter and page combination.
13. Accessible Pagination Controls
Pagination navigates between result pages, so use a labelled navigation landmark and real links when the state has URLs.
<nav aria-label="Search results pages">
<ul class="pagination">
<li><a href="?page=2" rel="prev">Previous</a></li>
<li><a href="?page=1">Page 1</a></li>
<li><a href="?page=2">Page 2</a></li>
<li>
<a href="?page=3" aria-current="page">Page 3</a>
</li>
<li><a href="?page=4">Page 4</a></li>
<li><a href="?page=4" rel="next">Next</a></li>
</ul>
</nav>
For client-only tables without meaningful page URLs, native buttons are appropriate.
Never use clickable <span> elements. Links and buttons provide keyboard behavior, semantics, focus, and disabled-state patterns.
Use aria-current="page" for the current page, and do not rely on color alone to show it.
14. Do Not Render Every Page Number
Rendering 10,000 numbered controls is neither usable nor efficient.
Use a compact window:
Previous 1 … 47 48 [49] 50 51 … 900 Next
function getPageItems(currentPage, totalPages, siblings = 1) {
if (totalPages <= 0) return [];
const left = Math.max(2, currentPage - siblings);
const right = Math.min(totalPages - 1, currentPage + siblings);
const items = [1];
if (left > 2) items.push("ellipsis-left");
for (let page = left; page <= right; page += 1) {
items.push(page);
}
if (right < totalPages - 1) items.push("ellipsis-right");
if (totalPages > 1) items.push(totalPages);
return items;
}
Ellipses communicate omitted ranges; they should not be empty links. A product that needs direct arbitrary navigation can also offer a “Go to page” input with clear validation.
15. Reusable Pagination Component
function Pagination({
currentPage,
totalPages,
onPageChange,
disabled = false,
}) {
if (totalPages <= 1) return null;
const items = getPageItems(currentPage, totalPages);
return (
<nav aria-label="Product result pages">
<ul className="pagination">
<li>
<button
type="button"
disabled={disabled || currentPage === 1}
onClick={() => onPageChange(currentPage - 1)}
>
Previous
</button>
</li>
{items.map(item => (
<li key={item}>
{typeof item === "number" ? (
<button
type="button"
aria-label={`Page ${item}`}
aria-current={
item === currentPage ? "page" : undefined
}
disabled={disabled}
onClick={() => onPageChange(item)}
>
{item}
</button>
) : (
<span aria-hidden="true">…</span>
)}
</li>
))}
<li>
<button
type="button"
disabled={disabled || currentPage === totalPages}
onClick={() => onPageChange(currentPage + 1)}
>
Next
</button>
</li>
</ul>
</nav>
);
}
This button-based version suits an in-place application view. For crawlable content, render equivalent links whose href contains the complete query state.
16. Loading and Transition Experience
When changing pages, choose intentionally between:
Replace with a Skeleton
Useful when stale records would be misleading, but it can create more visual movement.
Keep Previous Data Temporarily
Useful for fast comparison and stable layout. Mark the result area busy and prevent repeated accidental requests.
<section aria-busy={status === "loading"}>
<ProductGrid products={result.items} />
</section>
After a page change:
- maintain a predictable scroll position;
- move focus only when it helps the navigation model;
- announce an update such as “Page 3 loaded, 20 products” without excessive live-region noise;
- preserve the controls so users can continue navigating.
17. Caching and Prefetching
Cache results by their complete query identity:
products | q=phone | category=all | sort=price | page=3 | size=20
Useful behavior includes:
- immediately restore a recently visited page;
- prefetch the next page after the current one becomes stable;
- avoid duplicate requests for the same page;
- keep stale-time rules appropriate to the data;
- invalidate related pages after mutations.
Do not prefetch many pages automatically. It can recreate the bandwidth cost that pagination was meant to avoid.
18. Database and API Safeguards
A production endpoint should:
- cap page size;
- validate sort and filter fields against an allowlist;
- use parameterized queries;
- enforce deterministic ordering;
- create indexes that match common filters and sort order;
- avoid exposing sensitive cursor contents;
- return a consistent response shape;
- define behavior for empty and out-of-range pages;
- protect expensive count and deep-offset queries.
For cursor pagination, bind or validate the cursor against the active filters and sort order. Reusing a cursor from sort=newest with sort=price should not silently produce an invalid sequence.
19. SEO Considerations
For public, indexable listings:
- give pages stable URLs;
- render ordinary links for navigation;
- serve useful content without requiring a scroll gesture;
- use consistent canonical rules;
- avoid putting unrelated content on the same paginated series;
- ensure filters do not generate unlimited low-value URL combinations.
Server-side pagination is not automatically SEO-friendly. Discoverability depends on crawlable links, indexable rendering, URL design, canonicalization, and content value.
Authenticated tables and internal dashboards usually prioritize application state and usability rather than search indexing.
20. Pagination vs Infinite Scroll
| Pagination | Infinite scroll |
|---|---|
| Clear position and boundaries | Continuous discovery |
| Easy to bookmark a page | Needs explicit restoration design |
| Good for search and comparison | Good for browsing feeds |
| Footer remains reachable | Footer may be difficult to reach |
| Direct arbitrary navigation | Usually sequential loading |
Both can use offset or cursor APIs. Infinite scroll is a UI trigger layered on paginated data, not an alternative to pagination at the backend.
See Infinite Scroll for observer, virtualization, and restoration details.
21. Common Mistakes
| Mistake | Better approach |
|---|---|
| Fetching all records and calling it scalable pagination | Use server-side pagination for large datasets |
| Mixing zero-based and one-based page numbers | Define the boundary convention explicitly |
| Querying without deterministic ordering | Sort by stable fields with a unique tie-breaker |
Allowing an unlimited pageSize | Enforce a server-side maximum |
| Rendering every page number | Use a compact range with ellipses |
Using clickable <span> controls | Use links or buttons |
| Highlighting the current page only with color | Add aria-current="page" and a visible non-color cue |
| Keeping page 12 after changing filters | Reset to the first page |
| Ignoring fast-navigation request races | Cancel or ignore obsolete responses |
| Mixing caches for different search or sort values | Key by the complete query |
| Advancing a cursor before request success | Store the cursor returned by the confirmed page |
| Assuming server pagination guarantees SEO | Provide crawlable URLs, links, and indexable content |
22. Testing Checklist
Calculation and Boundaries
- Total pages round up correctly.
- First and last page boundaries are enforced.
- Partial final pages display correctly.
- Empty results do not produce page zero or invalid controls.
- Invalid URL values are normalized.
- Changing page size resets or recalculates the page safely.
Requests and Data
- Every page sends the correct parameters.
- Filters and sorting are applied before pagination.
- Fast navigation cannot display a stale response.
- Failed pages show retry without losing useful context.
- Duplicate requests are deduplicated where appropriate.
- Offset or cursor ordering remains deterministic.
Navigation and Accessibility
- All controls work by keyboard.
- Disabled boundaries are communicated.
- Exactly one visible page is marked current.
- The navigation landmark has a useful name.
- Back, Forward, refresh, bookmark, and shared URLs restore the view.
- Page changes preserve or intentionally manage focus and scroll.
Performance
- Page-size limits are enforced.
- Large offsets and count queries are measured.
- Only useful adjacent pages are prefetched.
- The DOM contains a compact set of pagination controls.
Official References
- W3C Design System: Pagination
- W3C WAI: Using
aria-current - MDN: Pagination Layout Pattern
- PostgreSQL:
LIMITandOFFSET - Google API Improvement Proposals: Pagination
Summary
Pagination is a contract between ordered data, bounded API queries, URL state, and accessible navigation controls.
Think of pagination as two synchronized layers:
Data layer
├── filters and deterministic sort
├── offset/page or cursor continuation
├── bounded page size
└── total or has-next metadata
UI layer
├── URL and active page state
├── loading, error, and empty states
├── compact accessible controls
└── cache, focus, scroll, and navigation restoration
The page-number formula is the easy part. Correct ordering, continuation, query identity, accessibility, and navigation behavior make pagination reliable in production.