Pagination on TanStack table under NEXT.JS

LAI TOCA
5 min readDec 7, 2023
Create by @Image Creator from Microsoft Designer (bing.com)

Using table UI component(s) to display data that query from storage-base (database…) usually would be a good ideal. But if you expect to present a lots amount of data in a single page inside a table would be a bad user experience. The pagination would be a solution to handle this kind of thing.

The story will take advantage of thirty party UI library: TanStack to shown how to do pagination from pulling user data over database under the NEXT.JS.

First, We have API that host on the server component to get the user’s information page by page:

// .../app/api/user/read/route.tsx

import { NextRequest, NextResponse } from "next/server"
import { db } from "@/lib/db"

export async function GET(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams
// totals records for each page
const limit = searchParams?.get("limit") ?? "5"
// skip for the offset
const offset = searchParams?.get("offset") ?? "0"

const allUsers = db.user.findMany({
skip: Number(offset),
take: Number(limit),
select: {
email: true,
username: true,
isVerified: true,
count: true,
lastLogon: true,
createdAt: true
},
orderBy: [{
lastLogon: {
sort: 'desc', nulls: 'last'
}
}]
})
const totals = await db.user.count()

return NextResponse.json({ data: JSON.stringify((await allUsers).map(u => u)), totals: totals })

}
catch (error: any) {
console.error(error.message)
return NextResponse.json({ message: "Something went wrong." }, { status: 500 })
}
}

Key ideal here was the client side will invoke the API with search parameters like: /api/user/read?offset=${offset}&limit=${limit}, so the query should be have information to skip the records and get back the current records (limit) of the page.

Next, we need to defined the table’s columns information:

// .../tables/colums.tsx
import { User } from "@prisma/client"
import { ColumnDef } from "@tanstack/react-table"

export const columns: ColumnDef<User>[] = [
{
accessorKey: "username",
header: "User Name",
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "isVerified",
header: "Verified",
},
{
accessorKey: "createdAt",
header: "Sign Up Time",
},
{
accessorKey: "count",
header: "Count",
},
{
accessorKey: "lastLogon",
header: "Last Logon Time",
}
]

Also defined the paging table layout components:

// .../tables/data-table-pagination.tsx

import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from "@radix-ui/react-icons"

import { type Table } from "@tanstack/react-table"

import { Button } from "@/components/ui/button"


interface DataTablePaginationProps<TData> {
table: Table<TData>
pageSizeOptions?: number[]
}

export function DataTablePagination<TData>({
table,
pageSizeOptions = [10, 20, 30, 40, 50],
}: DataTablePaginationProps<TData>) {
return (
<div className="flex w-full flex-col items-center justify-between gap-4 overflow-auto px-2 py-1 sm:flex-row sm:gap-8">
<div className="flex flex-col items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
aria-label="Go to first page"
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<DoubleArrowLeftIcon className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
aria-label="Go to previous page"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeftIcon className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
aria-label="Go to next page"
variant="outline"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRightIcon className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
aria-label="Go to last page"
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<DoubleArrowRightIcon className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>
</div>
)
}

and the table layout component:

// .../tables/data-table.tsx
"use client"

import {
ColumnDef,
PaginationState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table"

import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import React from "react"
import { DataTablePagination } from "./data-table-pagination"

interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
pageCount: number
}

export function DataTable<TData, TValue>({
columns,
data,
pageCount
}: DataTableProps<TData, TValue>) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()

// search params
const page = searchParams?.get("page") ?? "1" // default is page: 1
const per_page = searchParams?.get("per_page") ?? "5" // default 5 record per page

// create query string
const createQueryString = React.useCallback(
(params: Record<string, string | number | null>) => {
const newSearchParams = new URLSearchParams(searchParams?.toString())

for (const [key, value] of Object.entries(params)) {
if (value === null) {
newSearchParams.delete(key)
} else {
newSearchParams.set(key, String(value))
}
}

return newSearchParams.toString()
},
[searchParams]
)

// handle server-side pagination
const [{ pageIndex, pageSize }, setPagination] =
React.useState<PaginationState>({
pageIndex: Number(page) - 1,
pageSize: Number(per_page),
})

const pagination = React.useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
)

React.useEffect(() => {
setPagination({
pageIndex: Number(page) - 1,
pageSize: Number(per_page),
})
}, [page, per_page])

// changed the route as well
React.useEffect(() => {
router.push(
`${pathname}?${createQueryString({
page: pageIndex + 1,
per_page: pageSize,
})}`
)
}, [pageIndex, pageSize])

const table = useReactTable({
data,
columns,
pageCount: pageCount ?? -1,
state: {
pagination
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: true,
})

return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<DataTablePagination table={table} />
</div>
)
}

Final, in the target page, import the relatived “DataTable” and “columns”:


// .../user/statictis/page.tsx

import { DataTable } from "./data-table"
import { columns } from "./columns"

import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { User } from ".prisma/client"

export const revalidate = 0 // no cache

interface IndexPageProps {
searchParams: {
[key: string]: string | string[] | undefined
}
}

const page = async ({ searchParams }: IndexPageProps) => {

const { page, per_page } = searchParams
// calculate limit and offset according page and per_page records
const limit = typeof per_page === "string" ? parseInt(per_page) : 10
const offset = typeof page === "string" ? parseInt(page) > 0 ? (parseInt(page) - 1) * limit : 0 : 0

const getUsers = async () => {
try {
const usersRes = await fetch(`${process.env.NEXTAUTH_URL}/api/user/read?offset=${offset}&limit=${limit}`, {
method: 'GET'
})

if (!usersRes.ok) throw new Error('Retrieved users failed')

const { data, totals } = await usersRes.json()
return { data: data, totals: totals }
}
catch (error: any) {
console.error(error.message)
}
}

const pageCount = Math.ceil(totals / limit)
return (
<>
{users.length > 0 && (
<div className="container mx-auto py-10">
<DataTable columns={columns} data={users} pageCount={pageCount} />
</div>
)}
{users.length === 0 && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Sorry we could not retrieve users data at this moment.
</AlertDescription>
</Alert>
)}
</>
)
}

export default page

The key concept:

  • While directing to the page with query parameters: /user/statictis?page=1&per_page=${process.env.NEXT_PUBLIC_USER_STATISTICS_TABLE_ROWS_PER_PAGE}, the initial process will calculate the ‘offset’ and ‘limit’, then passing them to API for retrieving user information for the table.
  • Monitoring the parameters changes for ‘page’ and ‘per_page’ to trigger the function of setPagination.

Reference

--

--

LAI TOCA

Coding for fun. (Either you are running for food or running for being food.)