Server‑Side Pagination in Remix: A Practical Loader‑Based Guide
Learn how to implement server‑side pagination in Remix using loader functions and useLoaderData. Follow a step‑by‑step guide, validate page numbers, set cache headers, and test navigation to ensure correct behavior.
12 Feb 2026, 06:17 UTC

Desired Outcome
Implement efficient, SEO‑friendly pagination in a Remix route by fetching only the data needed for each page on the server, exposing it via a loader and rendering it with useLoaderData. The result is a lightweight client bundle, accurate page URLs, and predictable navigation.
Prerequisites
- Remix project (≥1.13) set up with a database or mock data source.
- Basic TypeScript knowledge (optional but recommended for type safety).
- Understanding of HTTP query parameters and status codes.
Step‑by‑Step Procedure
- Define the pagination constants. Decide on a page size and create a TypeScript interface for the loader’s return type.
const PAGE_SIZE = 10; interface PaginatedResult { items: Array<any>; page: number; totalPages: number; totalItems: number; } - Build the loader. The loader runs on the server for every request. Parse the
pagequery parameter, validate it, query the data source, and return the slice.import { json, LoaderFunction } from "@remix-run/node"; export const loader: LoaderFunction = async ({ request }) => { const url = new URL(request.url); const pageParam = url.searchParams.get("page"); const page = pageParam ? parseInt(pageParam, 10) : 1; // Validation: ensure page is a positive integer if (isNaN(page) || page < 1) { throw new Response("Invalid page number", { status: 400 }); } // Simulate a database query – replace with real fetch const allItems = await fetchAllItems(); // returns Array<any> const totalItems = allItems.length; const totalPages = Math.ceil(totalItems / PAGE_SIZE); if (page > totalPages) { // Return an empty list when the requested page is beyond the range return json({ items: [], page, totalPages, totalItems }); } const start = (page - 1) * PAGE_SIZE; const items = allItems.slice(start, start + PAGE_SIZE); return json({ items, page, totalPages, totalItems }); };Key points:
- All pagination logic lives in the loader – no client‑side state.
- Throwing a
Responsewith status400triggers Remix’s built‑in error boundary. - Replace
fetchAllItemswith your actual database query (e.g., Prisma, Drizzle).
- Consume the data in the component. Use
useLoaderDatato get the typed result and render the list and navigation.import { useLoaderData, Link } from "@remix-run/react"; import type { LoaderFunction, json } from "@remix-run/node"; export default function ItemsRoute() { const data = useLoaderData<PaginatedResult>(); return ( <div> <h1>Items (Page {data.page} of {data.totalPages})</h1> <ul> {data.items.map((item, idx) => <li key={idx}>{item.title}</li> )} </ul> <nav> {data.page > 1 && ( <Link to={`?page=${data.page - 1}`} replace>Previous</Link> )} {data.page < data.totalPages && ( <Link to={`?page=${data.page + 1}`} replace>Next</Link> )} </nav> </div> ); } - Set cache headers (optional). If you enable caching on the loader, add a
Cache-Controlheader to avoid stale pages.return json({ items, page, totalPages, totalItems }, { headers: { "Cache-Control": "public, max-age=60" } });
Expected Checks
- Network inspection. Each click on
PreviousorNextshould trigger a new request to the same route with the correct?page=Nquery string. Verify the request URL in the browser’s dev tools. - Data slice verification. In the loader, log the
startanditems.lengthto confirm they match the requested page andPAGE_SIZE. - UI count. The rendered list should contain exactly
PAGE_SIZEitems, except possibly on the last page. - Boundary handling. Requesting
?page=0or a page beyondtotalPagesshould display a 400 error or an empty list, respectively.
Common Pitfalls & Recovery Options
- Unvalidated page numbers. Neglecting to parse or validate
pagecan lead to negative indices or server overload. Always parse withparseIntand guard againstNaN. - Client‑side state duplication. Some tutorials suggest storing the current page in local state. That breaks SEO and can desynchronize the URL. Keep all logic in the loader.
- Stale cached data. If you enable a global cache, subsequent page changes may serve old data. Use
Cache-Controlheaders or include the page number in the cache key. - Large data sets. Fetching all items to slice them in memory is inefficient. Prefer a database query that directly fetches the required slice, e.g., using
LIMITandOFFSET.
Recovery: If a page fails, Remix will render the nearest error boundary. You can customize the error UI by creating ErrorBoundary.tsx in the route folder and inspecting the error object for status codes.
Conclusion
By confining pagination logic to the server loader and exposing a clean, typed API to the component, you achieve fast, SEO‑friendly pages with minimal client overhead. Follow the validation and cache guidelines above to avoid common mistakes and keep your pagination robust as your data grows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.