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
// 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>
);
}
The component mirrors the API of
@tanstack/react-tablebut 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 });
}
Key points
-
Manual pagination –
skip/takemap directly topageandsize. -
Sorting – the
orderByobject is built from query strings, avoiding string interpolation risks.
Top comments (1)
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
sortandfilterColumnaren't values here, they'restructure, and neither gets validated against anything:
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:
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.