Pagination is a common UX problem that comes up in many data-driven applications. We need to limit how much is shown on screen; if my content requires too much scrolling, navigating around my site becomes a painful experience for my users.
In this post, we’ll look at a common way to solve this problem by building a NestJS server with GraphQL and then consume it on a React frontend. By going step by step through the following sections, we will build a simple pagination system that can be applied to all kinds of applications.
This guide will be split into three main sections:
This is a practical guide to implementing a simple pagination system in NestJS and GraphQL. You can improve on the application that we will build in this guide to create something more production-ready.
I recommend coding along to solidify the concepts. All the code written in this guide can be found on my GitHub.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
The application we’ll build is a simple React frontend that allows a user to page through a list of users. It’s just simple enough to easily understand the different concepts we’ll cover, while still practical enough to modify for existing applications.

Before setting up the project, it’s worth it to go through the pagination algorithm we’ll be implementing. This will help you make sense of each part of the project as we start creating files and writing code.
Let’s skip ahead and take a look at the final GraphQL query that we’ll be calling to fetch and paginate the list of users.
{
count
users(take: 20, skip: 0) {
firstName
lastName
}
}
The query consists of two resources, count and users.
The first one, count, as you can probably tell from the name, simply returns a count of all the users in the database. The other resource, users lets us specify how many users we want to retrieve (take), as well as an offset to start fetching from (skip).
How can we implement pagination with this simple query?
Consider a scenario where we have five resources:
[one, two, three, four, five]
If we run the above query with the arguments take = 2, skip = 0, we’ll get the following resources:
[one, two]
And if we ran the same query again, but with the following arguments:
take = 2, skip = 2
we’d get the following resources:
[three, four]

take and skip work, but visuallyBy keeping track of how many users we’ve retrieved on the frontend, we can pass a number to the skip argument to retrieve the correct number of next users. This will become clearer when we implement the frontend.
For now, let’s set up the API to implement the functionality discussed so far.
Usually, we’d start by setting up a fresh NestJS project and installing a few dependencies to get us going.
However, to skip all the painful parts of setting up a project to follow a tutorial, I’ve gone ahead and set up a repository with all the necessary libraries and setup files.
The repository is a monorepo containing both backend and frontend components. This allows us to build both the API and the frontend in a single repo, unlocking extra speed in development time.
It relies on Yarn workspaces, so you’ll need to have both npm and Yarn installed.
Clone the repository and run the following commands to get started.
git clone https://github.com/ovieokeh/graphql-nestjs-pagination-guide.git npm install cd ../workspaces/frontend npm install cd workspaces/backend npm install mkdir src && cd src
If you run any of the commands in the package.json files, they’ll most likely error out. You may also see eslint errors if you have your editor configured. This is fine. We’ll fix these as we work through the guide.
Now that you’ve installed all of the required packages, we can start building the different components of our API.
First, we need to set up a database that will query GraphQL. I’ve decided to go with Mongoose for this guide because it’s one of the most popular database ORMs out there, but you should be able to apply the same concepts with other ORMs.
We’ll start by creating a src/mongoose folder and a src/mongoose/schema.ts file to hold our database types, models, and schema.
mkdir mongoose touch mongoose/schema.ts
Now, let’s configure our schema.ts file.
// src/mongoose/schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { Document } from 'mongoose'
export type UserDocument = UserModel & Document
@Schema()
export class UserModel {
@Prop()
firstName: string
@Prop()
lastName: string
@Prop()
email: string
@Prop()
dateOfBirth: Date
}
export const UserSchema = SchemaFactory.createForClass(UserModel)
UserDocument is a TypeScript type representing a user model and Mongoose documentUserModel represents a single user to be stored in the databaseUserSchema is a Mongoose schema derived from the UserModelWe’ll be making use of these as we finish setting up the API.
Next, we need to create some files and folders, which will be explained as we fill the contents.
mkdir users && cd users mkdir dto entities touch dto/fetch-users.input.ts entities/user.entity.ts
dto/fetch-users.input.ts// dto/fetch-users.input.ts
import { Field, Int, ArgsType } from '@nestjs/graphql'
import { Max, Min } from 'class-validator'
@ArgsType()
export class FetchUsersArgs {
@Field(() => Int)
@Min(0)
skip = 0
@Field(() => Int)
@Min(1)
@Max(50)
take = 25
}
FetchUsersArgs is a data transfer object (DTO), which means that it describes a piece of data being sent over the network. In this case, it’s describing the arguments, skip and take, that we will pass to the API when querying the users.
The next set of files we’ll create are the user service, resolver, and module.
users.service.ts filetouch users.service.ts users.resolver.ts users.module.ts
import { Model } from 'mongoose'
import { Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { UserDocument, UserModel } from '../../mongoose/schema'
import { FetchUsersArgs } from './dto/fetch-users.input'
import { User } from './entities/user.entity'
@Injectable()
export class UsersService {
constructor(
@InjectModel(UserModel.name) private userModel: Model<UserDocument>,
) {}
... continues below (1) ...
NestJS injects the Mongoose database we created earlier into the UsersService class using the @InjectModel decoration. This allows us to query the database using the getCount and findAll methods.
... continues from (1) ...
async getCount(): Promise<number> {
const count = await this.userModel.countDocuments()
return count
}
... continues below (2) ...
UsersService.getCount() is a method that allows us to fetch the total number of users in the database. This count will be useful for implementing the numbered pagination component in the frontend.
... continues from (2) ...
async findAll(args: FetchUsersArgs = { skip: 0, take: 5 }): Promise<User[]> {
const users: User[] = (await this.userModel.find(null, null, {
limit: args.take,
skip: args.skip,
})) as User[]
return users
}
}
UsersService.findAll({ skip, take }) is a method that fetches a specified amount of users (with the take argument) along with an offset (skip).
These two methods form the base of the pagination system we’ll be building.
users.resolver.ts fileimport { Resolver, Query, Args } from '@nestjs/graphql'
import { User } from './entities/user.entity'
import { UsersService } from './users.service'
import { FetchUsersArgs } from './dto/fetch-users.input'
@Resolver(() => User)
export class UsersResolver {
constructor(private readonly usersService: UsersService) {}
@Query(() => Number, { name: 'count' })
async getCount(): Promise<number> {
return this.usersService.getCount()
}
@Query(() => [User], { name: 'users' })
async findAll(@Args() args: FetchUsersArgs): Promise<User[]> {
return this.usersService.findAll(args)
}
}
The UsersResolver class is the GraphQL resolver for the count and users queries. The methods simply call the corresponding UsersService methods.
users.module.ts fileimport { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { UserModel, UserSchema } from '../../mongoose/schema'
import { UsersService } from './users.service'
import { UsersResolver } from './users.resolver'
@Module({
imports: [
MongooseModule.forFeature([{ name: UserModel.name, schema: UserSchema }]),
],
providers: [UsersResolver, UsersService],
})
export class UsersModule {}
The UsersModule class imports the Mongoose schema and configures the resolver and service classes, as defined above. This module gets passed to the main app module and allows for the query defined earlier.
app.module.ts fileFinally, to tie everything together, let’s create an app.module.ts file to consume all the modules we’ve defined so far.
import { Module } from '@nestjs/common'
import { GraphQLModule } from '@nestjs/graphql'
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'
import { MongooseModule } from '@nestjs/mongoose'
import { UsersModule } from './users/users.module'
import { ConfigModule, ConfigService } from '@nestjs/config'
import configuration from '../nest.config'
@Module({
imports: [
UsersModule,
ConfigModule.forRoot({
load: [configuration],
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
uri: configService.get('databaseUrl'),
}),
inject: [ConfigService],
}),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: 'schema.gql',
include: [UsersModule],
}),
],
})
export class AppModule {}
This should all be familiar if you already have experience with GraphQL and NestJS. We’re importing:
GraphQLModule for setting up GraphQLMongooseModule for the databaseUsersModule for the users resourceConfigModule for setting up environment variablesNow, make sure to setup a MongoDB database and create a .env file using the .env.example as a guide before adding your database connection URI.
At this point, you can now test the API by doing the following:
cd src/workspaces/backendyarn seed to seed in some fake user datayarn start:dev to start the server on port 3000http://localhost:3000/graphql on your browser to open the GraphQL playground, where you can try the query from the “The pagination algorithm” section like so:
{
count
users(take: 20, skip: 0) {
firstName
lastName
}
}
If you’ve made it this far, you’re a rock star 😎.
This is a good time to take a break and go through the backend code again. Take your time to understand it, maybe grab a cup of juice (or tea, if you’re fancy), and then continue with the frontend.
With our backend all set up, we can now create a shiny React frontend to implement a basic pagination system.
Instead of setting up a whole new frontend project, you can make use of the workspaces/frontend folder, which already has a React app set up with all the necessary dependencies installed.
cd ../frontend/src
Let’s start with a bottom-up approach to building out the components, before finally integrating it all at the end.
We’ll need the following components:
Users — queries the API and renders a list of usersPagination — provides the pagination logic and renders the controlsApp — renders both Users and PaginationIndex — wraps the app in an Apollo provider and renders to the DOMusers.tsx component
The component will query the GraphQL API using the @apollo/client library and render a list of users when the query is resolved.
// ensure you're in /workspaces/frontend/src touch Users.tsx
Open up the newly created file.
// Users.tsx
import { gql, useQuery } from '@apollo/client'
const GET_USERS = gql`
query GetUsers($skip: Int!, $amountToFetch: Int!) {
users(skip: $skip, take: $amountToFetch) {
id
firstName
lastName
}
}
`
type User = {
id: string
firstName: string
lastName: string
}
... continues below (3) ...
At the top of the file, we import gql and useQuery from the @apollo/client library mentioned earlier.
gql allows us to build a GraphQL query with functionality like dynamic variable replacement. The GET_USERS variable is a query that requests a list of users of length $amountToFetch from an offset $skip .
We’re querying the id , firstName, and lastName properties of each user. The User variable is a TypeScript type that specifies the structure of a user.
... continues from (3) ...
const Users = (props: { skip?: number; amountToFetch?: number }) => {
const { data } = useQuery<{ count: number; users: User[] }>(GET_USERS, {
variables: props,
})
const renderedUsers = data?.users?.map(({ id, firstName, lastName }) => {
const name = `${firstName} ${lastName}`
return (
<div key={id}>
<p>{name}</p>
</div>
)
})
return <div className="Users">{renderedUsers}</div>
}
export default Users
Finally, we have a Users component that accepts two props: skip and amountToFetch.
It immediately kicks off a query to the API the GET_USERS query as well as passing the props as variables.
Then we map over the array of users (using the ternary operator, in case the data isn’t ready yet) and return a div containing the name of each user.
At the end, the return statement completes this component.
pagination.tsx component
Hopefully you’re familiar with the renderProps technique in React. This component utilizes renderProps to render a component with props as well as render a select input and some buttons.
Create a new Pagination.tsx file and open it.
// ensure you're in /workspaces/frontend/src touch Pagination.tsx
We’ll start by importing some types and utilities from React and setup some state variables to track the current state of the pagination component.
import { ChangeEvent, cloneElement, FunctionComponentElement, useState } from 'react'
const Pagination = ({ count, render }: {
count: number
render: FunctionComponentElement<{ skip: number; amountToFetch: number }>
}) => {
const [step, setStep] = useState(0)
const [amountToFetch, setAmountToFetch] = useState(10)
... continues below (4) ...
The Pagination component accepts two props:
count — The total number of users in the database. Used to calculate the number of steps to render in the UIrender — A React component that will receive additional props from the Pagination componentIt also has two state variables:
step — The current step in being renderedamountToFetch — Amount of users to fetch at any given time... continues from (4) ...
const steps = count ? Math.ceil(count / amountToFetch) : 0
const renderedSteps = new Array(steps).fill(0).map((num, index) => (
<button
data-is-active={index === step}
key={index}
type="button"
onClick={() => setStep(index)}
>
{index + 1}
</button>
))
const renderWithProps = cloneElement(render, {
skip: step * amountToFetch,
amountToFetch,
})
... continues below (5) ...
Next, define three variables:
steps — This does some simple arithmetic to get the number of steps to renderrenderedSteps — Makes use of steps to render an array of buttons from 1..steps. Each button has an onClick handler that updates the step staterenderWithProps — Clones the component passed in the render prop and adds two new props to it:
skip — how much to skip by when querying the usersamountToFetch — the amount of users to retrieve ... continues from (5) ...
return (
<>
{renderWithProps}
<select
name="amount to fetch"
id="amountToFetch"
value={amountToFetch}
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
const newAmount = +e.target.value
setAmountToFetch(newAmount)
setStep(0)
}}
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
<button
type="button"
disabled={step === 0}
onClick={() => setStep((prevstep) => prevstep - 1)}
>
{'<'}
</button>
{renderedSteps}
<button
type="button"
disabled={(step + 1) * amountToFetch > count}
onClick={() => setStep((prevstep) => prevstep + 1)}
>
{'>'}
</button>
</>
)
}
export default Pagination
Finally, we render five elements to the DOM:
renderWithProps: The render component cloned with props addedselect: Controls the amountToFetch state variable and allows a user to change how much users to fetch per page. We’ve currently hardcoded three steps of 20, 50, and 100. The onChange handler updates the amountToFetch state and resets the stepbutton: Allows the user to move back one steprenderedSteps: A list of buttons that allows switching to the corresponding stepbutton: Allows the user to move forward one stepAgain, take some time to breathe, relax, and understand the concepts covered so far. Taking a walk may not be such a bad idea 😉
We’re so close to the finish line now! All that remains is to hook up the Users component with the Pagination component and render.
Create an App.tsx file and open it.
// ensure you're in /workspaces/frontend/src touch App.tsx
Here are our file contents:
import { gql, useQuery } from '@apollo/client'
import Users from './Users'
import Pagination from './Pagination'
import './App.css'
const GET_USERS_COUNT = gql`
query GetUsersCount {
count
}
`
function App() {
const { data } = useQuery<{ count: number }>(GET_USERS_COUNT)
return (
<div className="App">
<Pagination count={data?.count || 0} render={(<Users />) as any} />
</div>
)
}
export default App
This is a relatively simple component. We import:
gql and useQuery for a query we will define belowUsers and Pagination componentsThen we define the GET_USERS_COUNT query, which simply requests the total amount of users in the database.
The App function requests the GET_USERS_COUNT query and stores the result in the data variable.
In the return statement, we render the Pagination component in a div and —
data.count variable as the count propUsers component as the render propJust one final piece remains and you’ll be able to test your results in the browser. Whew!
Now, create an index.tsx file and open it.
// ensure you're in /workspaces/frontend/src touch index.tsx
Here again are our file contents:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client'
import App from './App'
import './index.css'
const client = new ApolloClient({
uri: process.env.REACT_APP_API_GRAPHQL_URL,
cache: new InMemoryCache(),
})
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
<ApolloProvider client={client}>
<App />
</ApolloProvider>
</React.StrictMode>
)
Most of the content in this file should feel familiar by now. What’s interesting is that we’re creating a new Apollo client to connect to our API and passing it to the Apollo provider in the root.render statement.
Note: Make sure to create a .env file using the .env.example as a guide, and adding your API URL (most likely http:localhost:3000/graphql).
At this point, you can now start the frontend in the browser and marvel at your creation.
yarn start:dev)workspaces/frontend and run yarn start
Go ahead and interact with the pagination controls. Maybe you can find a way to truncate the middle section or even add some nice styling; this is a base pagination system that you can customize to whatever data type or scenario.
You can find the source code for this article on my GitHub.
If you managed to stay until the end, you deserve a good pat on the back. I know it was a little dense at times but hopefully this was useful to you.
While GraphQL has some features for debugging requests and responses, making sure GraphQL reliably serves resources to your production app is where things get tougher. If you’re interested in ensuring network requests to the backend or third party services are successful, try LogRocket.
LogRocket lets you replay user sessions, eliminating guesswork around why bugs happen by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings — compatible with all frameworks.
LogRocket's Galileo AI watches sessions for you, instantly aggregating and reporting on problematic GraphQL requests to quickly understand the root cause. In addition, you can track Apollo client state and inspect GraphQL queries' key-value pairs.

Compare the top AI development tools and models of November 2025. View updated rankings, feature breakdowns, and find the best fit for you.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the November 5th issue.

A senior developer discusses how developer elitism breeds contempt and over-reliance on AI, and how you can avoid it in your own workplace.

Examine AgentKit, Open AI’s new tool for building agents. Conduct a side-by-side comparison with n8n by building AI agents with each tool.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now
One Reply to "Implementing pagination with GraphQL in NestJS"
Thank you for your article
But there is a problem
count is not dynamic and fetch count all user table. If we want to fetch based on parameters on users, it does not work fine.