Jacob Paris
← Back to all content

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.

ts
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,
})
}

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

ts
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.

tsx
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.

tsx
<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

Professional headshot
Moulton
Moulton

Hey there! I'm a developer, designer, and digital nomad building cool things with Remix, and I'm also writing Moulton, the Remix Community Newsletter

About once per month, I send an email with:

  • New guides and tutorials
  • Upcoming talks, meetups, and events
  • Cool new libraries and packages
  • What's new in the latest versions of Remix

Stay up to date with everything in the Remix community by entering your email below.

Unsubscribe at any time.