Cursor-based pagination for infinite scrolling using Next 13, Tailwind, Postgres and Prisma

Njihia Mark
8 min readMay 15, 2023

In this article, we will look at cursor-based pagination using Postgres and Prisma and consume data from the pagination using Next.js 13, to implement infinite scrolling.

What is Cursor Pagination

Cursors are tokens or values you pass to the database so it knows where to continue serving results. Imagine it like a bookmark in the results, giving the database a hint of which row it last returned.

Cursor-based pagination will allow your application’s users to specify how many items they want to receive, then return both the data and a cursor that you need to specify for the next request, this process repeats itself until no more items are available.

Why use cursors for pagination

  • Improved consistency because the cursor is based on an actual value instead of just a number as is in offset-based pagination
  • Improved performance/scalability for large databases where a cursor is used since a full dataset scan is not required as in offset-based pagination

Bootstrapping our Next.js 13 project

To bootstrap a Next.js application, run the following command npx create-next-app@latest

The default options from the prompt will suffice for this project. It’s awesome they have support for Tailwind.

To start the project, run the command npm run dev and you should see the page below.

Setting up Prisma

Next, we will set up Prisma and connect it to our PostgreSQL database. Start by installing the Prisma CLI as a development dependency via npm install prisma — save-dev

To initialize Prisma within the project we will run the command npx prisma init which will create a new /prisma directory and inside it, you will find a schema.prisma file. A .env (dotenv) file is also added to the root of the project. Remember to add the .envfile to the .gitignore file.

Open the .env file and replace the dummy connection URL with the connection URL of your PostgreSQL database.

#env
DATABASE_URL="postgresql://postgres:password@localhost:5432/next_cursor?schema=public"

Defining the models

Inside the /prisma/schema.prisma file, we will create a user model. Let us update the /prisma/schema.prisma file as follows:

// /prisma/schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
provider = "prisma-client-js"
}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model User {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
name String
email String @unique
avatar String?
}

To create the table in our database, we will npx prisma migrate dev -name init.

This command does the following things:

  • Generate a new SQL migration called init
  • Apply the migration to the database
  • Install Prisma Client if it’s not yet installed
  • Generate Prisma Client based on the current schema

Inside the prisma directory, you will notice a new folder called migrations. It should also contain another folder that ends with init and contains a file called migration.sql. The migration.sql file contains the generated SQL.

Single Instance PrismaClient

To create a single PrismaClient instance that can be imported anywhere it’s needed, we create a /lib/prisma.ts file inside the root directory.

Now, let us add the code below to the /lib/prisma.ts file to create the instance.

// /lib/prisma.ts

import { PrismaClient } from "@prisma/client";

declare global {
var cachedPrisma: PrismaClient;
}

let prisma: PrismaClient;

if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
if (!global.cachedPrisma) {
global.cachedPrisma = new PrismaClient();
}
prisma = global.cachedPrisma;
}

export default prisma;

Seeding the database

Since the database is currently empty, we want to populate it with data to use on the client. We will use PrismaClient to interact with the database and populate it with dummy data from Faker.js. Let us install Faker.js using the command npm install @faker-js/faker — save-dev .

Let us create a new file /prisma/seed.ts and update it with the following code:

// /prisma/seed.ts

import prisma from "@/lib/prisma";
import { faker } from "@faker-js/faker";

const fakerUser = () => ({
email: faker.internet.email(),
name: faker.person.fullName(),
avatar: faker.image.avatar(),
});

async function main() {
console.log("Seeding...");
const users = Array.from({ length: 400 }, fakerUser);
await prisma.user.createMany({ data: users });
console.log("Seeded!");
}

main().catch((e) => {
console.error(e);
process.exit(1);
}).finally(async () => {
await prisma.$disconnect();
});

To easily run the /prisma/seed.ts file, we will instal tsx using the command npm i -D tsx .

Update your package.json file by adding a prisma key with a seed property defining the script for seeding the database:

{
...
"prisma": {
"seed": "tsx ./prisma/seed.ts"
}
}

To seed the database run the npx prisma db seed command. If there were no errors, you can run the command npx prisma studio to view the seeded data in the database.

Defining a REST API endpoint to fetch the users

Next, we will implement a REST API endpoint to fetch the users' data and paginate it using cursors. Inside the /app directory, we will create a /api/users/route.ts file. This will create the endpoint http://localhost:3000/api/users

Let us update the /api/users/route.ts file with the following code:

// /app/api/users/route.ts

import prisma from "@/lib/prisma";

export async function GET(req: Request){
try {
// get page and lastCursor from query
const url = new URL(req.url);

const take = url.searchParams.get("take");
const lastCursor = url.searchParams.get("lastCursor");

let result = await prisma.user.findMany({
take: take ? parseInt(take as string) : 10,
...(lastCursor && {
skip: 1, // Do not include the cursor itself in the query result.
cursor: {
id: lastCursor as string,
}
}),
orderBy: {
createdAt: "desc",
}
}
);


if (result.length == 0) {
return new Response(JSON.stringify({
data: [],
metaData: {
lastCursor: null,
hasNextPage: false,
},
}), { status: 200 });
}

const lastPostInResults: any = result[result.length - 1];
const cursor: any = lastPostInResults.id;

const nextPage = await prisma.user.findMany({
// Same as before, limit the number of events returned by this query.
take: take ? parseInt(take as string) : 7,
skip: 1, // Do not include the cursor itself in the query result.
cursor: {
id: cursor,
},
});

const data = {
data: result, metaData: {
lastCursor: cursor,
hasNextPage: nextPage.length > 0,
}
};

return new Response(JSON.stringify(data), { status: 200 });
} catch (error: any) {
return new Response(JSON.stringify(JSON.stringify({ error: error.message })), { status: 403 });
}
}

The gif below shows testing the endpoint we just created. We can run the app by using the command npm run dev.

testing the endpoint

Let's build the frontend

In the previous sections, we focused on the backend. In the following sub-sections, we are going to focus on the frontend.

Installing a few dependencies

Let us run this command npm install @tanstack/react-query react-intersection-observer axios . We will use these dependencies in the frontend to help with the following:

  • React Query — manage server state in the frontend and also assist in infinite scrolling
  • React Intersection Observer — Helps us to know when a given element is in view
  • Axios — Gives us a simple API to get/push data to the server

Preparing the layout

Update the /app/globals.css file as this file here.

React Query needs to be wrapped in a provider for it to work. Let us create a React Query wrapper in the file /app/wrapper/query-wrapper.tsx as follows:

// /app/wrapper/query-wrapper.tsx

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

interface Props {
children: React.ReactNode;
}

const queryClient = new QueryClient();

const QueryWrapper = ({ children }: Props) => {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};

export default QueryWrapper;

We will use this wrapper inside the /app/layout.tsx file, let us update this file as follows:

// /app/layout.tsx

import "./globals.css";
import { Poppins } from "next/font/google";

import QueryWrapper from "./components/wrapper/query-wrapper";

const poppins = Poppins({ weight: "400", style: "normal", subsets: ["latin"] });

export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<QueryWrapper>
<body className={poppins.className}>{children}</body>
</QueryWrapper>
</html>
);
}

Building the user card

We will use a card to display a user on the UI. Let us make this component inside the /app/components/cards/user-card/index.tsx file as follows:

// /app/components/cards/user-card/index.tsx

import Image from "next/image";

type UserCardProps = {
name: string;
email: string;
avatar: string;
};

const UserCard = ({name, email, avatar}: UserCardProps) => {
return (
<div className="relative flex flex-col min-w-0 break-words bg-white w-full mb-6 shadow-lg rounded-lg p-4">
<div className="flex items-center">
<Image
alt="profile pic"
src={avatar}
className="rounded-full object-cover h-10 w-10 mr-3"
width={40}
height={40}
/>
<div>
<div className="font-medium text-gray-700">{name}</div>
<div className="text-gray-400">{email}</div>
</div>
</div>
</div>
);
};

export default UserCard;

Fetching the users

Next, we will fetch the 400 users and paginate the data as one scrolls the page on the browser.

Let us implement this inside the /app/components/users/users.tsx file as follows:

// /app/components/users/users.tsx

"use client";

import { useEffect } from "react";
import axios from "axios";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";

import UserCard from "../cards/user-card";

type UserQueryParams = {
take?: number;
lastCursor?: string;
};

const allUsers = async ({ take, lastCursor }: UserQueryParams) => {
const response = await axios.get("/api/users", {
params: { take, lastCursor },
});
return response?.data;
};

type UsersType = {
id: string;
name: string;
email: string;
avatar: string;
};

const Users = () => {
// to know when the last element is in view
const { ref, inView } = useInView();

// useInfiniteQuery is a hook that accepts a queryFn and queryKey and returns the result of the queryFn
const {
data,
error,
isLoading,
hasNextPage,
fetchNextPage,
isSuccess,
isFetchingNextPage,
} = useInfiniteQuery({
queryFn: ({ pageParam = "" }) =>
allUsers({ take: 10, lastCursor: pageParam }),
queryKey: ["users"],
// getNextPageParam is used to get the cursor of the last element in the current page
// which is then used as the pageParam in the queryFn
getNextPageParam: (lastPage) => {
return lastPage?.metaData.lastCursor;
},
});

useEffect(() => {
// if the last element is in view and there is a next page, fetch the next page
if (inView && hasNextPage) {
fetchNextPage();
}
}, [hasNextPage, inView, fetchNextPage]);

if (error as any)
return (
<div className="mt-10">
{"An error has occurred: " + (error as any).message}
</div>
);

// console.log("data:",data);

return (
<div className="mt-10">
{isSuccess &&
data?.pages.map((page) =>
page.data.map((user: UsersType, index: number) => {
// if the last element in the page is in view, add a ref to it
if (page.data.length === index + 1) {
return (
<div ref={ref} key={index}>
<UserCard
key={user.id}
name={user.name}
email={user.email}
avatar={user.avatar}
/>
</div>
);
} else {
return (
<UserCard
key={user.id}
name={user.name}
email={user.email}
avatar={user.avatar}
/>
);
}
})
)}

{(isLoading || isFetchingNextPage) && <p className="mb-4">Loading...</p>}
</div>
);
};

export default Users;

Lastly, let us use the component we just created above inside the /app/page.tsx file as follows:

// /app/page.tsx

import Users from "./components/users/users";
export default function Home() {
return (
<section className="pt-10">
<div className="container mx-auto px-2">
<div className="flex content-center items-center justify-center">
<div className="w-full lg:w-6/12 px-4">
<Users />
</div>
</div>
</div>
</section>
);
}

We have completed building our application. You can find the Github repo of the application here. Below is a clip of a user scrolling through the data.

Until Next time, peace, love and blessings!

--

--