Next.js + Server-Side Tables Done Right

작성자

카테고리:

← 피드로
DEV Community · Osibajo Inioluwa · 2026-09-21 개발(SW)

Osibajo Inioluwa

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 pagination – skip/take map directly to page and size.
  • Sorting – the orderBy object is built from query strings, avoiding string interpolation risks.

원문에서 계속 ↗