AdminKitDocs
Components

Next.js Bootstrap Tables

Next.js Bootstrap table markup rendered on the server: rows from an array with map and key, striped and hover tables, row colors, tables in cards.

Tables show rows of data that people scan and compare, such as clients or orders. In AdminKit for Next.js, a table is Bootstrap's .table markup in JSX: you map an array to rows, and there is no table component to import. Tables are part of the free and PRO versions.

A table needs no "use client". Your page is a server component, so it renders the table on the server, and the rows end up in the static HTML of the page. No JavaScript has to run before people can read them.

For sorting, search and paging, AdminKit PRO has the Data table plugin.

Example

src/app/(dashboard)/tables-bootstrap/page.tsx
import { pageMetadata } from "@/lib/site";

export const metadata = pageMetadata("/tables-bootstrap");

const people = [
  { id: 1, name: "Vanessa Tucker", phone: "864-348-0485", birthday: "June 21, 1961" },
  { id: 2, name: "William Harris", phone: "914-939-2458", birthday: "May 15, 1948" },
  { id: 3, name: "Sharon Lessman", phone: "704-993-5435", birthday: "September 14, 1965" },
];

export default function Page() {
  return (
    <table className="table">
      <thead>
        <tr>
          <th scope="col">Name</th>
          <th scope="col">Phone number</th>
          <th scope="col">Date of birth</th>
        </tr>
      </thead>
      <tbody>
        {people.map((person) => (
          <tr key={person.id}>
            <td>{person.name}</td>
            <td>{person.phone}</td>
            <td>{person.birthday}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Give each row a key from the data. Always write <thead> and <tbody>. A <tr> directly in a <table> is invalid HTML, and the browser corrects it when it reads the page. When React then takes over that HTML in the browser (hydration), it finds markup it did not render, and Next.js reports a hydration error.

Classes

ClassEffect
tableThe base class
table-stripedZebra stripes on the body rows
table-hoverA hover color on body rows
table-bordered, table-borderlessBorders on every side, or none
table-smCells with half the padding
table-<color>A row or cell in a theme color, such as table-primary
table-responsiveOn a wrapper: horizontal scrolling for a wide table
table-actionAdminKit: muted icon links with 18px icons

AdminKit centers body cells vertically, and it adapts tables to cards. A .table placed directly in a .card, or in a .table-responsive in a card, loses its outer borders and bottom margin. Its first and last cells line up with the card padding. These rules and the .table-action style are in src/scss/3-components/_tables.scss.

In a card, with actions

Dashboard tables usually sit in a card and end with a column of actions. On the tables demo, each table follows the .card-header of its card, and a .table-action cell holds the edit and delete links. The lucide-react icons render on the server too:

import { Pen, Trash } from "lucide-react";

<div className="card">
  <div className="card-header">
    <h2 className="card-title mb-0">Clients</h2>
  </div>
  <table className="table table-striped table-hover">
    <thead>
      <tr>
        <th scope="col">Name</th>
        <th scope="col">Phone number</th>
        <th scope="col">Actions</th>
      </tr>
    </thead>
    <tbody>
      {clients.map((client) => (
        <tr key={client.id} className={client.vip ? "table-success" : undefined}>
          <td>{client.name}</td>
          <td>{client.phone}</td>
          <td className="table-action">
            <a href="#" aria-label={`Edit ${client.name}`}>
              <Pen className="align-middle" />
            </a>{" "}
            <a href="#" aria-label={`Delete ${client.name}`}>
              <Trash className="align-middle" />
            </a>
          </td>
        </tr>
      ))}
    </tbody>
  </table>
</div>

Here the row color comes from the data. For clients that are not VIPs, className={client.vip ? "table-success" : undefined} leaves the attribute out.

Rows fetched in the browser

Rows you know at build time belong in the server component. Rows from your API are different. In the static export they arrive after the page loads, so a client component fetches them and holds them in state. While there are none, render one row that spans all the columns:

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

import { useEffect, useState } from "react";

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

export function OrdersTable() {
  const [orders, setOrders] = useState<Order[] | null>(null);

  useEffect(() => {
    fetch("/api/orders")
      .then((response) => response.json())
      .then(setOrders);
  }, []);

  return (
    <table className="table">
      <thead>
        <tr>
          <th scope="col">Order</th>
          <th scope="col">Customer</th>
          <th scope="col" className="text-end">Total</th>
        </tr>
      </thead>
      <tbody>
        {orders === null || orders.length === 0 ? (
          <tr>
            <td colSpan={3} className="text-center text-body-secondary">
              {orders === null ? "Loading orders..." : "No orders yet"}
            </td>
          </tr>
        ) : (
          orders.map((order) => (
            <tr key={order.id}>
              <td>{order.number}</td>
              <td>{order.customer}</td>
              <td className="text-end">{order.total}</td>
            </tr>
          ))
        )}
      </tbody>
    </table>
  );
}

In JSX, colspan is written colSpan. In AdminKit PRO, rows of placeholders can replace the loading text.

Responsive tables

When a table is wider than the screen, put it in a .table-responsive wrapper, so it scrolls sideways on its own. The demo makes the wrapper focusable and names it after the card title:

<div className="card">
  <div className="card-header">
    <h2 className="card-title mb-0" id="usage-title">Usage by region</h2>
  </div>
  <div className="table-responsive" role="region" tabIndex={0} aria-labelledby="usage-title">
    <table className="table mb-0">{/* ... */}</table>
  </div>
</div>

The demo hides the date of birth column on small screens with d-none d-md-table-cell on its <th> and <td> cells.

Accessibility

  • Column headings go in <th scope="col">, row headings in <th scope="row">.
  • Name icon-only links after their row, such as "Edit Vanessa Tucker".
  • A scrolling .table-responsive has role="region", tabIndex={0} and a name, so keyboard users can reach and scroll it.
  • A colored row needs a word too, such as "Overdue"; the color alone is not enough.

Last updated on

On this page