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 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]
By 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 UserModel
We’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/backend
yarn 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
componentThe 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
componentHopefully 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 step
button
: 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.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report 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.
LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.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 nowCompare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
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.