3 ways do to Paging in NextJS App Router
Learn 3 different way do to Paging in NextJS App Router. First we explore simple Previous/Next using Link and query strings, next we add enhancements to make it Forward Only, Load More using Link and query strings and finally we convert it to Infinite Scroll paging using Server Actions!
Content
Stater Project
Create a NextJS app using
npx create-next-app@latest
npx create-next-app@latest
Name your project "nextjs-paging" and accept rest of the defaults.
Seed
Create a new top level directory called “data” and create a file inventory.json
. Copy following inventory items, which will be used to display list.
[
{
"toolId": 1,
"toolName": "Hammer",
"manufacturer": "Stanley",
"model": "STHT51304",
"barcode": "0123456789012",
"photo": "hammer.jpg"
},
{
"toolId": 2,
"toolName": "Screwdriver",
"manufacturer": "Craftsman",
"model": "CMHT65045",
"barcode": "1122334455667",
"photo": "screwdriver.jpg"
},
{
"toolId": 3,
"toolName": "Pliers",
"manufacturer": "Klein Tools",
"model": "D2000-28",
"barcode": "9876543210987",
"photo": "pliers.jpg"
},
{
"toolId": 4,
"toolName": "Level",
"manufacturer": "DeWalt",
"model": "DWHT43003",
"barcode": "5432109876543",
"photo": "level.jpg"
},
{
"toolId": 5,
"toolName": "Tape measure",
"manufacturer": "Milwaukee",
"model": "48-22-6616",
"barcode": "8765432109876",
"photo": "tape_measure.jpg"
},
{
"toolId": 6,
"toolName": "Circular saw",
"manufacturer": "Makita",
"model": "XSH03Z",
"barcode": "6543210987654",
"photo": "circular_saw.jpg"
},
{
"toolId": 7,
"toolName": "Drill",
"manufacturer": "Ryobi",
"model": "P208B",
"barcode": "3210987654321",
"photo": "drill.jpg"
},
{
"toolId": 8,
"toolName": "Chainsaw",
"manufacturer": "Husqvarna",
"model": "460 Rancher",
"barcode": "0987654321098",
"photo": "chainsaw.jpg"
},
{
"toolId": 9,
"toolName": "Angle grinder",
"manufacturer": "Bosch",
"model": "GWS13-50VSP",
"barcode": "7654321098765",
"photo": "angle_grinder.jpg"
},
{
"toolId": 10,
"toolName": "Wrench set",
"manufacturer": "GearWrench",
"model": "9412",
"barcode": "2345678901234",
"photo": "wrench_set.jpg"
},
{
"toolId": 11,
"toolName": "Air compressor",
"manufacturer": "California Air Tools",
"model": "10020C",
"barcode": "3456789012345",
"photo": "air_compressor.jpg"
},
{
"toolId": 12,
"toolName": "Miter saw",
"manufacturer": "Delta",
"model": "S26-261L",
"barcode": "4567890123456",
"photo": "miter_saw.jpg"
},
{
"toolId": 13,
"toolName": "Impact driver",
"manufacturer": "Porter-Cable",
"model": "PCCK647LB",
"barcode": "5678901234567",
"photo": "impact_driver.jpg"
},
{
"toolId": 14,
"toolName": "Jigsaw",
"manufacturer": "Black+Decker",
"model": "BDEJS600C",
"barcode": "6789012345678",
"photo": "jigsaw.jpg"
},
{
"toolId": 15,
"toolName": "Power drill",
"manufacturer": "Dewalt",
"model": "DCD771C2",
"barcode": "7890123456789",
"photo": "power_drill.jpg"
},
{
"toolId": 16,
"toolName": "Belt sander",
"manufacturer": "Makita",
"model": "9403",
"barcode": "8901234567890",
"photo": "belt_sander.jpg"
},
{
"toolId": 17,
"toolName": "Hacksaw",
"manufacturer": "Lenox",
"model": "12132HT50",
"barcode": "9012345678901",
"photo": "hacksaw.jpg"
},
{
"toolId": 18,
"toolName": "Rotary hammer",
"manufacturer": "Bosch",
"model": "RH328VC",
"barcode": "0123456789098",
"photo": "rotary_hammer.jpg"
},
{
"toolId": 19,
"toolName": "Hand saw",
"manufacturer": "Irwin Tools",
"model": "213103",
"barcode": "1234567890123",
"photo": "hand_saw.jpg"
},
{
"toolId": 20,
"toolName": "Cordless screwdriver",
"manufacturer": "Black+Decker",
"model": "BDCS20C",
"barcode": "2345678901234",
"photo": "cordless_screwdriver.jpg"
},
{
"toolId": 21,
"toolName": "Hammer",
"manufacturer": "Stanley",
"model": "STHT51304",
"barcode": "123456789",
"photo": "hammer.jpg"
},
{
"toolId": 22,
"toolName": "Screwdriver",
"manufacturer": "Klein Tools",
"model": "32500",
"barcode": "987654321",
"photo": "screwdriver.jpg"
},
{
"toolId": 23,
"toolName": "Adjustable Wrench",
"manufacturer": "Channellock",
"model": "804",
"barcode": "456789123",
"photo": "wrench.jpg"
},
{
"toolId": 24,
"toolName": "Tape Measure",
"manufacturer": "Lufkin",
"model": "L625SCTMP",
"barcode": "321654987",
"photo": "tape_measure.jpg"
},
{
"toolId": 25,
"toolName": "Utility Knife",
"manufacturer": "Lenox",
"model": "20353SSRK1",
"barcode": "987321654",
"photo": "utility_knife.jpg"
},
{
"toolId": 26,
"toolName": "Pliers",
"manufacturer": "IRWIN",
"model": "2078209",
"barcode": "654789321",
"photo": "pliers.jpg"
},
{
"toolId": 27,
"toolName": "Level",
"manufacturer": "Empire",
"model": "EM81.9",
"barcode": "321789654",
"photo": "level.jpg"
},
{
"toolId": 28,
"toolName": "Chisel",
"manufacturer": "DEWALT",
"model": "DWHT16063",
"barcode": "789321654",
"photo": "chisel.jpg"
},
{
"toolId": 29,
"toolName": "Cordless Drill",
"manufacturer": "Makita",
"model": "XPH102",
"barcode": "456321789",
"photo": "drill.jpg"
},
{
"toolId": 30,
"toolName": "Crescent Wrench",
"manufacturer": "Craftsman",
"model": "AC610C",
"barcode": "987123456",
"photo": "crescent_wrench.jpg"
},
{
"toolId": 31,
"toolName": "Hacksaw",
"manufacturer": "Lenox",
"model": "20122-218HE",
"barcode": "654321987",
"photo": "hacksaw.jpg"
},
{
"toolId": 32,
"toolName": "Socket Set",
"manufacturer": "TEKTON",
"model": "1354",
"barcode": "789654321",
"photo": "socket_set.jpg"
},
{
"toolId": 33,
"toolName": "Carpenter Square",
"manufacturer": "Swanson Tool",
"model": "T0118",
"barcode": "321987654",
"photo": "square.jpg"
},
{
"toolId": 34,
"toolName": "Wire Stripper",
"manufacturer": "Klein Tools",
"model": "11054",
"barcode": "654987",
"photo": "wire_stripper.jpg"
},
{
"toolId": 35,
"toolName": "Wire Stripper",
"manufacturer": "Klein Tools",
"model": "11054",
"barcode": "654987321",
"photo": "wire_stripper.jpg"
},
{
"toolId": 36,
"toolName": "Caulking Gun",
"manufacturer": "Dripless Inc.",
"model": "E10",
"barcode": "987456321",
"photo": "caulking_gun.jpg"
},
{
"toolId": 37,
"toolName": "Claw Hammer",
"manufacturer": "Estwing",
"model": "E3-16C",
"barcode": "789654123",
"photo": "claw_hammer.jpg"
},
{
"toolId": 38,
"toolName": "Utility Bar",
"manufacturer": "Stanley",
"model": "55-120",
"barcode": "321456789",
"photo": "utility_bar.jpg"
},
{
"toolId": 39,
"toolName": "Safety Glasses",
"manufacturer": "3M",
"model": "SF201AF",
"barcode": "123987654",
"photo": "safety_glasses.jpg"
},
{
"toolId": 40,
"toolName": "Hacksaw",
"manufacturer": "IRWIN",
"model": "218HP300",
"barcode": "456789321",
"photo": "hacksaw.jpg"
}
]
[
{
"toolId": 1,
"toolName": "Hammer",
"manufacturer": "Stanley",
"model": "STHT51304",
"barcode": "0123456789012",
"photo": "hammer.jpg"
},
{
"toolId": 2,
"toolName": "Screwdriver",
"manufacturer": "Craftsman",
"model": "CMHT65045",
"barcode": "1122334455667",
"photo": "screwdriver.jpg"
},
{
"toolId": 3,
"toolName": "Pliers",
"manufacturer": "Klein Tools",
"model": "D2000-28",
"barcode": "9876543210987",
"photo": "pliers.jpg"
},
{
"toolId": 4,
"toolName": "Level",
"manufacturer": "DeWalt",
"model": "DWHT43003",
"barcode": "5432109876543",
"photo": "level.jpg"
},
{
"toolId": 5,
"toolName": "Tape measure",
"manufacturer": "Milwaukee",
"model": "48-22-6616",
"barcode": "8765432109876",
"photo": "tape_measure.jpg"
},
{
"toolId": 6,
"toolName": "Circular saw",
"manufacturer": "Makita",
"model": "XSH03Z",
"barcode": "6543210987654",
"photo": "circular_saw.jpg"
},
{
"toolId": 7,
"toolName": "Drill",
"manufacturer": "Ryobi",
"model": "P208B",
"barcode": "3210987654321",
"photo": "drill.jpg"
},
{
"toolId": 8,
"toolName": "Chainsaw",
"manufacturer": "Husqvarna",
"model": "460 Rancher",
"barcode": "0987654321098",
"photo": "chainsaw.jpg"
},
{
"toolId": 9,
"toolName": "Angle grinder",
"manufacturer": "Bosch",
"model": "GWS13-50VSP",
"barcode": "7654321098765",
"photo": "angle_grinder.jpg"
},
{
"toolId": 10,
"toolName": "Wrench set",
"manufacturer": "GearWrench",
"model": "9412",
"barcode": "2345678901234",
"photo": "wrench_set.jpg"
},
{
"toolId": 11,
"toolName": "Air compressor",
"manufacturer": "California Air Tools",
"model": "10020C",
"barcode": "3456789012345",
"photo": "air_compressor.jpg"
},
{
"toolId": 12,
"toolName": "Miter saw",
"manufacturer": "Delta",
"model": "S26-261L",
"barcode": "4567890123456",
"photo": "miter_saw.jpg"
},
{
"toolId": 13,
"toolName": "Impact driver",
"manufacturer": "Porter-Cable",
"model": "PCCK647LB",
"barcode": "5678901234567",
"photo": "impact_driver.jpg"
},
{
"toolId": 14,
"toolName": "Jigsaw",
"manufacturer": "Black+Decker",
"model": "BDEJS600C",
"barcode": "6789012345678",
"photo": "jigsaw.jpg"
},
{
"toolId": 15,
"toolName": "Power drill",
"manufacturer": "Dewalt",
"model": "DCD771C2",
"barcode": "7890123456789",
"photo": "power_drill.jpg"
},
{
"toolId": 16,
"toolName": "Belt sander",
"manufacturer": "Makita",
"model": "9403",
"barcode": "8901234567890",
"photo": "belt_sander.jpg"
},
{
"toolId": 17,
"toolName": "Hacksaw",
"manufacturer": "Lenox",
"model": "12132HT50",
"barcode": "9012345678901",
"photo": "hacksaw.jpg"
},
{
"toolId": 18,
"toolName": "Rotary hammer",
"manufacturer": "Bosch",
"model": "RH328VC",
"barcode": "0123456789098",
"photo": "rotary_hammer.jpg"
},
{
"toolId": 19,
"toolName": "Hand saw",
"manufacturer": "Irwin Tools",
"model": "213103",
"barcode": "1234567890123",
"photo": "hand_saw.jpg"
},
{
"toolId": 20,
"toolName": "Cordless screwdriver",
"manufacturer": "Black+Decker",
"model": "BDCS20C",
"barcode": "2345678901234",
"photo": "cordless_screwdriver.jpg"
},
{
"toolId": 21,
"toolName": "Hammer",
"manufacturer": "Stanley",
"model": "STHT51304",
"barcode": "123456789",
"photo": "hammer.jpg"
},
{
"toolId": 22,
"toolName": "Screwdriver",
"manufacturer": "Klein Tools",
"model": "32500",
"barcode": "987654321",
"photo": "screwdriver.jpg"
},
{
"toolId": 23,
"toolName": "Adjustable Wrench",
"manufacturer": "Channellock",
"model": "804",
"barcode": "456789123",
"photo": "wrench.jpg"
},
{
"toolId": 24,
"toolName": "Tape Measure",
"manufacturer": "Lufkin",
"model": "L625SCTMP",
"barcode": "321654987",
"photo": "tape_measure.jpg"
},
{
"toolId": 25,
"toolName": "Utility Knife",
"manufacturer": "Lenox",
"model": "20353SSRK1",
"barcode": "987321654",
"photo": "utility_knife.jpg"
},
{
"toolId": 26,
"toolName": "Pliers",
"manufacturer": "IRWIN",
"model": "2078209",
"barcode": "654789321",
"photo": "pliers.jpg"
},
{
"toolId": 27,
"toolName": "Level",
"manufacturer": "Empire",
"model": "EM81.9",
"barcode": "321789654",
"photo": "level.jpg"
},
{
"toolId": 28,
"toolName": "Chisel",
"manufacturer": "DEWALT",
"model": "DWHT16063",
"barcode": "789321654",
"photo": "chisel.jpg"
},
{
"toolId": 29,
"toolName": "Cordless Drill",
"manufacturer": "Makita",
"model": "XPH102",
"barcode": "456321789",
"photo": "drill.jpg"
},
{
"toolId": 30,
"toolName": "Crescent Wrench",
"manufacturer": "Craftsman",
"model": "AC610C",
"barcode": "987123456",
"photo": "crescent_wrench.jpg"
},
{
"toolId": 31,
"toolName": "Hacksaw",
"manufacturer": "Lenox",
"model": "20122-218HE",
"barcode": "654321987",
"photo": "hacksaw.jpg"
},
{
"toolId": 32,
"toolName": "Socket Set",
"manufacturer": "TEKTON",
"model": "1354",
"barcode": "789654321",
"photo": "socket_set.jpg"
},
{
"toolId": 33,
"toolName": "Carpenter Square",
"manufacturer": "Swanson Tool",
"model": "T0118",
"barcode": "321987654",
"photo": "square.jpg"
},
{
"toolId": 34,
"toolName": "Wire Stripper",
"manufacturer": "Klein Tools",
"model": "11054",
"barcode": "654987",
"photo": "wire_stripper.jpg"
},
{
"toolId": 35,
"toolName": "Wire Stripper",
"manufacturer": "Klein Tools",
"model": "11054",
"barcode": "654987321",
"photo": "wire_stripper.jpg"
},
{
"toolId": 36,
"toolName": "Caulking Gun",
"manufacturer": "Dripless Inc.",
"model": "E10",
"barcode": "987456321",
"photo": "caulking_gun.jpg"
},
{
"toolId": 37,
"toolName": "Claw Hammer",
"manufacturer": "Estwing",
"model": "E3-16C",
"barcode": "789654123",
"photo": "claw_hammer.jpg"
},
{
"toolId": 38,
"toolName": "Utility Bar",
"manufacturer": "Stanley",
"model": "55-120",
"barcode": "321456789",
"photo": "utility_bar.jpg"
},
{
"toolId": 39,
"toolName": "Safety Glasses",
"manufacturer": "3M",
"model": "SF201AF",
"barcode": "123987654",
"photo": "safety_glasses.jpg"
},
{
"toolId": 40,
"toolName": "Hacksaw",
"manufacturer": "IRWIN",
"model": "218HP300",
"barcode": "456789321",
"photo": "hacksaw.jpg"
}
]
Basic Paging
Let see how to page over data using basic Previous and Next buttons to navigate. We will utilize Link
to provide navigable link to next and previous pages with query string parameter page
to indicate which page to show.
Find app\page.tsx
and replace function with following:
import data from '@/data/inventory.json';
import { ToolList, Tool } from '@/app/tool-list';
const pageSize = 10;
export async function getData(page: number): Promise<{
items: Tool[];
total: number;
}> {
return {
items: data.slice(page * pageSize, page * pageSize + pageSize),
total: data.length / pageSize,
};
}
export default async function Page({
searchParams,
}: {
searchParams: {
page: string;
};
}) {
const page = +(searchParams?.page ?? 0);
const { items, total } = await getData(page);
return <ToolList items={items} page={page} total={total} />;
}
import data from '@/data/inventory.json';
import { ToolList, Tool } from '@/app/tool-list';
const pageSize = 10;
export async function getData(page: number): Promise<{
items: Tool[];
total: number;
}> {
return {
items: data.slice(page * pageSize, page * pageSize + pageSize),
total: data.length / pageSize,
};
}
export default async function Page({
searchParams,
}: {
searchParams: {
page: string;
};
}) {
const page = +(searchParams?.page ?? 0);
const { items, total } = await getData(page);
return <ToolList items={items} page={page} total={total} />;
}
Add a new file app\tool-list.tsx
and copy following code:
import Link from 'next/link';
import { Button } from '@/components/ui/button';
export interface Tool {
toolId: number;
toolName: string;
manufacturer: string;
model: string;
barcode: string;
}
export function ToolList({
items,
total,
page,
}: {
items: Tool[];
total: number;
page: number;
}) {
const canGetPrevious = page > 0;
const canGetNext = page < total - 1;
return (
<div className="flex flex-col gap-16 m-16">
<div className="flex flex-wrap gap-4 w-full overflow-y-auto ">
{items.map((item) => (
<ToolCard item={item} key={item.toolId} />
))}
</div>
<div className="flex flex-row items-center gap-4">
<Button
variant="outline"
className="w-24"
{...(canGetPrevious ? { asChild: true } : { disabled: true })}
>
<Link
href={{
pathname: '/',
query: { page: page - 1 },
}}
>
Previous
</Link>
</Button>
<div className="w-24 text-center">
Page {page + 1} / {total}
</div>
<Button
variant="outline"
className="w-24"
{...(canGetNext ? { asChild: true } : { disabled: true })}
>
<Link
href={{
pathname: '/',
query: { page: page + 1 },
}}
>
Next
</Link>
</Button>
</div>
</div>
);
}
export function ToolCard({ item }: { item: Tool }) {
return (
<div
key={item.toolId}
className="flex flex-row flex-nowrap gap-3 p-4 rounded-xl max-w-sm w-full bg-background border"
>
<div
className=" text-2xl font-semibold border rounded-full w-12 h-12 flex
justify-center items-center bg-muted "
>
{item.toolName.substring(0, 1)}
</div>
<div className="flex flex-col gap-2">
<h1 className="font-medium text-lg">{item.toolName}</h1>
<h2 className="text-base ">{item.manufacturer}</h2>
<p className="text-sm text-muted-foreground ">{item.model}</p>
<p className="text-sm text-muted-foreground ">{item.barcode}</p>
</div>
</div>
);
}
import Link from 'next/link';
import { Button } from '@/components/ui/button';
export interface Tool {
toolId: number;
toolName: string;
manufacturer: string;
model: string;
barcode: string;
}
export function ToolList({
items,
total,
page,
}: {
items: Tool[];
total: number;
page: number;
}) {
const canGetPrevious = page > 0;
const canGetNext = page < total - 1;
return (
<div className="flex flex-col gap-16 m-16">
<div className="flex flex-wrap gap-4 w-full overflow-y-auto ">
{items.map((item) => (
<ToolCard item={item} key={item.toolId} />
))}
</div>
<div className="flex flex-row items-center gap-4">
<Button
variant="outline"
className="w-24"
{...(canGetPrevious ? { asChild: true } : { disabled: true })}
>
<Link
href={{
pathname: '/',
query: { page: page - 1 },
}}
>
Previous
</Link>
</Button>
<div className="w-24 text-center">
Page {page + 1} / {total}
</div>
<Button
variant="outline"
className="w-24"
{...(canGetNext ? { asChild: true } : { disabled: true })}
>
<Link
href={{
pathname: '/',
query: { page: page + 1 },
}}
>
Next
</Link>
</Button>
</div>
</div>
);
}
export function ToolCard({ item }: { item: Tool }) {
return (
<div
key={item.toolId}
className="flex flex-row flex-nowrap gap-3 p-4 rounded-xl max-w-sm w-full bg-background border"
>
<div
className=" text-2xl font-semibold border rounded-full w-12 h-12 flex
justify-center items-center bg-muted "
>
{item.toolName.substring(0, 1)}
</div>
<div className="flex flex-col gap-2">
<h1 className="font-medium text-lg">{item.toolName}</h1>
<h2 className="text-base ">{item.manufacturer}</h2>
<p className="text-sm text-muted-foreground ">{item.model}</p>
<p className="text-sm text-muted-foreground ">{item.barcode}</p>
</div>
</div>
);
}
👉 Please install Button component from shadcn/ui
First we are loading data for current page using async getData
function. Next we pass loaded items to ToolList
component. ToolList component display list of tools by iterating over the provided items. We are also providing navigation Link
for Previous and Next page.
Start project with npm run dev
and go to http://localhost:3000/. You should see a list of tools. Click Next
to go to next page, it should display new set of tools. Because we are server rendering page and using standard href, navigation also works with Javascript disabled!
Now that we know basics of paging, let enhance code to support Forward only, load more paging.
Load More Paging
Currently we utilize Link
to get list of tools to shows. Every time we call get next page, server queries datastore and gets next sets of records. We read those and display the results. In order to support load more functionality, we need to aggregate data!
Change app\tool-list.tsx
as shown
'use client';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import React from 'react';
export interface Tool {
toolId: number;
toolName: string;
manufacturer: string;
model: string;
barcode: string;
}
export function ToolList({
items,
total,
page,
}: {
items: Tool[];
total: number;
page: number;
}) {
const [data, setData] = React.useState<Tool[]>(items);
// data accumulator
React.useEffect(() => {
setData((prev) => {
const lastToolId = items?.length ? items[items?.length - 1]?.toolId : 0;
if (prev[prev?.length - 1]?.toolId === lastToolId) return prev;
return [...prev, ...items];
});
}, [items]);
const canGetNext = page < total - 1;
return (
<div className="flex flex-col gap-16 m-16">
<div className="flex flex-wrap gap-4 w-full overflow-y-auto ">
{data.map((item) => (
<ToolCard item={item} key={item.toolId} />
))}
</div>
<div className="flex flex-row items-center gap-4">
{canGetNext ? (
<Button variant="outline" asChild>
<Link
href={{
pathname: '/',
query: { page: page + 1 },
}}
replace={true}
>
Load More
</Link>
</Button>
) : null}
</div>
</div>
); }
export function ToolCard({ item }: { item: Tool }) {
return (
<div
key={item.toolId}
className="flex flex-row flex-nowrap gap-3 p-4 rounded-xl max-w-sm w-full bg-background border"
>
<div
className=" text-2xl font-semibold border rounded-full w-12 h-12 flex
justify-center items-center bg-muted "
>
{item.toolName.substring(0, 1)}
</div>
<div className="flex flex-col gap-2">
<h1 className="font-medium text-lg">{item.toolName}</h1>
<h2 className="text-base ">{item.manufacturer}</h2>
<p className="text-sm text-muted-foreground ">{item.model}</p>
<p className="text-sm text-muted-foreground ">{item.barcode}</p>
</div>
</div>
);
}
'use client';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import React from 'react';
export interface Tool {
toolId: number;
toolName: string;
manufacturer: string;
model: string;
barcode: string;
}
export function ToolList({
items,
total,
page,
}: {
items: Tool[];
total: number;
page: number;
}) {
const [data, setData] = React.useState<Tool[]>(items);
// data accumulator
React.useEffect(() => {
setData((prev) => {
const lastToolId = items?.length ? items[items?.length - 1]?.toolId : 0;
if (prev[prev?.length - 1]?.toolId === lastToolId) return prev;
return [...prev, ...items];
});
}, [items]);
const canGetNext = page < total - 1;
return (
<div className="flex flex-col gap-16 m-16">
<div className="flex flex-wrap gap-4 w-full overflow-y-auto ">
{data.map((item) => (
<ToolCard item={item} key={item.toolId} />
))}
</div>
<div className="flex flex-row items-center gap-4">
{canGetNext ? (
<Button variant="outline" asChild>
<Link
href={{
pathname: '/',
query: { page: page + 1 },
}}
replace={true}
>
Load More
</Link>
</Button>
) : null}
</div>
</div>
); }
export function ToolCard({ item }: { item: Tool }) {
return (
<div
key={item.toolId}
className="flex flex-row flex-nowrap gap-3 p-4 rounded-xl max-w-sm w-full bg-background border"
>
<div
className=" text-2xl font-semibold border rounded-full w-12 h-12 flex
justify-center items-center bg-muted "
>
{item.toolName.substring(0, 1)}
</div>
<div className="flex flex-col gap-2">
<h1 className="font-medium text-lg">{item.toolName}</h1>
<h2 className="text-base ">{item.manufacturer}</h2>
<p className="text-sm text-muted-foreground ">{item.model}</p>
<p className="text-sm text-muted-foreground ">{item.barcode}</p>
</div>
</div>
);
}
We converted TooList
component to Client component by adding use client
at the top. We also added useState
to accumulate items and show accumulated list.
Start project with npm run dev
and go to http://localhost:3000/. You should see a list of tools. Click Load More
to load next page of data, it should display two pages of tools.
Server Action
As we are accumulating data, we need to move away from Link based paging as there are few issues (just try previous / next on browser). We will be using Server Action to fetch data from server when user clicks Load More
.
First make getData
a server action by adding use server
at start of function
export async function getData(page: number): Promise<{
items: Tool[];
total: number;
}> {
'use server';
return {
items: data.slice(page * pageSize, page * pageSize + pageSize),
total: data.length / pageSize,
};
}
export async function getData(page: number): Promise<{
items: Tool[];
total: number;
}> {
'use server';
return {
items: data.slice(page * pageSize, page * pageSize + pageSize),
total: data.length / pageSize,
};
}
And pass this server action to ToolList
in app\page.tsx
export default async function ListPage({
searchParams,
}: {
searchParams: {
page: string;
};
}) {
const page = +(searchParams?.page ?? 0);
const { items, total } = await getData(page);
return <ToolList items={items} page={page} total={total} getData={getData} />;
}
export default async function ListPage({
searchParams,
}: {
searchParams: {
page: string;
};
}) {
const page = +(searchParams?.page ?? 0);
const { items, total } = await getData(page);
return <ToolList items={items} page={page} total={total} getData={getData} />;
}
We will use the server action getData
in ToolList
component to get next page of data on demand.
Change app\tool-list.tsx
as follows
export function ToolList({
items,
total,
getData,
}: {
items: Tool[];
total: number;
page: number;
getData: (page: number) => Promise<{
items: Tool[];
total: number;
}>;
}) {
const [data, setData] = React.useState<Tool[]>(items);
const [page, setPage] = useState(0);
async function handleLoadMore() {
const { items } = await getData(page + 1);
setPage(page + 1);
setData((prev) => {
const lastToolId = items?.length ? items[items?.length - 1]?.toolId : 0;
if (prev[prev?.length - 1]?.toolId === lastToolId) return prev;
return [...prev, ...items];
});
}
const canGetNext = page < total - 1;
return (
<div className="flex flex-col gap-16 m-16">
<div className="flex flex-wrap gap-4 w-full overflow-y-auto ">
{data.map((item) => (
<ToolCard item={item} key={item.toolId} />
))}
</div>
<div className="flex flex-row items-center gap-4">
{canGetNext ? (
<Button variant="outline" onClick={handleLoadMore}>
Load More
</Button>
) : null}
</div>
</div>
);
}
export function ToolList({
items,
total,
getData,
}: {
items: Tool[];
total: number;
page: number;
getData: (page: number) => Promise<{
items: Tool[];
total: number;
}>;
}) {
const [data, setData] = React.useState<Tool[]>(items);
const [page, setPage] = useState(0);
async function handleLoadMore() {
const { items } = await getData(page + 1);
setPage(page + 1);
setData((prev) => {
const lastToolId = items?.length ? items[items?.length - 1]?.toolId : 0;
if (prev[prev?.length - 1]?.toolId === lastToolId) return prev;
return [...prev, ...items];
});
}
const canGetNext = page < total - 1;
return (
<div className="flex flex-col gap-16 m-16">
<div className="flex flex-wrap gap-4 w-full overflow-y-auto ">
{data.map((item) => (
<ToolCard item={item} key={item.toolId} />
))}
</div>
<div className="flex flex-row items-center gap-4">
{canGetNext ? (
<Button variant="outline" onClick={handleLoadMore}>
Load More
</Button>
) : null}
</div>
</div>
);
}
We have removed useEffect
and replaced it will a handleLoadMore
function call that gets triggered when user Clicks Load More
button. In the function, we are calling getData
server action and accumulating resultant items.
One more thing, as we are using experimental serverActions feature, modify next.config.js as shown:
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true,
},
};
module.exports = nextConfig;
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: true,
},
};
module.exports = nextConfig;
Start project with npm run dev
and go to http://localhost:3000/. You should see a list of tools. Click Load More
to load next page of data, it should display two pages of tools.
Infinite Scrolling
Infinite Scrolling refers to feature where app automatically load data as user scrolls. There are number of ways to achieve this, like using IntersectionObservable or listing for scroll events. We will use Scroll Events as it provides a better UX by allowing us to pre-load data ahead of time.
With Load More in place, it is simple matter of hooking up to scroll event and getting next page every time we hit the threshold. Following diagram depicts relationship between what is visible area - viewport and what we want to show - content.
- ViewPort height = height of our list visible area
- Content height = total height of our list
- Scroll Top = how far user has scrolled the page
We will use a simple threshold formula, if contentHeight - scrollTope < 1.5 * viewPortHeight
, meaning if we have scrolled past 1 and half page, and half page is remaining, we will get the next page.
Change component ToolList
as follows
export function ToolList({
items,
total,
getData,
}: {
items: Tool[];
total: number;
page: number;
getData: (page: number) => Promise<{
items: Tool[];
total: number;
}>;
}) {
const [data, setData] = React.useState<Tool[]>(items);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const handleLoadMore = React.useCallback(
async function () {
setLoading(true);
const { items } = await getData(page + 1);
setPage(page + 1);
setData((prev) => {
const lastToolId = items?.length ? items[items?.length - 1]?.toolId : 0;
if (prev[prev?.length - 1]?.toolId === lastToolId) return prev;
return [...prev, ...items];
});
setLoading(false);
},
[getData, page],
);
const canGetNext = page < total - 1;
// when user scrolls, get next page if required
const handleScroll = React.useCallback(
(event: any) => {
const elem = event.currentTarget;
if (loading || !canGetNext) return;
const viewportHeight = elem.clientHeight;
const contentHeight = elem.scrollHeight;
if (contentHeight - elem.scrollTop < 1.5 * viewportHeight) {
if (canGetNext && !loading) {
handleLoadMore();
}
}
},
[canGetNext, handleLoadMore, loading],
);
return (
<div className="flex flex-col gap-16 h-screen">
<div
className="flex flex-wrap gap-4 p-16 w-full overflow-y-auto "
onScroll={handleScroll}
>
{data.map((item) => (
<ToolCard item={item} key={item.toolId} />
))}
</div>
</div>
);
}
export function ToolList({
items,
total,
getData,
}: {
items: Tool[];
total: number;
page: number;
getData: (page: number) => Promise<{
items: Tool[];
total: number;
}>;
}) {
const [data, setData] = React.useState<Tool[]>(items);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(false);
const handleLoadMore = React.useCallback(
async function () {
setLoading(true);
const { items } = await getData(page + 1);
setPage(page + 1);
setData((prev) => {
const lastToolId = items?.length ? items[items?.length - 1]?.toolId : 0;
if (prev[prev?.length - 1]?.toolId === lastToolId) return prev;
return [...prev, ...items];
});
setLoading(false);
},
[getData, page],
);
const canGetNext = page < total - 1;
// when user scrolls, get next page if required
const handleScroll = React.useCallback(
(event: any) => {
const elem = event.currentTarget;
if (loading || !canGetNext) return;
const viewportHeight = elem.clientHeight;
const contentHeight = elem.scrollHeight;
if (contentHeight - elem.scrollTop < 1.5 * viewportHeight) {
if (canGetNext && !loading) {
handleLoadMore();
}
}
},
[canGetNext, handleLoadMore, loading],
);
return (
<div className="flex flex-col gap-16 h-screen">
<div
className="flex flex-wrap gap-4 p-16 w-full overflow-y-auto "
onScroll={handleScroll}
>
{data.map((item) => (
<ToolCard item={item} key={item.toolId} />
))}
</div>
</div>
);
}
We added onScroll
to list container and are calling handleLoadMore
to get next page of data when we hit scroll threshold.
Start project with npm run dev
and go to http://localhost:3000/. Narrow down the browser till to see one column of Tools. If you now scroll, it will keep in fetching more tools.
Initial Load
Lets take care of users with large monitors. Currently we only load when user scrolls, but if user has large monitor, first 10 rows will only fill portion of the screen. We will eagerly load additional 2 pages so that user has data to scroll.
// eager load first 3 pages
const pageSize = 10;
React.useEffect(() => {
if (data.length <= pageSize * 2 && canGetNext && !loading) {
handleLoadMore();
}
}, [canGetNext, data.length, handleLoadMore, loading]);
// eager load first 3 pages
const pageSize = 10;
React.useEffect(() => {
if (data.length <= pageSize * 2 && canGetNext && !loading) {
handleLoadMore();
}
}, [canGetNext, data.length, handleLoadMore, loading]);
Next Steps
Hopefully you now a good handle on various ways of paging in NextJs App router, from basic previous and next paging to on demand load more to infinite scrolling. You can build up on this by incorporating Seek/Cursor based paging and supporting search, sort and other functionality.
Find Source code with a ready to run project @ GitHub
CreateAppAI
CreateAppAI includes useDynamicPager
hook to support server action based infinite scrolling with support for search, sort and efficient cursor based querying. Not a fan of infinite scroll, the usePager
hook can be used for page based efficient navigation.