URL‑driven pagination in Remix using route loaders and search params
Learn how to implement URL‑driven pagination in Remix by reading search params in route loaders, rendering prev/next links with <Link>, and avoiding common pitfalls.
13 Nov 2025, 00:46 UTC

How URL‑driven pagination works in Remix
In Remix the loader runs on the server for every navigation to a route. By reading the request URL’s search parameters you keep pagination state in the URL itself, which makes pages bookmarkable, shareable and works with the browser’s back and forward buttons without extra client‑side state.
Loader code example
import { json } from '@remix-run/node'; // or '@remix-run/cloudflare' etc.
export async function loader({ request }) {
const url = new URL(request.url);
// read page and pageSize, provide sane defaults and clamp values
const page = Math.max(1, Number(url.searchParams.get('page') ?? '1'));
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize') ?? '25'));
// Example using OFFSET; replace with your data source
const offset = (page - 1) * pageSize;
const [rows, total] = await Promise.all([
db.select().from('posts').limit(pageSize).offset(offset),
db.count({ count: '* }).from('posts')
]);
// Return plain object – works in Remix v2; use json() helper for v1
return { rows, total, page, pageSize };
}
Component rendering links
import { useLoaderData, Link } from '@remix-run/react';
export default function PostsRoute() {
const { rows, total, page, pageSize } = useLoaderData();
const pageCount = Math.ceil(total / pageSize);
return (
{rows.map(post => (
- {post.title}
))}
{page > 1 && (
Previous
)}
Page {page} of {pageCount}
{page < pageCount && (
Next
)}
);
}
Limits and trade‑offs
Performance of OFFSET vs cursor
OFFSET pagination forces the database to skip rows, which becomes slower as the page number grows because the engine still reads and discards the preceding rows. For tables that change frequently this can also cause duplicated or missing rows between requests. Cursor‑based pagination avoids the skip by using a unique, ordered column (e.g., an auto‑increment id or a timestamp) as an opaque cursor:
const cursor = url.searchParams.get('cursor');
const rows = await db.select()
.from('posts')
.where gt(posts.id, cursor) // assuming numeric id
.limit(pageSize)
.orderBy(posts.id);
With a cursor you no longer need a total count for exact page numbers; you can show “next” only when a result set is returned.
Common pitfalls
- Parsing page with
Number()without validation can produce negative or huge offsets, leading to erroneous queries or runtime errors. - Returning non‑serializable values (Date objects, class instances) from the loader breaks Remix’s JSON serialization and causes hydration mismatches.
- Adding unrelated search parameters (e.g., tracking tokens) triggers a loader re‑run on every change, wasting server cycles.
- Assuming
json()helper is required in Remix v2; plain object returns are supported, but using the helper unnecessarily adds overhead. - Relying on an exact
totalfor large datasets can dominate response time; consider caching the count or omitting it for infinite scroll.
Verification steps
- Create a route
app/routes/posts.$id.jsx(orposts.jsx) with the loader and component above. - Start the dev server:
remix dev(requires read/write access to the project directory). - Navigate to
/posts?page=2and verify the list updates, the URL changes, and the browser’s back/forward buttons show the correct page. - Turn off JavaScript in the browser and reload the page; the links should still work because they are plain
elements generated byLinkand the loader runs on the server. - Compare network timings for
page=1andpage=1000with OFFSET; observe increasing response time, then repeat with a cursor‑based loader to see flat timing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.