DEV Community

Osibajo Inioluwa
Osibajo Inioluwa

Posted on

Next.js + Server-Side Tables Done Right

Next.js + Server‑Side Tables Done Right

Intro

Server‑side sorting, filtering, and pagination are the backbone of any production data table. With Next.js 13+ you can keep the UI declarative while letting the server do the heavy lifting. Below is a step‑by‑step guide that wires the shadcn/ui table component to a Next.js API route (or server‑action) so the browser only receives the rows it needs.


1. Set up the shadcn table component

The repo sadmann7/shadcn-table provides a fully typed table wrapper that works with React 18 and Next.js app router. Install the package and copy the component files:

npm i @shadcn/ui
# or yarn add @shadcn/ui
Enter fullscreen mode Exit fullscreen mode
// components/ui/DataTable.tsx
import { Table, TableHeader, TableBody, TableRow, TableCell } from "@shadcn/ui";
import { ColumnDef, flexRender } from "@tanstack/react-table";

interface DataTableProps<TData> {
  columns: ColumnDef<TData>[];
  data: TData[];
  total: number;          // total rows in DB
  pageIndex: number;
  pageSize: number;
  onSortChange: (col: string, dir: "asc" | "desc") => void;
  onFilterChange: (col: string, value: string) => void;
  onPageChange: (newIndex: number) => void;
}
export function DataTable<TData>(props: DataTableProps<TData>) {
  const {
    columns,
    data,
    total,
    pageIndex,
    pageSize,
    onSortChange,
    onFilterChange,
    onPageChange,
  } = props;

  // Build a TanStack Table instance (client‑side only for UI state)
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    manualSorting: true,
    manualFiltering: true,
    manualPagination: true,
  });

  return (
    <div className="space-y-4">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map(headerGroup => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map(header => (
                <TableCell
                  key={header.id}
                  onClick={() => {
                    const col = header.column.id;
                    const dir = header.column.getIsSorted()
                      ? header.column.getIsSorted() === "asc"
                        ? "desc"
                        : "asc"
                      : "asc";
                    onSortChange(col, dir);
                  }}
                >
                  {flexRender(header.column.columnDef.header, header.getContext())}
                  {header.column.getIsSorted()
                    ? header.column.getIsSorted() === "asc"
                      ? ""
                      : ""
                    : null}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableHeader>

        <TableBody>
          {table.getRowModel().rows.map(row => (
            <TableRow key={row.id}>
              {row.getVisibleCells().map(cell => (
                <TableCell key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </Table>

      {/* Pagination controls */}
      <div className="flex justify-between items-center">
        <span>
          {pageIndex * pageSize + 1}{Math.min((pageIndex + 1) * pageSize, total)} of {total}
        </span>
        <div className="flex gap-2">
          <button disabled={pageIndex === 0} onClick={() => onPageChange(pageIndex - 1)}>
            Prev
          </button>
          <button disabled={(pageIndex + 1) * pageSize >= total} onClick={() => onPageChange(pageIndex + 1)}>
            Next
          </button>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The component mirrors the API of @tanstack/react-table but delegates all data operations to the server via the callbacks.


2. Build a server‑side data endpoint

We’ll use a Next.js route handler (app/api/users/route.ts) that receives page, size, sort, and filter query parameters. The example assumes a Prisma‑backed Postgres table called User.

// app/api/users/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET(request: Request) {
  const url = new URL(request.url);
  const page = Number(url.searchParams.get("page") ?? "0");
  const size = Number(url.searchParams.get("size") ?? "10");
  const sort = url.searchParams.get("sort") ?? "id";
  const order = url.searchParams.get("order") ?? "asc";
  const filterColumn = url.searchParams.get("filterColumn");
  const filterValue = url.searchParams.get("filterValue");

  // Build Prisma where clause only if filtering is active
  const where = filterColumn && filterValue
    ? { [filterColumn]: { contains: filterValue, mode: "insensitive" } }
    : {};

  const [data, total] = await Promise.all([
    prisma.user.findMany({
      where,
      orderBy: { [sort]: order },
      skip: page * size,
      take: size,
    }),
    prisma.user.count({ where }),
  ]);

  return NextResponse.json({ data, total });
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • Manual paginationskip/take map directly to page and size.
  • Sorting – the orderBy object is built from query strings, avoiding string interpolation risks.

Top comments (1)

Collapse
 
junhanpang profile image
Junhan Pang

The orderBy note under "Key points" is worth a second look — I don't think
this one is safe yet.

Prisma does protect you from SQL injection, because it parameterises
values. But sort and filterColumn aren't values here, they're
structure, and neither gets validated against anything:

const where = { [filterColumn]: { contains: filterValue, mode: "insensitive" } };
Enter fullscreen mode Exit fullscreen mode

So ?filterColumn=password&filterValue=$2b$10 returns every user whose
password hash contains that string. Change the suffix, watch which rows
come back, and you can walk a hash character by character. The same thing
works more slowly through ?sort=password plus pagination — ordering by a
secret column leaks the ordering of that column.

An allowlist closes both and it's about four lines:

const SORTABLE = ["id", "name", "email", "createdAt"] as const;
const raw = url.searchParams.get("sort") ?? "id";
const sort = (SORTABLE as readonly string[]).includes(raw) ? raw : "id";
const order = url.searchParams.get("order") === "desc" ? "desc" : "asc";
Enter fullscreen mode Exit fullscreen mode

Worth the same treatment for filterColumn. Bonus: it also stops
?order=banana and ?sort=notAColumn turning into a 500.

Smaller thing — DataTable calls useReactTable and getCoreRowModel but
doesn't import them, and there's no "use client" at the top. In the app
router that'll fail as a server component before it ever reaches the
table.