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!

nextjs paging

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