Server‑Driven Pagination in Remix: A Decision Guide for Bookmarkable, Scalable Lists
When building a list in Remix, should you rely on server‑side offset pagination, cursor pagination, client‑side state, or infinite scroll? This guide compares the options, explains trade‑offs, and shows a concrete loader/Component pattern that keeps URLs bookmarkable and back‑button‑friendly.
26 Aug 2026, 00:49 UTC

Decision: How to Paginate a Large List in Remix
When a route needs to show many rows—say a product catalog or message history—you must decide how the data will be fetched and how the UI will navigate between pages. The core question is: What pagination strategy gives you bookmarkable URLs, good performance, and a smooth user experience?
Constraints & Goals
- Bookmarkable & shareable URLs – Users should be able to copy a link that lands on the same page.
- Back‑button safety – The browser’s back/forward should navigate correctly between pages.
- Scalable performance – Avoid loading the entire dataset for large tables.
- Consistency under concurrent writes – Avoid duplicates or gaps when new rows are inserted.
- Progressive enhancement – Work without JavaScript for basic navigation.
Supported Options
| Strategy | How It Works | Pros | Cons |
|---|---|---|---|
| Server‑side offset pagination
(page & size in URL) |
Loader reads ?page=<n>&size=<m>, clamps values, runs SQL OFFSET/LIMIT, returns slice + total count. |
Simple to implement; arbitrary page jumps; bookmarkable; works without JS. | Performance degrades on huge tables; can skip/duplicate rows if concurrent inserts; no stable cursor. |
| Server‑side cursor (keyset) pagination
(nextToken in URL) |
Loader uses a unique key (e.g., id) to fetch the next set with WHERE id > ? ORDER BY id LIMIT ?. |
Fast on large tables; stable under concurrent writes; no gaps. | Only supports forward/backward navigation; harder to jump to arbitrary pages; URL less intuitive. |
| Client‑side state pagination
(load all data once) |
Component fetches entire dataset once, stores in useState, slices in memory. |
Zero server round‑trips after initial load; instant page changes. | Not scalable for large data; no bookmarkable page; heavy initial payload. |
| Infinite scroll (client‑side fetcher) | Component calls fetcher.load('?page=' + nextPage) to append rows to existing list. |
Smooth UX; can load large lists lazily. | Back‑button loses position; hard to deep‑link; needs JS. |
| Hybrid: Link‑based navigation + server loader
(bookmarkable, progressive enhancement) |
Render <Link to="?page=2"> controls; Remix automatically re‑runs loader on navigation. |
Combines best of both worlds: bookmarkable, fast, back‑button safe, works without JS. | Requires careful validation of search params; must handle edge cases (out‑of‑range pages). |
Trade‑Off Summary
- Offset pagination is the easiest to start with and satisfies the bookmarkable requirement, but watch query performance on large tables.
- Cursor pagination is the most robust for high‑volume data, yet it complicates deep‑linking and UI controls.
- Client‑side state is only suitable for small lists where the entire payload is acceptable.
- Infinite scroll improves perceived speed but sacrifices navigation semantics.
- Hybrid link‑based pagination gives the best user experience for most scenarios, with the smallest implementation overhead.
Concrete Implementation: Server‑Offset Pagination with Bookmarkable Links
Below is a minimal, production‑ready example that demonstrates the loader, validation, and component rendering. Adjust the database layer and types to match your stack.
1. Loader (server‑only)
// app/routes/posts.jsx
import { json } from '@remix-run/node';
import { db } from '~/utils/db.server'; // replace with your DB client
export const loader = async ({ request }) => {
const url = new URL(request.url);
const rawPage = url.searchParams.get('page');
const rawSize = url.searchParams.get('size');
// Basic numeric validation and clamping
const page = Math.max(1, parseInt(rawPage ?? '1', 10) || 1);
const size = Math.min(100, Math.max(1, parseInt(rawSize ?? '20', 10) || 20));
const offset = (page - 1) * size;
// Parallel queries for speed
const [rows, total] = await db.$transaction([
db.post.findMany({ skip: offset, take: size, orderBy: { createdAt: 'desc' } }),
db.post.count()
]);
return json({ rows, total, page, size });
};
Key points:
- All work happens on the server; no client‑side state is required.
- Search params are parsed with
new URL(request.url)and validated before reaching the database. - We clamp
pageto at least 1 andsizeto a maximum of 100 to protect the DB. - Returning
totalallows the component to calculate page counts. - In Remix 2.x you could return a plain object instead of
json(), butjson()remains the most explicit and backwards‑compatible choice.
2. Component (client‑side)
// app/routes/posts.jsx (continued)
import { useLoaderData, Link, useNavigation } from '@remix-run/react';
export default function Posts() {
const { rows, total, page, size } = useLoaderData();
const navigation = useNavigation();
const totalPages = Math.ceil(total / size);
return (
<div>
<h1>Posts</h1>
<ul>
{rows.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<nav aria-label="Pagination">
{page > 1 && (
<Link to={`?page=${page - 1}&size=${size}`} replace>Prev</Link>
)}
<span> Page {page} of {totalPages} </span>
{page < totalPages && (
<Link to={`?page=${page + 1}&size=${size}`} replace>Next</Link>
)}
{navigation.state === 'loading' && <span>Loading…</span>}
</nav>
</div>
);
}
Highlights:
useLoaderData()gives you the JSON payload returned by the loader.- Links encode the new page number; Remix will re‑run the loader and perform a partial navigation.
- We use
replaceon theLinkto avoid polluting the browser history with intermediate navigation states. - The
useNavigationhook lets you show a loading indicator during the fetch.
3. Validation & Edge Cases
- If a user requests
?page=0or?page=-5, the loader clamps to 1. - Non‑numeric values (
?page=abc) fall back to the default page 1. - If
pageexceedstotalPages, you can redirect to the last valid page or show an empty list. - Always test with JavaScript disabled: the links should still work, and the page should render correctly.
4. Performance Check
- Run
EXPLAIN ANALYZEon theOFFSET/LIMITquery to ensure it uses an index and does not scan the entire table. - If the plan shows a full table scan, consider switching to cursor pagination or adding a composite index on the ordering column.
5. Optional Enhancements
- Allow the user to change
sizevia a<select>control and preserve the current page. - Show a “go to page” input that updates the URL using
navigatefromuseNavigate. - Cache the loader response with
Cache-Controlheaders for faster subsequent visits.
By following this pattern you get:
- Bookmarkable, shareable URLs that encode the exact page and size.
- Back‑button safety because each navigation is a full URL change.
- Server‑side rendering that works regardless of client JavaScript state.
- Clear separation of concerns: the loader handles data fetching, the component handles rendering.
When your dataset grows beyond a few thousand rows, revisit the query plan or switch to cursor pagination for better performance while keeping the same URL‑based navigation approach.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.