AdminKitDocs
Navigation

Next.js Bootstrap Pagination

Next.js Bootstrap pagination for the App Router: a pager in a client component that pages a table, with rows passed in from a server component page.

Pro

This component is only available in AdminKit PRO. Learn more.

Pagination breaks a long list into pages: a table of orders, search results, a grid of cards. People see which page they are on, jump to another by its number, or step through with Previous and Next.

AdminKit for Next.js has no pagination component of its own. You write Bootstrap's markup in JSX, keep the current page in React state and mark that page active. Pagination is part of AdminKit PRO. The stylesheet of the free version leaves out Bootstrap's pagination styles.

In the App Router, pages are server components: they render on the server and have no state. A pager has to remember the current page and react to clicks, so it lives in a client component, a file that starts with "use client". The page itself stays a server component. It can still pass the rows to the client component, because rows are plain data.

Example

This Pagination component only draws the pager. Its parent keeps the current page and passes it in, with a function to call when the user picks another page. Previous is disabled on the first page and Next on the last.

src/components/pagination.tsx
type PaginationProps = {
  page: number;
  pageCount: number;
  onPageChange: (page: number) => void;
  label: string;
};

export function Pagination({ page, pageCount, onPageChange, label }: PaginationProps) {
  const pages = Array.from({ length: pageCount }, (_, index) => index + 1);
  const isFirst = page === 1;
  const isLast = page === pageCount;

  return (
    <nav aria-label={label}>
      <ul className="pagination">
        <li className={`page-item${isFirst ? " disabled" : ""}`}>
          <button
            type="button"
            className="page-link"
            disabled={isFirst}
            onClick={() => onPageChange(page - 1)}
          >
            Previous
          </button>
        </li>
        {pages.map((number) => (
          <li
            key={number}
            className={`page-item${number === page ? " active" : ""}`}
            aria-current={number === page ? "page" : undefined}
          >
            <button type="button" className="page-link" onClick={() => onPageChange(number)}>
              {number}
            </button>
          </li>
        ))}
        <li className={`page-item${isLast ? " disabled" : ""}`}>
          <button
            type="button"
            className="page-link"
            disabled={isLast}
            onClick={() => onPageChange(page + 1)}
          >
            Next
          </button>
        </li>
      </ul>
    </nav>
  );
}

This file has no "use client" of its own. It takes a function, onPageChange, and a server component cannot pass a function to the browser. So only client components render it, and it runs in the browser with them. The items are buttons because they change state on the same page. When every page has its own URL, use Link from next/link with the page-link class instead.

Classes

ClassElementEffect
pagination<ul>Joins the items into one row of connected boxes
page-item<li>One item
page-link<button> or <a>The box people click
active.page-itemMarks the current page in the primary color; add aria-current="page" too
disabled.page-itemGreys out an item and ignores clicks; also set disabled on the button
pagination-sm, pagination-lg.paginationSmaller or larger items

The pager in AdminKit is a little more compact than Bootstrap's. src/scss/1-variables/_app.scss sets the item padding to 0.3rem 0.75rem, or 0.15rem 0.6rem small and 0.35rem 1rem large. The items have the background of cards in light and dark mode. Their text is --bs-gray-600, and --bs-gray-800 on hover. src/scss/3-components/_pagination.scss keeps the number of the active page white.

Pagination with a table

Most of the time, a pager sits below a table that shows one page of rows. The table component holds the current page and cuts that page out of the rows with slice. It starts with "use client" because it has state:

src/components/orders-table.tsx
"use client";

import { useState } from "react";
import { Pagination } from "@/components/pagination";

export type Order = { id: string; customer: string; total: string };

const pageSize = 10;

export function OrdersTable({ orders }: { orders: Order[] }) {
  const [page, setPage] = useState(1);
  const pageCount = Math.max(1, Math.ceil(orders.length / pageSize));
  const rows = orders.slice((page - 1) * pageSize, page * pageSize);

  return (
    <div className="card">
      <div className="card-body">
        <table className="table table-striped">
          <thead>
            <tr>
              <th>Order</th>
              <th>Customer</th>
              <th className="text-end">Total</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((order) => (
              <tr key={order.id}>
                <td>{order.id}</td>
                <td>{order.customer}</td>
                <td className="text-end">{order.total}</td>
              </tr>
            ))}
          </tbody>
        </table>
        <Pagination
          page={page}
          pageCount={pageCount}
          onPageChange={setPage}
          label="Orders pages"
        />
      </div>
    </div>
  );
}

The page stays a server component and passes the rows as a prop. The static HTML that Next.js builds already shows the first page of rows. In the browser, the table takes over and the pager starts to respond to clicks:

src/app/(dashboard)/orders/page.tsx
import { OrdersTable, type Order } from "@/components/orders-table";
import { pageMetadata } from "@/lib/site";

export const metadata = pageMetadata("/orders");

const orders: Order[] = [
  { id: "#1042", customer: "Ava Brooks", total: "$240.00" },
  { id: "#1041", customer: "Liam Carter", total: "$89.50" },
  // ...
];

export default function OrdersPage() {
  return (
    <>
      <h1 className="h3 mb-3">Orders</h1>
      <OrdersTable orders={orders} />
    </>
  );
}

pageMetadata() reads the route's title and description from src/data/pages.json, so add new routes there. See Tables for the table markup. When people also need to search and sort, use the Data table plugin. It has a pager of its own, drawn with the same pagination, page-item and page-link classes, so both pagers look alike.

Sizes and alignment

A smaller pager suits a busy card. Add pagination-sm or pagination-lg to the list, and move the pager with flexbox utilities such as justify-content-center or justify-content-end. Without state or handlers, this markup works in a server component too:

<nav aria-label="Invoices pages">
  <ul className="pagination pagination-sm justify-content-end">
    <li className="page-item active" aria-current="page">
      <button type="button" className="page-link">1</button>
    </li>
    <li className="page-item">
      <button type="button" className="page-link">2</button>
    </li>
    <li className="page-item">
      <button type="button" className="page-link">3</button>
    </li>
  </ul>
</nav>

Accessibility

  • Wrap the list in a <nav> with an aria-label that names what it pages through, such as "Orders pages". A page often has more than one navigation region.
  • Give the current page aria-current="page". The active class only changes its color.
  • Set disabled on the button of a disabled item, so Tab skips it and a click does nothing. On an <a>, the disabled class does not stop the keyboard: add tabIndex={-1} and aria-disabled="true".
  • Previous and Next buttons that show only an icon need an aria-label, as in the demo.

Last updated on

On this page