# CRUD Data Table

Full TanStack CRUD data table with search, filters, multiselect, column visibility, sorting, and API Platform helpers.

## Installation

```bash
npx shadcn@latest add https://ui.uptoolkit.com/r/crud-data-table.json
```

[Registry JSON](https://ui.uptoolkit.com/r/crud-data-table.json)

## Preview

```tsx
import { CrudDataTable } from "@/components/crud-data-table";

export function Preview() {
  return (
    <CrudDataTable
      heading="Books"
      onCreate={() => undefined}
      onEdit={() => undefined}
      onDelete={() => undefined}
      onBulkDelete={() => undefined}
    />
  );
}
```


## Source

### components/crud-data-table.tsx

```tsx
"use client";

import { IconPlus, IconTrash } from "@tabler/icons-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

import { DataTable } from "@/components/ui/data-table";
import { DataTablePagination } from "@/components/ui/data-table-pagination";
import { DataTableToolbar } from "@/components/ui/data-table-toolbar";
import {
  fetchApiPlatformCollection,
  getApiPlatformItemId,
  type ApiPlatformCollection,
  type CrudQueryState,
} from "@/lib/api-platform-crud";
import { useCrudDataTable } from "@/hooks/use-crud-data-table";
import {
  createBookColumns,
  sampleBooks,
  type Book,
} from "@/lib/crud-sample-data";

type CrudDataTableProps = {
  /** API Platform collection endpoint, for example `/api/books`. */
  endpoint?: string;
  /** Column definitions. Defaults to the sample book columns. */
  columns?: ReturnType<typeof createBookColumns>;
  /** Client-side data for previews and local CRUD. */
  data?: Book[];
  /** Total items when using server-side pagination. */
  totalItems?: number;
  /** Force server-side mode. Inferred when `endpoint` is set. */
  manual?: boolean;
  heading?: string;
  searchPlaceholder?: string;
  pageSize?: number;
  className?: string;
  onCreate?: () => void;
  onEdit?: (row: Book) => void;
  onDelete?: (row: Book) => void;
  onBulkDelete?: (rows: Book[]) => void;
  /** Called whenever the API Platform query changes. */
  onQueryChange?: (query: CrudQueryState) => void;
  /** Custom fetcher for non-default API clients. */
  fetchCollection?: (
    endpoint: string,
    query: CrudQueryState,
  ) => Promise<ApiPlatformCollection<Book>>;
};

/**
 * Full CRUD data table built on TanStack Table.
 *
 * Ships with search, customizable column filters, multiselect, column
 * visibility, sorting, and pagination. In server mode it builds API Platform
 * query strings (`page`, `itemsPerPage`, `order[field]`, filters, `search`).
 */
function CrudDataTable({
  endpoint,
  columns: columnsProp,
  data: dataProp,
  totalItems: totalItemsProp,
  manual,
  heading = "Records",
  searchPlaceholder = "Search records...",
  pageSize = 10,
  className,
  onCreate,
  onEdit,
  onDelete,
  onBulkDelete,
  onQueryChange,
  fetchCollection = fetchApiPlatformCollection,
}: CrudDataTableProps) {
  const isServer = manual ?? Boolean(endpoint);
  const columns = React.useMemo(
    () => columnsProp ?? createBookColumns({ onEdit, onDelete }),
    [columnsProp, onDelete, onEdit],
  );

  const [serverData, setServerData] = React.useState<Book[]>(dataProp ?? []);
  const [serverTotal, setServerTotal] = React.useState(totalItemsProp ?? 0);
  const [loading, setLoading] = React.useState(false);

  const { table, query } = useCrudDataTable<Book>({
    columns,
    data: isServer ? serverData : (dataProp ?? sampleBooks),
    rowCount: isServer ? serverTotal : undefined,
    manual: isServer,
    pageSize,
    onQueryChange,
    getRowId: (row) => getApiPlatformItemId(row as unknown as Record<string, unknown>) || String(row.id),
  });

  React.useEffect(() => {
    if (!isServer || !endpoint) {
      return;
    }

    let cancelled = false;

    async function load() {
      if (!endpoint) {
        return;
      }

      setLoading(true);

      try {
        const collection = await fetchCollection(endpoint, query);
        if (!cancelled) {
          setServerData(collection.member);
          setServerTotal(collection.totalItems);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    void load();

    return () => {
      cancelled = true;
    };
  }, [endpoint, fetchCollection, isServer, query]);

  const selectedRows = table.getFilteredSelectedRowModel().rows.map((row) => row.original);

  return (
    <section aria-label={heading} className={cn("flex flex-col gap-4", className)}>
      <div className="flex flex-wrap items-center justify-between gap-3">
        <div className="flex flex-col gap-1">
          <h2 className="m-0 text-lg font-semibold">{heading}</h2>
          {isServer && endpoint ? (
            <p className="m-0 font-mono text-xs text-muted-foreground">{endpoint}</p>
          ) : null}
        </div>

        <div className="flex items-center gap-2">
          {selectedRows.length > 0 && onBulkDelete ? (
            <Button variant="destructive" size="sm" onClick={() => onBulkDelete(selectedRows)}>
              <IconTrash aria-hidden="true" />
              Delete ({selectedRows.length})
            </Button>
          ) : null}
          {onCreate ? (
            <Button size="sm" onClick={onCreate}>
              <IconPlus aria-hidden="true" />
              Create
            </Button>
          ) : null}
        </div>
      </div>

      <DataTableToolbar table={table} searchPlaceholder={searchPlaceholder} />

      <div className={cn(loading && "opacity-60")}>
        <DataTable table={table} emptyMessage={loading ? "Loading..." : "No results."} />
      </div>

      <DataTablePagination table={table} />
    </section>
  );
}

export { CrudDataTable, type CrudDataTableProps };
```


### lib/crud-sample-data.tsx

```tsx
/**
 * Sample rows and column definitions for the CRUD data table previews.
 */

import { IconDots, IconPencil, IconTrash } from "@tabler/icons-react";
import type { ColumnDef } from "@tanstack/react-table";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

import type { DataTableColumnMeta } from "@/lib/data-table";
import { DataTableColumnHeader } from "@/components/ui/data-table-column-header";
import { getApiPlatformItemId } from "@/lib/api-platform-crud";

type BookStatus = "draft" | "published" | "archived";

type Book = {
  "@id": string;
  id: number;
  title: string;
  author: string;
  status: BookStatus;
  published: boolean;
  updatedAt: string;
};

const statusOptions = [
  { label: "Draft", value: "draft" },
  { label: "Published", value: "published" },
  { label: "Archived", value: "archived" },
] as const;

const sampleBooks: Book[] = [
  {
    "@id": "/books/1",
    id: 1,
    title: "The Left Hand of Darkness",
    author: "Ursula K. Le Guin",
    status: "published",
    published: true,
    updatedAt: "2025-05-12T10:00:00.000Z",
  },
  {
    "@id": "/books/2",
    id: 2,
    title: "Dune",
    author: "Frank Herbert",
    status: "published",
    published: true,
    updatedAt: "2025-05-10T08:30:00.000Z",
  },
  {
    "@id": "/books/3",
    id: 3,
    title: "The Dispossessed",
    author: "Ursula K. Le Guin",
    status: "draft",
    published: false,
    updatedAt: "2025-04-28T16:45:00.000Z",
  },
  {
    "@id": "/books/4",
    id: 4,
    title: "Neuromancer",
    author: "William Gibson",
    status: "published",
    published: true,
    updatedAt: "2025-04-20T12:15:00.000Z",
  },
  {
    "@id": "/books/5",
    id: 5,
    title: "The Three-Body Problem",
    author: "Liu Cixin",
    status: "archived",
    published: false,
    updatedAt: "2025-03-18T09:00:00.000Z",
  },
  {
    "@id": "/books/6",
    id: 6,
    title: "Foundation",
    author: "Isaac Asimov",
    status: "published",
    published: true,
    updatedAt: "2025-03-01T11:20:00.000Z",
  },
  {
    "@id": "/books/7",
    id: 7,
    title: "Snow Crash",
    author: "Neal Stephenson",
    status: "draft",
    published: false,
    updatedAt: "2025-02-14T14:10:00.000Z",
  },
  {
    "@id": "/books/8",
    id: 8,
    title: "The Broken Earth",
    author: "N.K. Jemisin",
    status: "published",
    published: true,
    updatedAt: "2025-01-30T18:00:00.000Z",
  },
];

function formatBookDate(value: string): string {
  return new Intl.DateTimeFormat("en", {
    month: "short",
    day: "numeric",
    year: "numeric",
    timeZone: "UTC",
  }).format(new Date(value));
}

function createBookColumns(options?: {
  onEdit?: (book: Book) => void;
  onDelete?: (book: Book) => void;
}): ColumnDef<Book, unknown>[] {
  return [
    {
      id: "select",
      header: ({ table }) => (
        <Checkbox
          checked={table.getIsAllPageRowsSelected()}
          indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
          onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
          aria-label="Select all"
        />
      ),
      cell: ({ row }) => (
        <Checkbox
          checked={row.getIsSelected()}
          onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
          aria-label="Select row"
        />
      ),
      enableSorting: false,
      enableHiding: false,
    },
    {
      accessorKey: "title",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Title" />,
      meta: {
        label: "Title",
        filter: { type: "text", placeholder: "Filter title...", operator: "contains" },
      } satisfies DataTableColumnMeta,
    },
    {
      accessorKey: "author",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Author" />,
      meta: {
        label: "Author",
        filter: { type: "text", placeholder: "Filter author...", operator: "contains" },
      } satisfies DataTableColumnMeta,
    },
    {
      accessorKey: "status",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
      cell: ({ row }) => {
        const status = row.getValue<BookStatus>("status");

        return <Badge variant="outline">{status}</Badge>;
      },
      meta: {
        label: "Status",
        filter: {
          type: "multiselect",
          operator: "in",
          options: statusOptions.map((option) => ({ ...option })),
        },
      } satisfies DataTableColumnMeta,
    },
    {
      accessorKey: "published",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Published" />,
      cell: ({ row }) => (row.getValue<boolean>("published") ? "Yes" : "No"),
      meta: {
        label: "Published",
        filter: { type: "boolean", operator: "eq" },
      } satisfies DataTableColumnMeta,
    },
    {
      accessorKey: "updatedAt",
      header: ({ column }) => <DataTableColumnHeader column={column} title="Updated" />,
      cell: ({ row }) => formatBookDate(row.getValue<string>("updatedAt")),
      meta: { label: "Updated" } satisfies DataTableColumnMeta,
    },
    {
      id: "actions",
      header: () => <span className="sr-only">Actions</span>,
      cell: ({ row }) => (
        <DropdownMenu>
          <DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" />}>
            <IconDots aria-hidden="true" />
            <span className="sr-only">Open row actions</span>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuItem onClick={() => options?.onEdit?.(row.original)}>
              <IconPencil aria-hidden="true" />
              Edit
            </DropdownMenuItem>
            <DropdownMenuItem variant="destructive" onClick={() => options?.onDelete?.(row.original)}>
              <IconTrash aria-hidden="true" />
              Delete
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      ),
      enableSorting: false,
      enableHiding: false,
    },
  ];
}

export {
  createBookColumns,
  formatBookDate,
  getApiPlatformItemId,
  sampleBooks,
  statusOptions,
  type Book,
  type BookStatus,
};
```



## Usage

A batteries-included admin table for API Platform resources. Ships with search,
customizable column filters, row multiselect, column visibility, sorting, and
pagination. In server mode it builds query strings compatible with API
Platform's default `page`, `itemsPerPage`, `order[field]`, filter, and `search`
parameters.

```bash
npx shadcn@latest add @_cn/crud-data-table
```

```tsx
import { CrudDataTable } from "@/components/crud-data-table";

<CrudDataTable
  endpoint="/api/books"
  manual
  heading="Books"
  onCreate={() => router.push("/books/new")}
  onEdit={(book) => router.push(`/books/${book.id}`)}
  onDelete={(book) => deleteBook(book["@id"])}
  onBulkDelete={(rows) => deleteBooks(rows.map((row) => row["@id"]))}
  onQueryChange={(query) => console.log(buildApiPlatformQuery(query).toString())}
/>;
```

### Client mode

Omit `endpoint` to run against local rows — useful for Storybook-style previews
or optimistic client CRUD:

```tsx
<CrudDataTable data={books} columns={bookColumns} />
```

### Column filters

Define filters on `columnDef.meta.filter`. Supported types: `text`, `select`,
`multiselect`, `boolean`. Each maps to API Platform filter operators (`contains`,
`in`, `eq`, and so on).

### Dependencies

Requires `@tanstack/react-table` in the host application.

