URL-Driven Pagination in Remix: Loaders, Search Params, and Shareable Pages
Keep pagination state in the query string instead of React state: a Remix loader reads ?page=N, validates it, fetches the slice, and plain links re-run the loader—giving you deep links, server rendering, and working back buttons.
22 May 2026, 21:02 UTC

If your list page keeps its page number in React state, the URL says nothing useful: refreshing loses the page, sharing a link sends people to page 1, and the back button behaves like a trap. The fix in Remix is to make the query string the single source of truth—?page=3—and let the route loader do the fetching. You get server-rendered pages, working deep links, and correct back/forward navigation with no client-side refetch logic.
The outcome you're building
A route like /items?page=3 where:
- The loader reads
pagefrom the request URL, validates it, and queries the right slice of data. - Pagination controls are plain links (or a GET form), so clicking one triggers a navigation and Remix re-runs the loader automatically.
- Invalid input (
page=abc,page=-5) falls back to page 1 instead of crashing or leaking into a SQL query.
Version note: the examples below use the json helper and file-based route conventions from Remix 2.x with the Vite plugin. Remix has converged with React Router 7, where the same loader pattern applies but imports come from react-router instead of @remix-run/node. Check which package your project actually depends on before copying import paths.
Prerequisites
- A Remix app on a data router (the default Vite plugin setup qualifies).
- A data source that supports offset pagination (a SQL database with
LIMIT/OFFSET, or an API with page parameters). - TypeScript, so
useLoaderDatagives you typed data.
Step 1: Read and validate the page param in the loader
Search params are untrusted user input. Parse them defensively: default to 1, reject non-numeric values, and clamp to a minimum of 1. Never interpolate the raw string into SQL—use parameterized queries.
// app/routes/items.tsx (Remix 2.x style)
import { json } from "@remix-run/node";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { db } from "~/db.server";
const PAGE_SIZE = 20;
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const raw = url.searchParams.get("page");
const parsed = raw === null ? 1 : Number.parseInt(raw, 10);
const page = Number.isFinite(parsed) && parsed >= 1 ? parsed : 1;
const offset = (page - 1) * PAGE_SIZE;
const [items, total] = await Promise.all([
db.item.findMany({ skip: offset, take: PAGE_SIZE, orderBy: { id: "asc" } }),
db.item.count(),
]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return json({ items, page, totalPages });
}Two decisions worth noting:
- Clamping vs. 404 for out-of-range pages. If
page > totalPages, you can either clamp to the last page or thrownew Response("Not found", { status: 404 }). Clamping is friendlier when the dataset shrinks (deleted rows); a 404 is more honest for permanent URLs. Pick one and test it. - Stable ordering. Offset pagination only returns consistent slices if the query has a deterministic
ORDER BY(like a unique id). Without it, rows can shift between pages.
Step 2: Render links, not buttons with state
Pagination controls should be <Link> elements pointing at the next URL. A navigation re-runs the loader, so there is no fetch function to write and no local copy of the data to keep in sync.
import { Link, useLoaderData, useNavigation } from "@remix-run/react";
export default function ItemsRoute() {
const { items, page, totalPages } = useLoaderData<typeof loader>();
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
return (
<section aria-busy={isLoading}>
{isLoading ? <p>Loading…</p> : null}
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
<nav aria-label="Pagination">
{page > 1 ? (
<Link to={`?page=${page - 1}`}>Previous</Link>
) : null}
<span>
Page {page} of {totalPages}
</span>
{page < totalPages ? (
<Link to={`?page=${page + 1}`}>Next</Link>
) : null}
</nav>
</section>
);
}useNavigation() tells you a page change is in flight. Remix keeps the old UI rendered during the navigation, so a subtle pending indicator is enough—avoid swapping in a full-screen spinner that hides the current list.
A common bug to avoid: copying loader data into useState "for convenience." That copy goes stale on the next navigation and is the usual cause of "the URL says page 4 but the list shows page 2." Render directly from useLoaderData.
Step 3: Handle loader failures
If the database query throws, the route's ErrorBoundary renders instead of a blank page:
import { useRouteError, isRouteErrorResponse } from "@remix-run/react";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <p>{error.status} — {error.statusText}</p>;
}
return <p>Could not load this page of results. Try again.</p>;
}How to verify it works
- Server rendering: open
/items?page=2with JavaScript disabled (or view the page source). The correct slice should be in the HTML—this confirms the loader did the work, not the browser. - Edge cases: try
?page=0,?page=-5,?page=abc, and a page beyondtotalPages. Each should hit your clamp or 404 behavior, not a 500. - Single request: in the network tab, click Next. You should see exactly one document/data request and no duplicate client-side fetch.
- History: navigate forward two pages, then press Back. The URL and list should return to the previous page.
- Regression test: a Playwright test that clicks Next, asserts the URL contains
page=2, and asserts the first list item changed covers the whole flow.
Limitations to know before shipping
Offset pagination can skip or duplicate rows when records are inserted or deleted between requests, because "skip 40 rows" refers to a shifting set. For fast-moving data—activity feeds, inboxes—cursor-based pagination (passing the last seen id as ?cursor=…) is more stable, though it makes "jump to page 7" impossible. For a mostly static catalog, offset with a stable sort is fine and keeps the UI simpler.
Also confirm the json helper and route conventions against your installed version: in React Router 7–based setups, json is unnecessary (you can return plain objects) and imports move to react-router. The loader/URL pattern itself is identical either way.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.