Trendy Coder Logo
  • Home
  • About
  • Blog
  • Newsletter

Server-Side Pagination Made Easy in Payload CMS

Posted on: August 11 2025
By Dave Becker
Hero image for Server-Side Pagination Made Easy in Payload CMS

Long before JavaScript was really a popular web framework technology or even before it could even make a fetch to the server using XHR, all web page pagination was rendered from the server-side. With the arrival of React Server Components (RSC), we can once again take advantage of server-side pagination using modern frameworks like Next.js and Payload CMS?

In this article I'll show you how to use Next.js and Payload CMS to easily provide server-side pagination. Payload provides all of the API endpoints needed to make the job easy.

In this article, I will cover:

  • What is the difference between server and client pagination.
  • Server-side pagination using React Server Components (RSC).
  • Payload API support for pagination.
  • How to use Next.js to do the server-side rendering.

So, let's take a step back in time and see how pagination was done in the early days but using a modern approach with React.

What is Server-Side Pagination?

Similar to client-side pagination, a fetch is called to load the next page of data. The difference being that server-side pagination will make the fetch on the server before it ever reaches the browser.

In addition, server-side pagaination does not have access to the following features:

  • No React hooks, including the Next.js client hooks params, searchParams, useRouter, etc
  • No DOM actions or events.
  • No access to Browser features.

Typically a client pagination component will make use of hooks to store state, fetch page data or redirect for example using the useRouter in Next.js.

The server-side pagination approach will make use of query string params, or even path params, to maintain the state of the pagination, for example:

http://<host>/orders?page=1

or using path params

http://<host>/orders/page/1

So a server pagination component can easily build static links for subsequent calls to load a specific page of data based on the given query param.

Building a Server Paginator

To build a server-side paginaton component, we'll need the following pieces of code.

  • A Payload Collection endpoint to query page data from.
  • A Next.js server page (RSC).
  • A Pagination component (RSC).

Also, in order to paginate data, we'll need to know two values from the API:

  • Total pages or Total docs (records)
  • Items per page.

For example, if we have 85 total docs

const totalDocs = 85
const pageSize = 10 // items to show per page
const totalPages = Math.ceil(totalDocs/pageSize) // 9 pages

Since we're rounding up, the last page will be a partial page with only the remaining 5 items.

One of the great features of Payload CMS is that it takes the hard work out of pagination by providing all the data points we'll need.

So let's start on the server endpoint before we create the actual pagination component.

A Simple Payload Collection

For this example I'll define a simple Orders Collections to hold some data and seed it with some random data.

Orders Collection
import type { CollectionConfig } from 'payload'

export const Orders: CollectionConfig = {
  slug: 'orders',
  admin: {
    useAsTitle: 'orderNumber',
    defaultColumns: ['orderNumber', 'item', 'total'],
  },
  access: {
    read: () => true,
    update: () => true,
    create: () => true,
    delete: () => true,
  },
  fields: [
    {
      name: 'orderNumber',
      label: 'Order Number',
      type: 'number',
    },
    {
      name: 'item',
      label: 'Item name',
      type: 'text',
    },
    {
      name: 'total',
      label: 'Total',
      type: 'number',
      min: 0,
    },
  ],
}

This Collection just serves as a source of data to query but it could be used on any Payload CMS Collection.

It simply holds some mock order field data including:

  • An order number
  • The item name
  • The price total amount

Now that the Collection is defined and seeded with some data, we can work on the API query.

The Payload Query Response

There's a few options to use when querying for data with Payload CMS and it offers the following:

  • REST
  • GraphQL
  • Local (only server side)

All of these API services will return the same paginated response format when running a query.

The docs field will change depending on your query and data structure but the pagination meta data will have the same response format.

For example I'll run a query on the orders data and it will show the following response.

{
    "docs": [
      {
            "id": 96,
            "orderNumber": 7538,
            "item": "Bacon",
            "total": 15.99,
            "updatedAt": "2025-07-08T06:02:38.823Z",
            "createdAt": "2025-07-08T06:02:38.858Z"
      }
      // the rest omitted but shows only 10 docs based on limit count
    ],
    "hasNextPage": true,
    "hasPrevPage": false,
    "limit": 10,
    "nextPage": 2,
    "page": 1,
    "pagingCounter": 1,
    "prevPage": null,
    "totalDocs": 100,
    "totalPages": 10
}

Payload provides all of the query meta data needed to build the pagination.

The bare minimum for our case will be:

  • limit: determines how many docs per page size
  • totalPages: the total pages which is totalDocs/limit.

Create the Next.js Page (RSC)

Now that the Collection is ready we can create the Next.js page that will load the data.

A few things to note here is that we need to obtain the initial state.

  • page: The current page from the page search param.
  • pageSize: Typically found in a users preferences or dropdown but for this example we'll use a constant.

I'm also using a url helper package to parse and format url strings.

npm i url

or

pnpm add url

Here's the Next.js page code and then I'll breakdown what's happening.

Orders page
import React from 'react'
import OrdersList from './components/OrdersList'
import { Pagination } from './components/Pagination'
import { getPayload, PaginatedDocs } from 'payload'
import configPromise from '@payload-config'
import { Order } from '@payload-types'
import { notFound } from 'next/navigation'
import url from 'url'
import './styles.css'

type Args = {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
export default async function Orders({ searchParams }: Args) {
  const query = await searchParams

  // gets the current page from searchParams
  const { page: currentPage = 1 } = query

  // builds a relative url path and catches all query params
  const urlPath = url.format({ pathname: '/orders', query})

  // the per/page size count or limit
  const pageSize = 5

  // get the Payload local API
  const payload = await getPayload({ config: configPromise })

  const parsedCurrentPage = Number(currentPage)

  if (!Number.isInteger(parsedCurrentPage)) notFound()

  const bundle: PaginatedDocs<Order> = await payload.find({
    collection: 'orders',
    depth: 1,
    limit: pageSize,
    page: parsedCurrentPage,
    overrideAccess: false,
  })

  const { docs, page, totalPages } = bundle

  return (
    <div className="w-[80%] mx-auto p-5">
      <OrdersList data={docs}></OrdersList>
      <Pagination path={urlPath} page={page} totalPages={totalPages}></Pagination>
    </div>
  )
}

Analyzing the Next.js RSC Page

By getting a reference to the searchParams we can extract the page number to query the Collection.

It's important to retain the full searchParams state when formatting the url and not truncate any other search params.

Sending only the page param will discard any other query params, so it's best to pass the full query variable object.

The urlPath will be passed into the Pagination component to create HTML links for each of the Orders pages.

...
  const query = await searchParams

  const urlPath = url.format({ pathname: '/orders', query})
  /* 
    outputs: /orders?page=1 
    but can also pass any other query params as needed for the query.
    i.e. /order?page=1&sort=asc&orderBy=id  
  */
...

Using fetch vs. Payload Local

In the previous example, I opted to use the local Payload API to query the Collection since it's faster and we are making the call on the server.

If you preferred to use a fetch query, you could simply make the following changes. The response from the fetch will have the same PaginatedDocs response type format.

...

let bundle: PaginatedDoc<Orders> = null
try {
  bundle = await fetch(
    `${process.env.NEXT_PUBLIC_SERVER_URL}/api/orders?[limit]=${pageSize}&[page]=${Number(currentPage)}`,
    {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      },
    },
  )
    .then((res) => res.json())
    .then((res) => {
      if (res.errors) throw new Error(res?.errors?.[0]?.message ?? 'Error fetching doc')

      return res
    })
} catch (error) {
  // console.error(error)
}

const { docs, page, totalPages } = bundle

...

The Pagination Component

So now that we have the data and Next.js page ready we can add the Pagination component.

The Pagination component expects the following props:

  • page: current page from search params.
  • totalPages: passed in from Payload CMS query response.
  • path: expects the pathname starting with a leading / and any query params that are passed down to the page.
  • className (optional): optional class styling

I've added some basic TailwindCSS to style the components and some React icons.

Pagination
import React from 'react'
import { TbDots as Dots } from 'react-icons/tb'
import { FaAngleLeft as ArrowLeft, FaAngleRight as ArrowRight } from 'react-icons/fa6'
import Link from 'next/link'

export async function Pagination({
  className,
  page,
  totalPages,
  path = '/',
}: {
  className?: string
  page: number
  totalPages: number
  path?: string
}) {
  const { query, pathname } = url.parse(path.startsWith('/') ? path : `/${path}`, true)
  const hasNextPage = page < totalPages
  const hasPrevPage = page > 1

  const hasMorePrevPages = page - 1 > 1
  const hasMoreNextPages = page + 1 < totalPages

  const getPageUrl = (page: number) => {
    const urlPath = url.format({ pathname, query: { ...query, page } })
    return urlPath
  }

  return (
    <div className={['my-12', className].filter(Boolean).join(' ')}>
      <nav
        className={['mx-auto flex w-full justify-center', className].filter(Boolean).join(' ')}
        role="navigation"
      >
        <ul className={['flex flex-row items-center gap-1', className].filter(Boolean).join(' ')}>
          <PageItem>
            <PagePrevious disabled={!hasPrevPage} href={getPageUrl(page - 1)} />
          </PageItem>

          {hasMorePrevPages && (
            <PageItem>
              <Ellipsis />
            </PageItem>
          )}

          {hasPrevPage && (
            <PageItem>
              <PageLink href={getPageUrl(page - 1)}>{page - 1}</PageLink>
            </PageItem>
          )}

          <PageItem>
            <PageLink isActive href={getPageUrl(page)}>
              {page}
            </PageLink>
          </PageItem>

          {hasNextPage && (
            <PageItem>
              <PageLink href={getPageUrl(page + 1)}>{page + 1}</PageLink>
            </PageItem>
          )}

          {hasMoreNextPages && (
            <PageItem>
              <Ellipsis />
            </PageItem>
          )}

          <PageItem>
            <PageNext disabled={!hasNextPage} href={getPageUrl(page + 1)} />
          </PageItem>
        </ul>
      </nav>
    </div>
  )
}


function PageItem({ children, className, ...props }: React.HTMLAttributes<HTMLLIElement>) {
  return (
    <li className={className} {...props}>
      {children}
    </li>
  )
}

type PageLinkProps = {
  href: string
  isActive?: boolean
  disabled?: boolean
} & React.ComponentProps<'button'>

function PageLink({ href, className, isActive, disabled, ...props }: PageLinkProps) {
  return (
    <Link
      href={href}
      className={['flex items-center', disabled && 'pointer-events-none'].filter(Boolean).join(' ')}
    >
      <button
        className={[
          'px-4 py-2 cursor-pointer',
          isActive &&
            'border-2 border-neutral-300 rounded-lg bg-inherit hover:bg-neutral-200 hover:text-neutral-foreground',
          !isActive && 'bg-inherit',
          ,
          className,
        ]
          .filter(Boolean)
          .join(' ')}
        disabled={disabled}
        {...props}
      />
    </Link>
  )
}

function PagePrevious({ className, ...props }: React.ComponentProps<typeof PageLink>) {
  return (
    <PageLink
      aria-label="Go to previous page"
      className={['flex items-center gap-1 pl-2.5', className].filter(Boolean).join(' ')}
      {...props}
    >
      <ArrowLeft className="h4 w-4" />
      <span>Previous</span>
    </PageLink>
  )
}

function PageNext({ className, ...props }: React.ComponentProps<typeof PageLink>) {
  return (
    <PageLink
      aria-label="Go to next page"
      className={['flex items-center gap-1', className].filter(Boolean).join(' ')}
      {...props}
    >
      <span>Next</span>
      <ArrowRight className="h4 w-4" />
    </PageLink>
  )
}

function Ellipsis({ className, ...props }: React.ComponentProps<'span'>) {
  return (
    <span
      aria-hidden
      className={['flex h-9 w-9 items-center justify-center', className].filter(Boolean).join(' ')}
      {...props}
    >
      <Dots className="h-4 w-4" />
      <span className="sr-only">More pages</span>
    </span>
  )
}

The Paginator component will generate all the links that, when clicked, will load the specified page data.

It also sets the state of the links and buttons, whether disabled or not for the next user interaction.

Note: Since URL's can be bookmarked or shared by users, using server-side pagination automatically makes URL's sharable. The query state could include sorting and filtering state params for deep queries on large data sets.

Listing the Orders Data

This is just a helper component used in this example to list the data in a table format.

Orders list component
import React from 'react'
import { Order } from '@payload-types'

export default async function OrdersList({ data: orders = [] }: { data?: Order[] }) {
  return (
    <div className="w-full">
      <h1 className="text-2xl my-3">Your Orders</h1>
      {(!orders || !Array.isArray(orders) || orders?.length === 0) && (
        <div className="flex justify-center p-5 mb-3 font-bold">You have no orders.</div>
      )}

      {orders && (
        <table className="table-auto w-full border border-gray-200">
          <thead className="bg-gray-100">
            <tr>
              <th className="uppercase p-3">Order Number</th>
              <th className="uppercase p-3">Item</th>
              <th className="uppercase p-3">Total</th>
            </tr>
          </thead>
          <tbody>
            {orders.map(({ orderNumber, item, total }) => {
              return (
                <tr>
                  <td className="p-3">{orderNumber}</td>
                  <td className="p-3">{item}</td>
                  <td className="p-3">
                    {total.toLocaleString('en-US', {
                      style: 'currency',
                      currency: 'usd',
                    })}
                  </td>
                </tr>
              )
            })}
          </tbody>
        </table>
      )}
    </div>
  )
}

The Paginator in Action

The final result will show the following page view.

As you click the pagination links at the bottom, you can observe the url in the browser changes in accordance to the current page.

A table listing data loaded from Payload CMS using server-side pagination.

In Conclusion

Using Next.js and Payload CMS is one of my go-to stacks for building scalable applications with the server-side capabilities.

Hopefully this has demystified any complexity of server-side components. It should give you a good idea of how to think from a server-side approach first.

Some key takeaways:

  • Whenever possible, off-load as much fetching and rendering to the server as possible, allowing your client code to be leaner and more focused on UI.
  • Use server-side pagination for any large data sets, or whenever it's possible.
  • Also, leverage search params effectively because they are an excellent way for users to bookmark important page state using the url path.

I hope this has helped.

Topics

SEOLinuxSecuritySSHEmail MarketingMore posts...

Related Posts

Hero image for Boost Payload CMS with Search: Step-by-Step Tutorial
Posted on: August 11 2025
By Dave Becker
Boost Payload CMS with Search: Step-by-Step Tutorial
Hero image for Server-Side Pagination Made Easy in Payload CMS
Posted on: August 11 2025
By Dave Becker
Server-Side Pagination Made Easy in Payload CMS
Hero image for Payload CMS: Getting Started Using the New Join Field
Posted on: August 11 2025
By Dave Becker
Payload CMS: Getting Started Using the New Join Field
Hero image for Maximizing Efficiency: The Power of Payload CMS Blocks
Posted on: August 11 2025
By Dave Becker
Maximizing Efficiency: The Power of Payload CMS Blocks
Hero image for Create Custom Forms Using Payload CMS Form Builder Plugin
Posted on: August 11 2025
By Dave Becker
Create Custom Forms Using Payload CMS Form Builder Plugin
Hero image for Payload CMS SEO Plugin: Boosting Your Site's Search Ranking
Posted on: April 04 2025
By Dave Becker
Payload CMS SEO Plugin: Boosting Your Site's Search Ranking
Hero image for GraphQL Optimization in Payload CMS
Posted on: April 04 2025
By Dave Becker
GraphQL Optimization in Payload CMS
Hero image for Exploring the Game-Changing Features of Payload CMS 3.0
Posted on: April 04 2025
By Dave Becker
Exploring the Game-Changing Features of Payload CMS 3.0
Hero image for Document Nesting With Payload's Nested Docs Plugin
Posted on: April 04 2025
By Dave Becker
Document Nesting With Payload's Nested Docs Plugin
Hero image for Payload CMS Collections: How They Streamline Content Management
Posted on: April 04 2025
By Dave Becker
Payload CMS Collections: How They Streamline Content Management
Trendy Coder Logo
Resources
  • Blog
Website
  • Home
  • About us
Subscribe

Get the latest news and articles to your inbox periodically.

We respect your email privacy

© TrendyCoder.com. All rights reserved.