Ovie Okeh Programming enthusiast, lover of all things that go beep.

Implementing pagination with GraphQL in NestJS

11 min read 3250

Implementing pagination with GraphQL in NestJS

Implementing pagination with GraphQL in NestJS

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:

  1. The pagination algorithm
  2. Setting up an API using NestJS, GraphQL, and Mongoose
  3. Building a React frontend to consume the API

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.

What are we building?

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.

Page through a list of users
This should be getting an AWWWARDS nomination anytime now

The pagination algorithm

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]
Using skip and take to keep track of users
How take and skip work, but visually

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.


More great articles from LogRocket:


Setting up an API using NestJS, GraphQL, and Mongoose

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.

Mongoose schema setup

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 document
  • UserModel represents a single user to be stored in the database
  • UserSchema is a Mongoose schema derived from the UserModel

We’ll be making use of these as we finish setting up the API.

NestJS and GraphQL

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.

Creating the users.service.ts file

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

Creating the users.resolver.ts file

import { 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.

Creating the users.module.ts file

import { 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.

Creating the app.module.ts file

Finally, 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 GraphQL
  • MongooseModule for the database
  • UsersModule for the users resource
  • ConfigModule for setting up environment variables

Now, 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:

  1. Ensure that you’re in the backend directory — cd src/workspaces/backend
  2. Run yarn seed to seed in some fake user data
  3. Run yarn start:dev to start the server on port 3000
  4. Navigate to http://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.

Building a React frontend to consume the API

With our backend all set up, we can now create a shiny React frontend to implement a basic pagination system.

Building the components

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 users
  • Pagination — provides the pagination logic and renders the controls
  • App — renders both Users and Pagination
  • Index — wraps the app in an Apollo provider and renders to the DOM

Writing our users.tsx component

List of names
Just a list of names

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.

The pagination.tsx component

Pagination component
The glorious pagination 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:

  1. count — The total number of users in the database. Used to calculate the number of steps to render in the UI
  2. render — A React component that will receive additional props from the Pagination component

It also has two state variables:

  1. step — The current step in being rendered
  2. amountToFetch — 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:

  1. steps — This does some simple arithmetic to get the number of steps to render
    > if count = 10 users and amountToFetch = 5
    > steps = 2 // < 1 2 >
    > if count = 10 users and amountToFetch = 2
    > steps = 5 // < 1 2 3 4 5 >
  2. renderedSteps — Makes use of steps to render an array of buttons from 1..steps. Each button has an onClick handler that updates the step state
  3. renderWithProps — Clones the component passed in the render prop and adds two new props to it:
    1. skip — how much to skip by when querying the users
    2. amountToFetch — 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:

  1. renderWithProps: The render component cloned with props added
  2. select: 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
  3. button: Allows the user to move back one step
  4. renderedSteps: A list of buttons that allows switching to the corresponding step
  5. button: Allows the user to move forward one step

Again, take some time to breathe, relax, and understand the concepts covered so far. Taking a walk may not be such a bad idea 😉

React and Apollo

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 below
  • The Users and Pagination components
  • A CSS stylesheet that comes with the project

Then 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 —

  • Pass the data.count variable as the count prop
  • Pass the Users component as the render prop

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

  • Ensure that the backend is running (yarn start:dev)
  • Ensure that you’re in workspaces/frontend and run yarn start
  • Navigate to http://localhost:3001
End result is paginated list of users
Tada!

Conclusion

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.

Monitor failed and slow GraphQL requests in production

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.https://logrocket.com/signup/

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. .
Ovie Okeh Programming enthusiast, lover of all things that go beep.

Leave a Reply