Pagination URLs affect SEO crawling, user experience, and API performance. Choose the right pattern based on your use case: page numbers for browsable content, cursors for APIs, or keyset for real-time data.
Key Takeaways
- 1Offset pagination uses page or skip/limit parameters
- 2Cursor pagination is more efficient for large datasets
- 3Keyset pagination is best for real-time sorted data
- 4SEO requires rel="prev/next" or View All option
- 5API pagination should include total count and navigation links
“Google can find and crawl your paginated content as long as each page has unique URLs and clear links between pages.”
Pagination URL Patterns
Different pagination patterns suit different scenarios. The table below compares common approaches, helping you choose based on whether you are building a user-facing listing or an API.
| Pattern | URL Example | Best For |
|---|---|---|
| Page number | ?page=2 | User-facing listings |
| Offset/limit | ?offset=20&limit=10 | APIs |
| Cursor | ?cursor=abc123 | Large datasets, real-time |
| Keyset | ?after_id=100 | Sorted, real-time data |
| Path-based | /page/2 | SEO-focused sites |
Each pattern has trade-offs in terms of implementation complexity, performance at scale, and user experience. Let us start with the most familiar approach.
Offset Pagination
Offset pagination is the classic approach where you specify a page number and items per page. It is intuitive for users who want to jump to page 5 directly, but it can be slow with large datasets.
// Page-based pagination
// URL: /products?page=2&per_page=20
function parsePageParams(url) {
const params = new URLSearchParams(url.search);
const page = parseInt(params.get('page')) || 1;
const perPage = Math.min(parseInt(params.get('per_page')) || 20, 100);
return {
page,
perPage,
offset: (page - 1) * perPage,
limit: perPage
};
}
// Build pagination URLs
function buildPaginationUrls(baseUrl, currentPage, totalPages) {
const urls = {};
if (currentPage > 1) {
urls.first = `${baseUrl}?page=1`;
urls.prev = `${baseUrl}?page=${currentPage - 1}`;
}
if (currentPage < totalPages) {
urls.next = `${baseUrl}?page=${currentPage + 1}`;
urls.last = `${baseUrl}?page=${totalPages}`;
}
return urls;
}
// API response with pagination
{
"data": [...],
"pagination": {
"page": 2,
"per_page": 20,
"total": 500,
"total_pages": 25,
"links": {
"first": "/api/products?page=1",
"prev": "/api/products?page=1",
"next": "/api/products?page=3",
"last": "/api/products?page=25"
}
}
}This code parses page parameters from the URL and builds navigation links for first, previous, next, and last pages. The API response includes metadata so clients know their position in the result set.
While offset pagination is easy to understand, it becomes inefficient for large datasets. Cursor pagination solves this by using a pointer to the last item instead of counting offsets.
Cursor Pagination
Cursor pagination uses an opaque token representing a position in the result set. The client does not need to know page numbers—it simply requests items after the cursor from the previous response.
// Cursor-based pagination
// More efficient for large datasets
// URL: /api/items?cursor=eyJpZCI6MTAwfQ&limit=20
// Encode cursor (typically base64 of last item)
function encodeCursor(item) {
return Buffer.from(JSON.stringify({
id: item.id,
createdAt: item.createdAt
})).toString('base64url');
}
function decodeCursor(cursor) {
try {
return JSON.parse(Buffer.from(cursor, 'base64url').toString());
} catch {
return null;
}
}
// API handler
async function getItems(req) {
const cursor = req.query.cursor ? decodeCursor(req.query.cursor) : null;
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
let query = db.items.orderBy('id');
if (cursor) {
query = query.where('id', '>', cursor.id);
}
const items = await query.limit(limit + 1).get();
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
const nextCursor = hasMore ? encodeCursor(items[limit - 1]) : null;
return {
data,
pagination: {
next_cursor: nextCursor,
has_more: hasMore
}
};
}The cursor encodes the last item's ID in base64. The server decodes it and uses a WHERE clause to fetch items after that position. This approach performs consistently regardless of how deep into the results you are.
Keyset pagination is similar to cursor pagination but uses explicit field values instead of opaque tokens. This makes URLs more readable and debugging easier.
Keyset Pagination
Keyset pagination (also called the seek method) navigates using the sort column values directly. This works especially well for time-ordered data where you want items after a specific timestamp.
// Keyset pagination (seek method)
// Best for real-time, sorted data
// URL: /api/posts?after_id=100&limit=20
// URL: /api/posts?after_date=2026-01-15T10:00:00Z&after_id=100
// Works well with composite keys for stable ordering
async function getPostsKeyset(req) {
const afterDate = req.query.after_date;
const afterId = parseInt(req.query.after_id);
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
let query = `
SELECT * FROM posts
WHERE (created_at, id) > (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT ?
`;
const posts = await db.query(query, [afterDate, afterId, limit + 1]);
const hasMore = posts.length > limit;
const data = hasMore ? posts.slice(0, -1) : posts;
const lastItem = data[data.length - 1];
return {
data,
pagination: {
has_more: hasMore,
next: hasMore ? {
after_date: lastItem.created_at,
after_id: lastItem.id
} : null
}
};
}
// Keyset advantages:
// - Consistent results even with new inserts
// - Better database performance (no OFFSET)
// - Stable pagination with concurrent changesUsing composite keys (created_at, id) ensures stable ordering even when items have identical timestamps. The database can use indexes efficiently since there is no OFFSET to skip over.
For user-facing content, search engines need to understand the relationship between paginated pages. Proper SEO markup helps Google crawl and index your content effectively.
SEO Pagination
Search engines need signals to understand that page 1, page 2, and page 3 are all part of the same content series. Use rel="prev" and rel="next" links to indicate the sequence.
<!-- SEO pagination markup -->
<!-- Page 1 -->
<link rel="next" href="https://shop.com/products?page=2" />
<link rel="canonical" href="https://shop.com/products" />
<!-- Page 2 -->
<link rel="prev" href="https://shop.com/products" />
<link rel="next" href="https://shop.com/products?page=3" />
<link rel="canonical" href="https://shop.com/products?page=2" />
<!-- Last page -->
<link rel="prev" href="https://shop.com/products?page=24" />
<link rel="canonical" href="https://shop.com/products?page=25" />
<!-- Alternative: View All page -->
<!-- All paginated pages canonical to View All -->
<link rel="canonical" href="https://shop.com/products/all" />
<!-- Path-based pagination (SEO-friendly) -->
https://shop.com/products/page/2
<!-- Cleaner URLs but harder to implement -->Each paginated page should have a self-referencing canonical URL and links to adjacent pages. Alternatively, you can canonical all pages to a "View All" page if your content is not too large.
Modern interfaces often use infinite scroll instead of page numbers. You can still maintain URL state for shareability and browser history navigation.
Infinite Scroll URLs
Infinite scroll creates a smooth browsing experience, but users lose the ability to share specific positions. Updating the URL as they scroll preserves shareability and lets them return to where they left off.
// Infinite scroll with URL state
// Update URL as user scrolls without page reload
function updateUrlOnScroll(page) {
const url = new URL(window.location.href);
if (page > 1) {
url.searchParams.set('page', page);
} else {
url.searchParams.delete('page');
}
// Update URL without reload
history.replaceState({ page }, '', url);
}
// On initial load, jump to URL's page
function initFromUrl() {
const params = new URLSearchParams(window.location.search);
const page = parseInt(params.get('page')) || 1;
if (page > 1) {
// Load all items up to this page
loadItemsUpTo(page);
}
}
// Intersection Observer for infinite scroll
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadNextPage();
updateUrlOnScroll(currentPage);
}
});
observer.observe(document.querySelector('#load-more-trigger'));Using history.replaceState updates the URL without triggering a page reload. When users share the URL or refresh, initFromUrl loads all content up to that page position.