Upgrading from shadcn/ui Table
The official shadcn/ui Data Table documentation is a great introduction to @tanstack/react-table. It demonstrates how to wrap table primitives (Table, TableHeader, TableRow) with sorting, column visibility, and client-side pagination.
In production applications, however, basic tables quickly hit four friction points:
- Single-field text search instead of faceted filters. The tutorial provides an
<Input>that filters one column. Real applications need multi-select checkbox lists with item counts (Error (42),Warning (8)), numeric sliders, and date range pickers. - State lost on refresh. Filters and sort state live in React's local
useState. Refreshing the page, navigating away, or sharing the URL resets all user selections. - Client-side memory limits.
getPaginationRowModel()expects every row in browser memory. When datasets grow to tens of thousands of rows, client-side filtering and sorting degrades performance. - Repetitive boilerplate. Every column requires 30–50 lines of manual
ColumnDefcode to wire up sorting buttons, formatters, and filter callbacks.
data-table-filters is an upgrade path for existing shadcn/ui projects. It reuses your existing UI primitives while replacing manual table plumbing with declarative schemas, Linear-style faceted controls, and URL-synchronized state.
Architectural Comparison
| Capability | Official shadcn/ui Tutorial | With data-table-filters |
|---|---|---|
| Primitives & Theme | @/components/ui/table | Reuses your existing shadcn primitives |
| Column Definitions | Manual ColumnDef<T>[] boilerplate | Declarative createTableSchema + col.* |
| Filtering | Single-column text <Input /> | Faceted checkboxes (with counts), sliders, date ranges, ⌘K command palette |
| State Persistence | React useState (lost on refresh) | nuqs URL search params or zustand |
| Pagination | Client 10-per-page buttons | Virtualized infinite scroll (DataTableInfinite) or cursor pagination |
| Server-Side SQL | Manual API & SQL orchestration | createDrizzleHandler (filters, counts, cursors in SQL) |
| Row Details | Hand-rolled modal or drawer | Pre-built DataTableSheetDetails |
Step 1: Install the Upgrade Blocks
Preserve your existing @/components/ui/table.tsx and other primitives. Install the core table engine, declarative schema builder, and URL state adapter:
npx shadcn@latest add \
https://data-table.openstatus.dev/r/data-table.json \
https://data-table.openstatus.dev/r/data-table-schema.json \
https://data-table.openstatus.dev/r/data-table-nuqs.jsonPrerequisite. Works on either shadcn library: the CLI default, Base UI (
npx shadcn@latest init -d), or Radix (npx shadcn@latest init -b radix -p nova). The CLI resolves primitives from whichever base yourcomponents.jsonspecifies.
The CLI downloads the components into src/components/data-table/ and helpers into src/lib/. You own the code directly.
Step 2: Replace ColumnDef[] with createTableSchema
In the standard shadcn tutorial, defining columns requires repetitive manual code for headers, sorting buttons, and formatting:
Before (Standard shadcn columns.tsx)
// columns.tsx
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { ArrowUpDown } from "lucide-react";
export type Payment = {
id: string;
amount: number;
status: "pending" | "processing" | "success" | "failed";
email: string;
createdAt: Date;
};
export const columns: ColumnDef<Payment>[] = [
{
accessorKey: "status",
header: "Status",
},
{
accessorKey: "email",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Email
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
},
{
accessorKey: "amount",
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"));
return <div className="text-right font-medium">${amount.toFixed(2)}</div>;
},
},
{
accessorKey: "createdAt",
header: "Created At",
},
];Tip on timestamp fields: The basic shadcn tutorial data only defines
id,amount,status, andcreatedAt: Dateto your data model unlocks time range filters (col.timestamp().filterable("timerange")) and timestamp cursor pagination (cursorColumn: "createdAt"). Ensure your row objects include this field before applying the timestamp column.
After (table-schema.ts)
With createTableSchema, each column's type, display label, sortability, and filter type are declared once:
// table-schema.ts
import { field } from "@/lib/store/schema";
import { col, createTableSchema } from "@/lib/table-schema";
import {
generateColumns,
generateFilterFields,
generateFilterSchema,
} from "@/lib/table-schema";
export const paymentSchema = createTableSchema({
status: col
.enum(["pending", "processing", "success", "failed"])
.label("Status")
.filterable("checkbox"),
email: col.string().label("Email").sortable().filterable("input"),
amount: col
.number()
.label("Amount")
.sortable()
.filterable("slider", { min: 0, max: 1000 })
.display("custom", {
cell: (val) => <span>${Number(val).toFixed(2)}</span>,
}),
createdAt: col
.timestamp()
.label("Created At")
.sortable()
.filterable("timerange"),
});
// Auto-generate columns, faceted filter definitions, and store schema
export const columns = generateColumns(paymentSchema.definition);
export const filterFields = generateFilterFields(paymentSchema.definition);
export const filterSchema = generateFilterSchema(paymentSchema.definition, {
sort: field.sort(),
size: field.number().default(40),
cursor: field.timestamp(),
direction: field.stringLiteral(["prev", "next"]).default("next"),
});One schema definition drives:
- Typed column definitions (
columns) - Faceted filter controls (
filterFields) - Row detail drawer fields (
sheetFields) - URL search param serialization
Step 3: Sync with URL Search Params (nuqs)
In standard shadcn, filters live in component state and vanish on page reload:
// Standard shadcn: local state lost on refresh
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [sorting, setSorting] = React.useState<SortingState>([]);Replace local state with useNuqsAdapter. Every filter selection, sort direction, and search query immediately synchronizes with the browser URL (e.g. ?status=failed&amount=100-500&sort=amount.desc):
// payments-table.tsx
"use client";
import { DataTableInfinite } from "@/components/data-table/data-table-infinite";
import { useNuqsAdapter } from "@/lib/store/adapters/nuqs";
import { DataTableStoreProvider } from "@/lib/store/provider/DataTableStoreProvider";
import { columns, filterFields, filterSchema } from "./table-schema";
import type { Payment } from "./types";
const noop = () => Promise.resolve();
const noopRefetch = () => {};
export function PaymentsTable({ data }: { data: Payment[] }) {
const adapter = useNuqsAdapter(filterSchema.definition, { id: "payments" });
return (
<DataTableStoreProvider adapter={adapter}>
<DataTableInfinite
columns={columns}
data={data}
filterFields={filterFields}
totalRows={data.length}
totalRowsFetched={data.length}
hasNextPage={false}
fetchNextPage={noop}
refetch={noopRefetch}
/>
</DataTableStoreProvider>
);
}Setup Reminder: As with any
nuqsusage in Next.js App Router, ensure<NuqsAdapter>wraps your root layout (app/layout.tsx) and the client component is wrapped in a<Suspense>boundary.
Step 4: Add Faceted Filter Controls and Command Palette
The standard shadcn table renders a basic search input above the table. To upgrade to faceted controls:
-
filterFieldspassed to<DataTableInfinite>automatically renders:- Checkbox dropdowns with option counts
- Numeric range sliders with min/max bounds
- Date range pickers with presets
- Active filter badges and a "Reset" button
-
Add a ⌘K Command Palette: Install the palette block:
npx shadcn@latest add https://data-table.openstatus.dev/r/data-table-filter-command.jsonPass it into
commandSlot:import { DataTableFilterCommand } from "@/components/data-table/data-table-filter-command"; <DataTableInfinite columns={columns} data={data} filterFields={filterFields} totalRows={data.length} totalRowsFetched={data.length} hasNextPage={false} fetchNextPage={noop} refetch={noopRefetch} commandSlot={<DataTableFilterCommand schema={filterSchema.definition} />} />;
Step 5: (Optional) Scale to Server-Side SQL with Drizzle
If your dataset grows beyond what the browser can comfortably handle, you do not need to rewrite the table. Install the Drizzle block:
npx shadcn@latest add https://data-table.openstatus.dev/r/data-table-drizzle.jsonPass the exact same paymentSchema.definition to createDrizzleHandler in your Next.js Route Handler:
// app/api/payments/route.ts
import { db } from "@/db";
import { payments } from "@/db/schema";
import { createDrizzleHandler } from "@/lib/drizzle";
import { defineFilters } from "@/lib/filters";
import { createNuqsSearchParams } from "@/lib/store/adapters/nuqs/server";
import { filterSchema, paymentSchema } from "@/app/payments/table-schema";
import { NextRequest } from "next/server";
const { searchParamsCache } = createNuqsSearchParams(filterSchema.definition);
const handler = createDrizzleHandler({
db,
table: payments,
filters: defineFilters(paymentSchema.definition),
columnMapping: {
id: payments.id,
status: payments.status,
email: payments.email,
amount: payments.amount,
createdAt: payments.createdAt,
},
cursorColumn: "createdAt",
});
export async function GET(req: NextRequest) {
const search = searchParamsCache.parse(
Object.fromEntries(req.nextUrl.searchParams),
);
const result = await handler.execute(search);
return Response.json(result);
}Dynamic WHERE clauses, multi-column sorting, cursor pagination, and faceted count aggregation now execute directly in PostgreSQL, bounded by database indexes rather than browser memory.
Incremental Adoption Path
You do not need to migrate everything at once:
- Day 1: Install
data-tableanddata-table-schema. Replace your manualColumnDef[]withcreateTableSchemaand render<DataTableInfinite>. Start with in-memory state viauseMemoryAdapter. - Day 2: Switch
useMemoryAdaptertouseNuqsAdapterto enable shareable URL filter links. - Day 3: Add optional blocks as needed:
- Detail side panel:
data-table-sheet - Cell renderers:
data-table-cell(badges, status codes, timestamps) - Bulk actions:
data-table-floating-bar - Timeline chart:
data-table-chart
- Detail side panel:
- When Scaling: Add
data-table-drizzleto push filtering and facets into SQL.
Next Steps
- Explore all column types and presets in Table Schema
- Learn about URL state options in State Management
- Check out full server-side walkthroughs in Drizzle ORM
- Try the interactive Table Builder to experiment with schema options
