Server-side pagination with Remix
Developers often implement client-side pagination as a half-measure to avoid the complexity of server-side pagination.
When you deal with pagination on the server, you need to communicate the specific page and page size to the server, and you need to return the total number of items so the client can render the pagination controls.
As the page changes, you need to update the URL and create new history entries, so the user can use the back button to go to the previous page, and share specific pages with other users.
And then there's the issue of data fetching: each time the page changes, you need to fetch the new data from the server and render it on the page.
But Remix is built for this kind of thing.
Showing the right data for each URL is what Remix was made for, so it's actually easier to implement server-side pagination with Remix than it is to do it on the client.
If you store the page number and size in the URL, Remix will fetch the new data and display it on the page immediately when the URL changes, and since databases generally have a way to return a single page of data at once, you get that for free too.
Read query parameters from the URL
The OData specification says that you should use the $top
and $skip
query parameters to specify the page size and page number, respectively.
So if you want to show the first page of 10 items, you would use the URL ?$top=10
, and to show the second page, you would use ?$top=10&$skip=10
.
Read the query parameters from the request URL in your loader, and use them to grab the right page of data from your database.
Depending on your database adapter, this part will look different, but there's usually a first-class way to do this like Prisma's { take, skip }
options or SQL's LIMIT
and OFFSET
clauses.
export async function loader({ request,}: LoaderFunctionArgs) { const url = new URL(request.url) const $top = Number(url.searchParams.get("$top")) || 10 const $skip = Number(url.searchParams.get("$skip")) || 0 // Slice the current page of issues from the database const issues = db.issues.slice($skip, $skip + $top) return json({ total: db.issues.length, issues, })}
Use links instead of a form
When I first wrote this article, I used a form with a submit button for each page, for a few reasons
- It's easy to disable the previous and next buttons when you're at the beginning or end of the pagination
- Including the existing query parameters is easy with hidden inputs
- Search crawlers won't follow the links and pollute your SEO profile
- Screen readers won't list each page as a navigation point
But after some feedback from the community, I've changed my mind. The SEO concerns are solvable, and being able to use prefetch
on the links makes for a really fast UI that's hard to match otherwise.
Create a pagination component
Let's create a rolling pagination component that shows the current page along with a few pages before and after it, like Google Search.
There are a few things to note about this implementation
- When the first page is selected, it shows 6 pages to the right of the current page
- When the last page is selected, it shows 6 pages to the left of the current page
- When the current page is in the middle, it shows 3 pages to the left and 3 pages to the right
Since components can independently access the URL parameters, we don't need any callbacks or state management to make this work. Each button will just be a link that includes the current parameters and the appropriate $skip
value.
To construct this link, make a new function that modifies the current search params and returns a url string
function setSearchParamsString( searchParams: URLSearchParams, changes: Record<string, string | number | undefined>,) { const newSearchParams = new URLSearchParams(searchParams) for (const [key, value] of Object.entries(changes)) { if (value === undefined) { newSearchParams.delete(key) continue } newSearchParams.set(key, String(value)) } // Print string manually to avoid over-encoding the URL // Browsers are ok with $ nowadays // optional: return newSearchParams.toString() return Array.from(newSearchParams.entries()) .map(([key, value]) => value ? `${key}=${encodeURIComponent(value)}` : key, ) .join("&")}
And then create the pagination bar component, passing in the total number of items so it can calculate the total number of pages.
export function PaginationBar({ total,}: { total: number}) { const [searchParams] = useSearchParams() const $skip = Number(searchParams.get("$skip")) || 0 const $top = Number(searchParams.get("$top")) || 10 const totalPages = Math.ceil(total / $top) const currentPage = Math.floor($skip / $top) + 1 const maxPages = 7 const halfMaxPages = Math.floor(maxPages / 2) const canPageBackwards = $skip > 0 const canPageForwards = $skip + $top < total const pageNumbers = [] as Array<number> if (totalPages <= maxPages) { for (let i = 1; i <= totalPages; i++) { pageNumbers.push(i) } } else { let startPage = currentPage - halfMaxPages let endPage = currentPage + halfMaxPages if (startPage < 1) { endPage += Math.abs(startPage) + 1 startPage = 1 } if (endPage > totalPages) { startPage -= endPage - totalPages endPage = totalPages } for (let i = startPage; i <= endPage; i++) { pageNumbers.push(i) } } return ( <div className="flex items-center gap-1"> <Button size="xs" variant="outline" asChild disabled={!canPageBackwards} > <Link to={{ search: setSearchParamsString(searchParams, { $skip: 0, }), }} preventScrollReset prefetch="intent" className="text-neutral-600" > <span className="sr-only"> First page</span> <Icon name="double-arrow-left" /> </Link> </Button> <Button size="xs" variant="outline" asChild disabled={!canPageBackwards} > <Link to={{ search: setSearchParamsString(searchParams, { $skip: Math.max($skip - $top, 0), }), }} preventScrollReset prefetch="intent" className="text-neutral-600" > <span className="sr-only"> Previous page</span> <Icon name="arrow-left" /> </Link> </Button> {pageNumbers.map((pageNumber) => { const pageSkip = (pageNumber - 1) * $top const isCurrentPage = pageNumber === currentPage if (isCurrentPage) { return ( <Button size="xs" variant="ghost" key={`${pageNumber}-active`} className="grid min-w-[2rem] place-items-center bg-neutral-200 text-sm text-black" > <div> <span className="sr-only"> Page {pageNumber} </span> <span>{pageNumber}</span> </div> </Button> ) } else { return ( <Button size="xs" variant="ghost" asChild key={pageNumber} > <Link to={{ search: setSearchParamsString( searchParams, { $skip: pageSkip, }, ), }} preventScrollReset prefetch="intent" className="min-w-[2rem] font-normal text-neutral-600" > {pageNumber} </Link> </Button> ) } })} <Button size="xs" variant="outline" asChild disabled={!canPageForwards} > <Link to={{ search: setSearchParamsString(searchParams, { $skip: $skip + $top, }), }} preventScrollReset prefetch="intent" className="text-neutral-600" > <span className="sr-only"> Next page</span> <Icon name="arrow-right" /> </Link> </Button> <Button size="xs" variant="outline" asChild disabled={!canPageForwards} > <Link to={{ search: setSearchParamsString(searchParams, { $skip: (totalPages - 1) * $top, }), }} preventScrollReset prefetch="intent" className="text-neutral-600" > <span className="sr-only"> Last page</span> <Icon name="double-arrow-right" /> </Link> </Button> </div> )}
Add labels to the buttons
There are a few icon links here with arrows instead of text, so make sure you add accessible labels to them so screen reader users know what they do.
A simple aria-label
attribute handles most cases, though some experts recommend using visually hidden text instead for better compatibility across devices.
<Link> <Icon name="arrow-left" /> <span className="sr-only">Previous page</span></Link>
Live demo
View the source code or check out the live demo to see it in action