# data-table-filters — full documentation > Open-source React data table with faceted filters, sorting, infinite scroll, and virtualization. Distributed as shadcn registry blocks you install into your own repo — not as an npm dependency, so there is no library to wrap and nothing to eject from. - Stack: React 19+, TanStack Table v8, Tailwind CSS v4, shadcn/ui. Next.js App Router is first-class; the blocks work in any React app. - Works on either shadcn library: the CLI default, Base UI (`npx shadcn@latest init -d`), or Radix (`npx shadcn@latest init -b radix -p lyra`). CI installs into both and typechecks them on every registry change and nightly. - Starting from nothing? `pnpm dlx shadcn@latest init @data-table-filters/data-table-example-infinite --name logs-viewer --template next -p lyra` creates a Next.js app, initializes shadcn on Base UI, and installs a working example route with every block it needs; run the dev server and open http://localhost:3000/example. Add `-b radix` before `-p lyra` for Radix. - Install with `npx shadcn@latest add @data-table-filters/` — the registry is in the shadcn directory, so no components.json entry is needed; the block URL `https://data-table.openstatus.dev/r/.json` works too. The shadcn CLI resolves block dependencies, rewrites `@/` import paths to match components.json, and injects the required CSS variables. - Built for large tables: filtering, faceted counts, sorting, and cursor pagination all execute in SQL, and rows are virtualized, so table size is bounded by the database rather than the browser. - One `createTableSchema` definition drives the columns, the filter controls, the row detail sheet, the server-side query handler, the natural-language filter parser, and the MCP tool schema. Source: https://data-table.openstatus.dev/docs — index at https://data-table.openstatus.dev/llms.txt --- # Introduction Source: https://data-table.openstatus.dev/docs/introduction _**It’s not a library. It’s a playbook.**_ Stop hand-rolling data tables. Copy proven patterns, install the [agent skill](/docs/quick-start#agent-skill) and start shipping. Built on: - **[shadcn registry](https://ui.shadcn.com)** — drop components directly into your codebase - **[TanStack Table](https://tanstack.com/table) + [Query](https://tanstack.com/query)** — sorting, filtering, infinite scroll, done - **[Drizzle ORM](/docs/drizzle-orm)** — server-side `WHERE`, cursors, and faceted counts out of the box - **[nuqs](https://nuqs.47ng.com) / [Zustand](https://zustand.docs.pmnd.rs/getting-started/introduction)** — URL or memory state, your call - **[Agent SKILL.md](/docs/quick-start#agent-skill)** — describe your schema, let the agent wire it up Define a schema. Generate columns, filters, and sheet details. Done. ![data-table with some filters and activated live mode](/assets/docs/data-table.png) This guide covers the system end to end — from defining a table schema to rendering filters, columns, and row details. Get started with the [Quick Start](/docs/quick-start), follow [Upgrading from shadcn/ui Table](/docs/migrating-from-shadcn) to upgrade an existing table, or jump to a [Full Example](/docs/full-example). ## Examples - [Auto](/auto) — zero-config table from raw data (no schema needed) - [Drizzle](/drizzle) — server-side filtering with Drizzle ORM - [Default](/default) — client-side pagination without infinite scroll - [Infinite](/infinite) — infinite scroll with cursor pagination (mock) - [Light](/light) — lightweight frontend for [light.openstatus.dev](https://light.openstatus.dev) - [Builder](/builder) — interactive schema builder > Questions, ideas, or feedback? [Open an issue on GitHub](https://github.com/openstatusHQ/data-table-filters/issues). --- ## Quick Overview ### Zero Config Pass data, get a fully filtered table — columns, filters, and display types are auto-inferred: ```tsx import { DataTableAuto } from "@/components/data-table/data-table-auto"; ; ``` See the [/auto](/auto) example for a live demo. ### With Schema When you need full control, define a schema and wire up state management: ```tsx import { col, createTableSchema } from "@/lib/table-schema"; import { generateColumns, generateFilterFields } from "@/lib/table-schema"; import { createSchema, field } from "@/lib/store/schema"; // 1. Define your table const tableSchema = createTableSchema({ level: col.presets.logLevel(["error", "warn", "info", "debug"]), latency: col.presets.duration("ms").label("Latency").sortable(), host: col.string().label("Host"), }); // 2. Generate everything the components need const columns = generateColumns(tableSchema.definition); const filterFields = generateFilterFields(tableSchema.definition); // 3. Wire up state + components ``` --- ## Architecture The data-table is built on three layers: 1. **Table Schema** — a declarative builder that defines columns, filters, display, sorting, and row details in one place 2. **State Management** — a pluggable adapter system for filter state (URL, Zustand, or custom) 3. **UI Components** — pre-built filter controls, command palette, infinite scroll table, and row detail drawer For server-side data, the [Drizzle ORM](/docs/drizzle-orm) helpers handle `WHERE` conditions, sorting, cursor pagination, and faceted counts. The [Data Fetching](/docs/data-fetching) layer covers the API response shape and React Query integration. The schema is serializable, so it can also cross the network. [Headless Tables](/docs/headless) inverts the first layer: instead of importing a schema, the table fetches one from an endpoint you own — along with what that endpoint can compute and what may be done to a row — and derives everything from it. You keep the data; the only thing you publish is its shape. ``` Table Schema → Generators → Components ↓ ↓ ↓ col.* columns[] DataTableInfinite presets filterFields DataTableFilterControls .sheet() sheetFields DataTableSheetDetails filterSchema DataTableFilterCommand ``` --- # Quick Start Source: https://data-table.openstatus.dev/docs/quick-start Two ways in. Starting from nothing, one command creates the app and a working table. Already have a shadcn project, one install command and one paste. ## Start from scratch One shadcn command creates the Next.js app, initializes shadcn, and installs a working example with every block it needs: ```bash pnpm dlx shadcn@latest init @data-table-filters/data-table-example-infinite --name logs-viewer --template next -p lyra cd logs-viewer pnpm dev ``` Requires pnpm (`npm i -g pnpm`); `npx` and `npm run dev` work the same if you prefer npm. Open [localhost:3000/example](http://localhost:3000/example): a table over 5,000 mock rows with a timeline chart, filters, facet counts, infinite scroll, a command palette and a row sheet. It lives in `app/example` — the table schema, a mock API route, and the page — so change the columns in `app/example/table-schema.ts` and watch every surface follow, or delete the folder once you have your own table. That lands on Base UI, the shadcn default. For Radix, add `-b radix` before `-p lyra`. The whole thing takes about a minute on a clean machine, most of it `pnpm install`. Using an agent instead? Paste this into Claude Code, Cursor or any agent that can run commands. It reads the docs index first and ends on the same page: ```text Read https://data-table.openstatus.dev/llms.txt, then set up data-table-filters here: install @data-table-filters/data-table-example-infinite with the shadcn CLI (run `npx shadcn@latest init -d` first if the project has no components.json; if there is no app yet, create one with `pnpm dlx shadcn@latest init @data-table-filters/data-table-example-infinite --name logs-viewer --template next -p lyra` instead), start the dev server, and open /example. ``` That is the Quick Start. The rest of this page is for adding a table to a project you already have. ## Add to an existing project Three steps: a shadcn project, one install command, one paste. ### 1. Prerequisite > **Prerequisite.** Works on either shadcn library: the CLI default, Base UI (`npx shadcn@latest init -d`), or Radix (`npx shadcn@latest init -b radix -p lyra`). CI installs into both and typechecks them on every registry change and nightly. Any `"style"` in `components.json` works — `base-…` (Base UI), `radix-…`, or the pre-v4 `new-york` / `default`. The blocks read the primitives from whichever one you are on. No shadcn yet? `npx shadcn@latest init -d` in the project first. ### 2. Install One command installs the core `data-table` block (table engine, memory store adapter, all 4 filter types) and `data-table-schema` (the schema system, including `DataTableAuto`): ```bash npx shadcn@latest add @data-table-filters/data-table @data-table-filters/data-table-schema ``` The CLI resolves the shadcn primitives the blocks need, rewrites `@/` import paths to match your `components.json`, and injects the CSS variables. ### 3. Render Paste this into a page — `app/page.tsx`, or `src/app/page.tsx` if the project uses a `src` directory: ```tsx import { DataTableAuto } from "@/components/data-table/data-table-auto"; const data = [ { name: "Alice", role: "admin", rating: 5, created_at: "2026-01-15T10:00:00Z", }, { name: "Bob", role: "user", rating: 3, created_at: "2026-02-20T14:30:00Z" }, ]; export default function Page() { return ; } ``` Columns, filters, display types, and cell renderers are auto-inferred from your data, and the command palette and row detail sheet come along. See the [/auto](/auto) example for a live demo. ![data-table with some filters and activated live mode](/assets/docs/data-table.png) ## You own the files There is no `data-table-filters` package in your `package.json` — only the blocks' own dependencies (TanStack Table, cmdk, and friends). The blocks landed as source under `src/components/data-table/` and `src/lib/`. Edit them like any other file in your repo. When the inferred table is not enough, [eject to the Builder](/builder) or define a [Table Schema](/docs/table-schema) by hand. ## Agent Skill Install the plugin in Claude Code: ```bash /plugin marketplace add openstatushq/data-table-filters /plugin install data-table-filters@openstatus ``` Or install the skill with any agent that supports the `skills` CLI: ```bash npx skills add https://github.com/openstatushq/data-table-filters --skill data-table-filters ``` Let the agent handle the rest: > Add a data table with filters to my project It detects your stack, installs the right blocks, and wires them up. For agents without the skill, see [For AI Agents](/docs/agents) — `llms.txt`, raw markdown docs, and Cursor rules. ## Blocks Every block installs with the same command: ```bash npx shadcn@latest add @data-table-filters/ ``` The registry is listed in the [shadcn directory](https://ui.shadcn.com/docs/directory), so the name resolves with no `registries` entry in `components.json`. The JSON form, `https://data-table.openstatus.dev/r/.json`, works too. | Block | Install | What it adds | | ------------------------------ | -------------------------------------------------- | -------------------------------------------------------------------------------------- | | `data-table` | `@data-table-filters/data-table` | Core: table engine, store, 4 filter types, memory adapter | | `data-table-schema` | `@data-table-filters/data-table-schema` | Declarative schema system with `col.*` factories and `DataTableAuto` | | `data-table-cell` | `@data-table-filters/data-table-cell` | Cell renderers: badges, bars, heatmaps, status codes, timestamps | | `data-table-sheet` | `@data-table-filters/data-table-sheet` | Row detail side panel | | `data-table-filter-command` | `@data-table-filters/data-table-filter-command` | Command palette with history and keyboard shortcuts | | `data-table-nuqs` | `@data-table-filters/data-table-nuqs` | nuqs URL state adapter | | `data-table-zustand` | `@data-table-filters/data-table-zustand` | zustand state adapter | | `data-table-drizzle` | `@data-table-filters/data-table-drizzle` | Drizzle ORM server-side helpers | | `data-table-query` | `@data-table-filters/data-table-query` | React Query infinite query integration | | `data-table-filter-command-ai` | `@data-table-filters/data-table-filter-command-ai` | Natural language → filter inference | | `data-table-mcp` | `@data-table-filters/data-table-mcp` | MCP server endpoint for AI agents | | `data-table-actions` | `@data-table-filters/data-table-actions` | Row and bulk actions rendered from server metadata | | `data-table-remote` | `@data-table-filters/data-table-remote` | Headless table that renders from an API endpoint's manifest | | `data-table-chart` | `@data-table-filters/data-table-chart` | Timeline chart over the table: stacked buckets per level, drag to zoom the time filter | | `data-table-example-infinite` | `@data-table-filters/data-table-example-infinite` | Ready-to-run `/example` route: schema, mock API, infinite table with URL state | > Looking to add natural language filtering? The `data-table-filter-command-ai` block translates queries like _"5xx errors last 24h"_ into structured filters using any LLM. See [AI Filters](/docs/ai-filters) for setup. ## Next Steps - Upgrading an existing table? Follow [Upgrading from shadcn/ui Table](/docs/migrating-from-shadcn) to swap manual column definitions and useState for faceted filters and URL state - Browse the [/infinite](/infinite) demo to see all components in action - Learn about the [Table Schema](/docs/table-schema) builder for type-safe column definitions - Explore the [UI Components](/docs/ui-components) available out of the box - Rows live in Postgres? Follow [Drizzle ORM](/docs/drizzle-orm) for server-side filtering, facets, and cursor pagination --- # Table Schema Source: https://data-table.openstatus.dev/docs/table-schema The table schema is a type-safe builder for defining your entire table in one place. Instead of manually wiring up `columns.tsx`, `filterFields`, `sheetFields`, and a filter state schema separately, a single schema definition generates all of them. ## Defining a Schema ```tsx import { col, createTableSchema, type InferTableType, } from "@/lib/table-schema"; const LEVELS = ["error", "warn", "info", "debug"] as const; const METHODS = ["GET", "POST", "PUT", "DELETE"] as const; export const tableSchema = createTableSchema({ level: col.presets.logLevel(LEVELS).description("Log severity"), date: col.presets.timestamp().label("Date").size(200).sheet(), latency: col.presets .duration("ms") .label("Latency") .sortable() .size(110) .sheet(), status: col.presets.httpStatus().label("Status").size(60), method: col.presets.httpMethod(METHODS).size(69), host: col.string().label("Host").size(125).sheet(), path: col.presets.pathname().label("Path").size(130).sheet(), traceId: col.presets.traceId().label("Request ID").hidden().sheet(), headers: col.record().label("Headers").sheetOnly().sheet(), }); // Row type inferred from the schema export type ColumnSchema = InferTableType; ``` ## Column Factories Choose the factory based on your data type: ```ts col.string(); // string — text display, input filter col.number(); // number — number display, input filter col.boolean(); // boolean — boolean display, checkbox filter col.timestamp(); // Date — timestamp display, timerange filter col.enum(values); // union — badge display, checkbox filter col.array(item); // array — badge display, checkbox filter col.record(); // Record — text display, not filterable col.select(); // boolean — row selection checkbox column ``` Each factory returns a `ColBuilder` where `T` is the inferred TypeScript type and `F` constrains which filter types are valid at compile time: | Factory | Allowed Filters | Notes | | ----------------------------- | ----------------------------------- | ----------------------------------------------- | | `col.string()` | `"input"` | Text search | | `col.number()` | `"input"`, `"slider"`, `"checkbox"` | Slider for ranges, checkbox for discrete values | | `col.boolean()` | `"checkbox"` | Pre-wired with true/false options | | `col.timestamp()` | `"timerange"` | Date range picker | | `col.enum(values)` | `"checkbox"` | Options auto-derived from values | | `col.array(col.enum(values))` | `"checkbox"` | Multi-value tags, regions, labels | | `col.record()` | _none_ (`never`) | Use `.sheetOnly()` for detail drawers | | `col.select()` | _none_ (`never`) | Row selection checkbox + floating bar | ### Row Selection with `col.select()` `col.select()` adds a checkbox column for row selection. It renders a select-all checkbox in the header and a per-row checkbox in each cell. Pair it with `DataTableFloatingBar` to show bulk actions when rows are selected. ```ts export const tableSchema = createTableSchema({ select: col.select().size(37), // ...other columns }); ``` > `col.select()` is not filterable, not sortable, and excluded from the sheet. It should typically be the first column in your schema. See [DataTableFloatingBar](/docs/ui-components#datatablefloatingbar) for the bulk action bar. ## Presets Pre-configured builders for common patterns. All remain fully customizable via chaining. ```ts col.presets.logLevel(["error", "warn", "info", "debug"]); // → enum + badge + checkbox + defaultOpen col.presets.httpMethod(["GET", "POST", "PUT", "DELETE"]); // → enum + text display + checkbox col.presets.httpStatus(); // → number + checkbox with common codes (200, 201, 204, 301, ..., 504) // Custom codes: col.presets.httpStatus([200, 400, 500]) col.presets.duration("ms"); // → number + formatted display with unit + slider (0–5000) // Custom bounds: col.presets.duration("s", { min: 0, max: 60 }) col.presets.timestamp(); // → Date + relative time display + timerange filter + sortable col.presets.traceId(); // → string + code display + not filterable col.presets.pathname(); // → string + text display + input filter ``` ## Builder Methods All methods return a new builder instance (immutable) for fluent chaining. ### Label & Description ```ts col .string() .label("Host") // Column header label (required) .description("Origin server"); // For AI agents / MCP tools (not shown in UI) ``` > Descriptions are essential for [AI filter](/docs/ai-filters) accuracy. Without them, the AI only sees field names and types, which can lead to ambiguous results. Add `.description()` to any column you want the AI to handle well. ### Display Controls how the cell value renders. Built-in display types: | Type | Use case | | ------------------- | ------------------------------------------------------- | | `"text"` | Plain text with overflow tooltip (default for strings) | | `"code"` | Monospace — IDs, paths, hashes | | `"number"` | Formatted number with optional `unit` suffix | | `"bar"` | Horizontal bar with `min`/`max` range and optional unit | | `"heatmap"` | Background color intensity based on `min`/`max` range | | `"badge"` | Colored chip (default for enums) | | `"timestamp"` | Relative time ("3m ago"), absolute on hover | | `"boolean"` | Checkmark / dash icon | | `"star"` | Filled yellow star (true) / outlined muted star (false) | | `"status-code"` | HTTP status code coloring | | `"level-indicator"` | Severity dot indicator | | `"custom"` | Developer-supplied JSX (not serializable) | ```ts col.number().display("number", { unit: "ms" }) col.number().display("bar", { min: 0, max: 5000, unit: "ms" }) col.number().display("heatmap", { min: 0, max: 100 }) col.enum(v).display("badge", { colorMap: { error: "#ef4444", warn: "#f59e0b" } }) col.enum(v).display("custom", { cell: (value, row) => , }) ``` ### Filtering ```ts col.string().filterable("input") col.number().filterable("slider", { min: 0, max: 5000 }) col.enum(v).filterable("checkbox") col.enum(v).filterable("checkbox", { options: v.map(v => ({ label: v, value: v })), component: (props) => , }) col.timestamp().filterable("timerange") col.string().notFilterable() // Disables filtering (F becomes never) col.enum(v).defaultOpen() // Expand in filter sidebar by default col.timestamp().commandDisabled() // Exclude from command palette ``` > Fields with `.commandDisabled()` are hidden from the manual command palette but **still available to the AI**. This is useful for fields like date ranges that are easier to express in natural language (e.g., _"last 24 hours"_). See [AI Filters — Schema Considerations](/docs/ai-filters#schema-considerations). Passing a filter type not in `F` is a **compile-time error** — e.g. `col.string().filterable("slider")` won't compile. ### Visibility & Layout ```ts col.string().hidden(); // Hidden by default (toggleable in column menu) col.enum(v).hideHeader(); // Hide header label, keep column visible col.string().resizable(); // Enable drag-to-resize col.string().size(125); // Fixed width in pixels (initial width if resizable) col.timestamp().minSize(200); // Floor for a flexing (unsized) column ``` A column without `.size()` flexes to absorb the table's leftover width. Give it a `.minSize()` floor so it never gets compressed below a usable width. ### Sorting & Optionality ```ts col.number().sortable(); // Click-to-sort on column header col.presets.timestamp().sortable(false); // Switch it off again on a preset col.string().optional(); // T becomes T | undefined in InferTableType ``` ### Sheet (Row Detail Drawer) ```ts col.string().sheet() // Include in detail drawer col.string().sheet({ label: "Server", skeletonClassName: "w-24" }) col.number().sheet({ component: (row) => <>{row.latency}ms, skeletonClassName: "w-16", }) col.record().sheetOnly() // hidden + notFilterable + enableHiding: false ``` ## Generators The schema drives four generators that produce everything the table components need. ```tsx import { generateColumns, generateFilterFields, generateFilterSchema, generateSheetFields, getDefaultColumnVisibility, } from "@/lib/table-schema"; // TanStack Table ColumnDef[] const columns = generateColumns(tableSchema.definition); // Filter sidebar and command palette fields const filterFields = generateFilterFields(tableSchema.definition); // Row detail drawer fields const sheetFields = generateSheetFields(tableSchema.definition); // Initial column visibility from .hidden() columns const defaultColumnVisibility = getDefaultColumnVisibility( tableSchema.definition, ); ``` You can append custom virtual columns that span multiple fields: ```tsx const allColumns = [ ...generateColumns(tableSchema.definition), { id: "timing", header: "Timing Phases", cell: ({ row }) => , size: 130, }, ]; ``` ### generateFilterSchema Bridges the table schema to filter state by generating a filter schema. Add non-column state fields (sort, pagination, live mode) alongside: ```tsx import { createSchema, field } from "@/lib/store/schema"; export const filterSchema = createSchema({ ...generateFilterSchema(tableSchema.definition).definition, sort: field.sort(), live: field.boolean().default(false), size: field.number().default(40), }); ``` > The `/infinite` route writes the filter state schema manually instead of using `generateFilterSchema` for better TypeScript inference. Both approaches work — use `generateFilterSchema` for convenience, manual for full control. The mapping from column types to filter state field types: | Column + Filter | filter state Field | | --------------------------------- | ----------------------------------------------- | | `col.string()` + `"input"` | `field.string()` | | `col.number()` + `"input"` | `field.number()` | | `col.number()` + `"slider"` | `field.array(field.number()).delimiter("-")` | | `col.number()` + `"checkbox"` | `field.array(field.number()).delimiter(",")` | | `col.enum(v)` + `"checkbox"` | `field.array(field.stringLiteral(v))` | | `col.boolean()` + `"checkbox"` | `field.array(field.boolean()).delimiter(",")` | | `col.timestamp()` + `"timerange"` | `field.array(field.timestamp()).delimiter("-")` | See the [Builder](/docs/builder) page for visual schema creation, serialization, and AI integration. --- # State Management Source: https://data-table.openstatus.dev/docs/state-management An adapter pattern that decouples filter state management from the table components. Use **nuqs** (URL-based), **Zustand** (client-side), **memory** (ephemeral), or build your own adapter. See the [/infinite](/infinite) demo for a live example using the nuqs adapter. ## Schema Definition The schema is the single source of truth for filter field types, defaults, and serialization. ```tsx import { createSchema, field } from "@/lib/store/schema"; export const filterSchema = createSchema({ // Checkbox filters (arrays of values) level: field.array(field.stringLiteral(["success", "warning", "error"])), status: field.array(field.number()).delimiter(","), regions: field.array(field.stringLiteral(["ams", "gru", "syd"])), // Text input filters host: field.string(), // Slider filters (ranges) latency: field.array(field.number()).delimiter("-"), // Date range filter date: field.array(field.timestamp()).delimiter("-"), // Sorting sort: field.sort(), // UI state live: field.boolean().default(false), size: field.number().default(40), }); export type FilterState = typeof filterSchema._type; ``` ## Field Types ```ts // Primitives (default to null) field.string() // string | null field.number() // number | null (parseInt) field.boolean() // boolean | null field.timestamp() // Date | null (serialized as ms) // Constrained field.stringLiteral(["a", "b", "c"]) // 'a' | 'b' | 'c' | null // Sorting field.sort() // { id: string; desc: boolean } | null // Serialized as "columnId.asc" / "columnId.desc" // Arrays field.array(field.string()) // string[] field.array(field.number()) // number[] field.array(field.stringLiteral([...])) // Literal[] // Modifiers (chainable) field.array(field.string()) .default(["default"]) // Default value on reset .delimiter(",") // URL serialization separator ``` ## Adapters Goal is to BYOS (Bring Your Own Store) - so if you want to use a specific state management, you can. Here are some adapters we've implemented for you. ### nuqs Adapter (URL-based) Syncs filter state to URL search params. Enables shareable URLs, browser history, and server-side parsing. Best for most use cases. | Option | Default | Description | | ------------ | -------- | ------------------------------------------- | | `id` | required | Table identifier (namespaces URL params) | | `shallow` | `true` | Don't trigger Next.js navigation on change | | `history` | `"push"` | `"push"` or `"replace"` for browser history | | `throttleMs` | `50` | Throttle URL updates (ms) | | `scroll` | `false` | Scroll to top on filter change | ```tsx import { useNuqsAdapter } from "@/lib/store/adapters/nuqs"; function Client() { const adapter = useNuqsAdapter(filterSchema.definition, { id: "my-table", shallow: true, // Don't trigger navigation history: "push", // "push" | "replace" throttleMs: 50, // Throttle URL updates }); return ( ); } ``` For server-side parsing: ```tsx import { createNuqsSearchParams } from "@/lib/store/adapters/nuqs/server"; export const { searchParamsParser, searchParamsCache, searchParamsSerializer } = createNuqsSearchParams(filterSchema.definition); // In page.tsx export default async function Page({ searchParams }) { const search = await searchParamsCache.parse(searchParams); // Use for server-side data fetching or prefetching... } ``` ### Zustand Adapter (Client-side) Integrates with existing Zustand stores. Use when you already have a Zustand store and want filter state to live alongside your app state, or when you don't want filters in the URL. ```tsx import { createFilterSlice, useZustandAdapter, } from "@/lib/store/adapters/zustand"; import { create } from "zustand"; export const useFilterStore = create>((set, get) => ({ ...createFilterSlice(filterSchema.definition, "my-table", set, get), })); function Client() { const adapter = useZustandAdapter(useFilterStore, filterSchema.definition, { id: "my-table", }); return ( ); } ``` ### Memory Adapter (Ephemeral) Lightweight in-memory state. No URL sync, no external store. State resets on unmount. Useful for embedded tables, the builder, or preview contexts where you don't want to pollute the URL. ```tsx import { useMemoryAdapter } from "@/lib/store/adapters/memory"; const adapter = useMemoryAdapter(filterSchema.definition); ``` ### Custom Adapters Implement the `StoreAdapter` interface: ```ts interface StoreAdapter> { subscribe(listener: () => void): () => void; getSnapshot(): { state: T; version: number }; getServerSnapshot?(): { state: T; version: number }; setState(partial: Partial): void; setField(key: K, value: T[K]): void; reset(fields?: (keyof T)[]): void; pause(): void; resume(): void; isPaused(): boolean; destroy(): void; getTableId(): string; getSchema(): SchemaDefinition; getDefaults(): T; } ``` ## Provider & Hooks ### DataTableStoreProvider Wraps your components with the adapter context: ```tsx {children} ``` ### useFilterState Read filter state. Uses `useSyncExternalStore` for optimal React 18+ compatibility. ```tsx // Read entire state const state = useFilterState(); // Read with selector (only re-renders when selected value changes) const live = useFilterState((s) => s.live); const regions = useFilterState((s) => s.regions); ``` ### useFilterActions Modify filter state. ```tsx const { setFilter, // Set a single field setFilters, // Set multiple fields at once resetFilter, // Reset a single field to default resetAllFilters, // Reset all filters pause, // Pause state updates (for live mode) resume, // Resume and apply queued changes isPaused, // Check if paused } = useFilterActions(); setFilter("regions", ["ams", "gru"]); setFilters({ regions: ["ams"], host: "api.example.com" }); resetFilter("regions"); resetAllFilters(); ``` ### useReactTableSync Bidirectional sync between adapter filter state and React Table's `columnFilters`. Useful when you need React Table's internal filtering to stay in sync with adapter state. ```tsx import { useReactTableSync } from "@/lib/store"; import { dataTableFeatures } from "@/lib/table/features"; import { useTable } from "@tanstack/react-table"; const table = useTable({ features: dataTableFeatures, ... }); useReactTableSync({ table, filterFields, }); ``` ### useFilterField Work with a single field. Combines read and write. ```tsx const { value, setValue, reset } = useFilterField( "regions", ); setValue(["ams", "gru", "fra"]); reset(); // Back to default ``` --- # UI Components Source: https://data-table.openstatus.dev/docs/ui-components See the [/infinite](/infinite) example for a live demo with all components in action. ![data-table with filters and timeline chart](/assets/docs/data-table.png) ## DataTableInfinite The main infinite scroll table component. Located at `src/app/infinite/data-table-infinite.tsx`. ```tsx props.row?.original.pathname} getRowId={(row) => row.uuid} chartData={chartData} chartDataColumnId="date" /> ``` ## DataTableFilterControls The left sidebar with accordion-based filter controls. Renders the appropriate UI component for each filter type: - **Checkbox** — multi-select with search and count display - **Slider** — dual min/max range inputs - **Input** — text search with debounce - **Timerange** — date range picker with preset shortcuts Toggle with `Cmd+B`. For responsive layouts, `DataTableFilterControlsDrawer` provides a mobile-friendly drawer alternative. ## DataTableFilterCommand The command palette for text-based filtering. Opens with `Cmd+K`. Supports filter syntax: - Regular: `host:API` - Union: `regions:ams,gru` - Range: `latency:100-500` - Quoted: `host:"API Server"` (for values with space) ```tsx ``` > Want AI-powered natural language queries in your command palette? Install the `data-table-filter-command-ai` block for an enhanced version that translates free-form text into structured filters. See [AI Filters](/docs/ai-filters). ## DataTableFilterAICommand A drop-in replacement for `DataTableFilterCommand` that adds natural language query support. It includes everything from the standard command palette (structured `key:value` syntax, autocomplete, history) plus: - **AI inference** — type a free-form query like _"5xx errors in the last hour"_ and press Enter. The input is sent to your AI endpoint, which streams back structured filters. - **TextShimmer** — while the AI is processing, the closed command bar displays the query with a shimmer animation so the user knows it's working. - **Sparkles indicator** — an `AI:` badge in the footer and a sparkles icon on the infer suggestion hint that the input is being treated as natural language. The component automatically detects whether the input is structured (`host:API`) or natural language (`show me slow requests`) based on whether it matches known field names. ```tsx ``` | Prop | Type | Description | | ------------- | ----------------------- | ------------------------------------------------------------- | | `schema` | `SchemaDefinition` | BYOS schema for parsing/serializing filter values | | `tableSchema` | `TableSchemaDefinition` | Table schema for AI context generation | | `api` | `string` | API endpoint that streams AI filter results | | `tableId` | `string` | Unique ID for localStorage namespacing (default: `"default"`) | > For full setup including the API route and provider configuration, see [AI Filters](/docs/ai-filters). ## DataTableSheetDetails ![data-table sheet detail drawer](/assets/docs/data-table-sheet.png) Row detail drawer. Opens when a row is selected. Supports keyboard navigation between rows. ```tsx {row?.original.pathname}} titleClassName="font-mono" > ``` ## DataTableFloatingBar A fixed bottom bar that appears when rows are selected, providing a slot for bulk action buttons. Dismisses when no rows are selected. - Shows `"{n} selected"` count with a deselect button - `Cmd+Shift+X` hotkey to deselect all rows - Uses a render prop that receives the selected `rows` and `table` instance ```tsx > {({ rows, table }) => ( )} ``` Pass it via the `floatingBarSlot` prop on `DataTableInfinite`: ```tsx > {({ rows }) => } } // ...other props /> ``` ## Cell Components Built-in cell renderers (used automatically by `generateColumns`): - `DataTableCellText` — plain text with overflow tooltip - `DataTableCellCode` — monospace - `DataTableCellNumber` — formatted with optional unit - `DataTableCellBar` — the number over a bar filled to its share of `min`–`max` - `DataTableCellHeatmap` — the number over a cell tinted by that same share - `DataTableCellGauge` — the number beside a circular gauge - `DataTableCellTimestamp` — relative time with absolute tooltip - `DataTableCellBadge` — colored chip - `DataTableCellBoolean` — checkmark / dash - `DataTableCellStar` — filled yellow star / outlined muted star - `DataTableCellStatusCode` — HTTP status styling - `DataTableCellLevelIndicator` — severity dot Each takes an optional `color` (a hex string). Rather than passing it per cell, set `colorMap` on the column's display config — `generateColumns` and the sheet look the row's value up in it and pass the result through as `color`. ## Filter Functions Columns produced by `generateColumns` carry their `filterFn` as a **function**, resolved from the table schema's declared semantics. They need no registration — the same interpretation runs on the client, in the in-memory routes, and in SQL. Registration is only needed for columns you write by hand that refer to a filter function by name. In TanStack Table v9 that registration lives on the feature set, not on the table config: ```ts // lib/table/features.ts import { filterFn_arrIncludes, filterFn_arrIncludesSome, filterFn_equals, filterFn_includesString, filterFn_inNumberRange, filterFn_weakEquals, tableFeatures, } from "@tanstack/react-table"; import { arrSome, inDateRange } from "@/lib/table/filterfns"; export const dataTableFeatures = tableFeatures({ // ...features and row models filterFns: { // The six names `filterFn: 'auto'` resolves to. Register all of them — // see the note below. `inDateRange` is the sixth, supplied further down. includesString: filterFn_includesString, inNumberRange: filterFn_inNumberRange, equals: filterFn_equals, arrIncludes: filterFn_arrIncludes, weakEquals: filterFn_weakEquals, // Not auto-resolved: registered because columns reference it by name. arrIncludesSome: filterFn_arrIncludesSome, // Ours. `inDateRange` deliberately shadows the v9 built-in of the same // name, so it also serves `'auto'` for Date columns. inDateRange, arrSome, }, }); ``` - `inDateRange` — matches dates within [start, end] range - `arrSome` — matches if row value is in the filter array `filterFn` defaults to `'auto'`, which resolves by data type to `includesString`, `inNumberRange`, `equals`, `arrIncludes`, `inDateRange`, or `weakEquals`. Unlike v8, an unregistered name does not fall back — the column simply stops filtering (and warns in development) — so `features.ts` registers that whole set. ## Extending TanStack Table Types v9 threads a `TFeatures` type parameter through every table type, including the declaration-merged meta interfaces: ```tsx import "@tanstack/react-table"; declare module "@tanstack/react-table" { interface TableMeta< in out TFeatures extends TableFeatures, in out TData extends RowData, > { getRowClassName?: (row: Row) => string; } interface ColumnMeta< in out TFeatures extends TableFeatures, in out TData extends RowData, TValue extends CellData = CellData, > { headerClassName?: string; cellClassName?: string; label?: string; kind?: string; } } ``` The v8 `FilterFns` augmentation is gone — filter functions are registered in the `filterFns` slot on `tableFeatures()` instead (see [Filter Functions](#filter-functions)). --- # Data Fetching Source: https://data-table.openstatus.dev/docs/data-fetching Data fetching is powered by [TanStack React Query](https://tanstack.com/query) using `useInfiniteQuery` for cursor-based pagination. A factory function creates the query options so you don't have to wire up cursors, serialization, or caching manually. See the [/infinite](/infinite) demo for a working example. ### Query Options Factory `createDataTableQueryOptions` generates `infiniteQueryOptions` for your table. It handles cursor management, search param serialization, and [SuperJSON](https://github.com/flightcontrolhq/superjson) deserialization: ```ts import { createDataTableQueryOptions } from "@/lib/data-table"; const _dataOptions = createDataTableQueryOptions({ queryKeyPrefix: "my-table", apiEndpoint: "/my-table/api", searchParamsSerializer: searchParamsSerializer, }); export const dataOptions = (search: SearchParamsType) => _dataOptions(search as unknown as Record); ``` The factory configures: - **Query key** — derived from serialized search params (excludes `cursor`, `direction`, `uuid`, `live` for stable cache keys) - **Initial cursor** — `Date.now()` (most recent data first) - **Page params** — `getNextPageParam` / `getPreviousPageParam` from `nextCursor` / `prevCursor` - **Caching** — `keepPreviousData` for smooth filter transitions, 5-minute stale time, no refetch on window focus ### Skipping Metadata on Pagination `meta` (chart data, facets, your own `metadata`) is computed over the whole filtered set, so it is identical on every page — yet a naive setup recomputes and re-sends it on every infinite-scroll fetch. Set `skipMetaOnPagination` to append `_meta=false` to pagination requests so your route can skip that work: ```ts const _dataOptions = createDataTableQueryOptions({ queryKeyPrefix: "my-table", apiEndpoint: "/my-table/api", searchParamsSerializer: searchParamsSerializer, skipMetaOnPagination: true, }); ``` It is opt-in because it only works if **both** halves are in place: 1. **Your route honors it.** Read the raw param and skip the aggregation: ```ts const skipMeta = req.nextUrl.searchParams.get("_meta") === "false"; const chartData = skipMeta ? [] : groupChartData(filteredData, date); const facets = skipMeta ? {} : getFacetsFromData(filteredData); ``` Only `meta` is skippable. Per-row fields still have to be returned on every page — skipping one leaves the rows fetched by scrolling missing a value the table renders. 2. **Your client reads meta from the right page.** Use `getMetaPage`, not the last page — live mode prepends pages with `fetchPreviousPage`, so the page carrying meta is not always at index `0`: ```tsx import { getMetaPage } from "@/lib/data-table"; const metaPage = getMetaPage(data); const chartData = metaPage?.meta?.chartData; const facets = metaPage?.meta?.facets; ``` `getMetaPage` identifies that page from React Query's `pageParams`, and falls back to the last page — so it is safe to use whether or not meta skipping is on. > `_meta` is appended to the URL after your serializer runs, so it works even with an allow-list serializer that drops unknown keys. It never enters the query key. ### useInfiniteQuery Pattern ```tsx function DataTableContent() { const search = useFilterState(); const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery( dataOptions(search), ); // Flatten pages into a single array const flatData = React.useMemo( () => data?.pages?.flatMap((page) => page.data ?? []) ?? [], [data?.pages], ); // Derive column filters from state (exclude non-filter fields) const { sort, cursor, direction, uuid, live, size, ...filter } = search; const defaultColumnFilters = React.useMemo(() => { return Object.entries(filter) .map(([key, value]) => ({ id: key, value })) .filter(({ value }) => { if (value === null || value === undefined) return false; if (Array.isArray(value) && value.length === 0) return false; return true; }); }, [filter]); return ( ); } ``` When the user scrolls to the bottom, `DataTableInfinite` calls `fetchNextPage`. React Query fetches the next page using the `nextCursor` from the last page's response and appends it to `data.pages`. ### Server Prefetch Server-side prefetching with React Query's `HydrationBoundary` avoids a loading spinner on first render: ```tsx // page.tsx import { getQueryClient } from "@/providers/get-query-client"; import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; export default async function Page({ searchParams }) { const search = await searchParamsCache.parse(searchParams); const queryClient = getQueryClient(); await queryClient.prefetchInfiniteQuery(dataOptions(search)); return ( ); } ``` The query is prefetched on the server and dehydrated into the HTML. On the client, `useInfiniteQuery` picks up the cached data immediately — no extra network request. ### Transport `createDataTableQueryOptions` defaults to a same-origin `fetch` and a [SuperJSON](https://github.com/flightcontrolhq/superjson) payload — right for an endpoint you ship alongside the app, and wrong for anything else. The `transport` option covers the rest: ```ts import { createDataTableQueryOptions, schemaJsonParser, } from "@/lib/data-table"; const _dataOptions = createDataTableQueryOptions({ queryKeyPrefix: "my-table", apiEndpoint: "/logs", searchParamsSerializer, transport: { baseUrl: "https://api.example.com", // A function, so an auth token is read fresh on every request rather than // captured once at module scope. It may be async, to await a refresh. headers: async () => ({ authorization: `Bearer ${await getToken()}` }), credentials: "include", // The endpoint returns ordinary JSON; revive its timestamp columns from // the schema instead of asking it to adopt SuperJSON. parseResponse: schemaJsonParser(schema), }, }); ``` | Option | Default | | --------------- | ---------------------------------------------------------------------------------------- | | `baseUrl` | Same origin (or `VERCEL_URL` on the server) — a string, or a function called per request | | `headers` | None — a value, or a sync/async function | | `credentials` | The `fetch` default | | `fetch` | Global `fetch` | | `parseResponse` | `superjsonParser()` | Three parsers ship: `superjsonParser()` (the default), `jsonParser()` (the body verbatim), and `schemaJsonParser(schema)` — which reads the schema to find the `timestamp` columns and revives them from ISO strings, so a third-party endpoint works without changing its serializer. A cross-origin `baseUrl` needs CORS on the endpoint. ### Errors A non-2xx response, or a body that cannot be parsed, throws `DataTableFetchError` rather than letting a 500's HTML reach `SuperJSON.parse` and surface as an opaque syntax error: ```tsx const { error } = useInfiniteQuery(dataOptions(search)); if (error instanceof DataTableFetchError) { // error.status, error.url, and the first 500 chars of the body } ``` React Query's `AbortSignal` is forwarded to `fetch`, so a superseded request is cancelled rather than left in flight. ### Pagination The default addresses pages by a millisecond timestamp read from a date column, paging both ways — the right default for an append-only log and wrong for anything else. It is one strategy among three: ```ts import { offsetPagination, opaqueCursorPagination, timestampCursorPagination, } from "@/lib/data-table"; createDataTableQueryOptions({ // … pagination: offsetPagination({ size: 50 }), }); ``` | Strategy | Wire format | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `timestampCursorPagination()` | `?cursor=&direction=next\|prev` — the default, bidirectional | | `opaqueCursorPagination()` | `?cursor=` — the server's `nextCursor` echoed back verbatim, forward-only | | `offsetPagination({ size })` | `?offset=&size=` — honours `nextCursor` as the next offset when the server sends one, otherwise advances until a page comes back short | Each strategy owns its parameter names (`cursorKey`, `offsetKey`, …) and clears them from the cache key, so every page of one filter state shares a single query key. The page param React Query carries is `{ page, _meta }` — the strategy owns `page`, and the [meta-skipping](#skipping-metadata-on-pagination) flag sits outside it so the same flag works whether pages are addressed by cursor, opaque token, or offset. `getMetaPage` reads `_meta` and is unaffected by which strategy is in use. To point the whole table at an endpoint rather than wiring this by hand, see [Headless Tables](/docs/headless). --- # Data Layer Source: https://data-table.openstatus.dev/docs/data-layer These concepts apply regardless of your ORM — Drizzle, Prisma, or raw SQL. The [Drizzle ORM](/docs/drizzle-orm) guide shows a concrete implementation. See the [/infinite](/infinite) demo for a live example with all data layer features in action. ### API Response Shape Your API endpoint should return this shape (serialized with [SuperJSON](https://github.com/flightcontrolhq/superjson) to preserve `Date` objects): ```ts { data: ColumnSchema[]; meta: { totalRowCount: number; // Total rows in the table (unfiltered) filterRowCount: number; // Rows matching current filters chartData: BaseChartSchema[]; facets: Record; metadata?: TMeta; // Custom metadata (e.g. percentiles) }; nextCursor: number | null; // Timestamp (ms) of last row prevCursor: number | null; // Timestamp (ms) of first row } ``` ### Request Format The reserved query parameters, and how a filter value is encoded, used to exist only implicitly inside the client's serializer. They are now written down as `REQUEST_PARAMS` and `FILTER_ENCODING`: | Parameter | Meaning | | -------------- | ----------------------------------------------- | | `cursor` | Page cursor — epoch ms for the default strategy | | `direction` | `next` (older) or `prev` (newer) | | `size` | Page size | | `sort` | `.` | | `uuid`, `live` | View state — these never narrow the result set | Every other parameter is a column key: | Filter type | Encoding | | ----------- | ---------------------------------------------- | | `checkbox` | `?level=error,warn` | | `slider` | `?latency=100-3000` (inclusive both ends) | | `timerange` | `?date=1700000000000-1700003600000` (epoch ms) | | `input` | `?host=example.com` | Two constraints follow from the delimiters: a checkbox value may not contain a comma, and slider or timerange bounds may not be negative. Because `uuid` and `live` do not narrow results, an endpoint that rejects unrecognised query parameters will break on them. Ignore what you do not know. ### Validating a Response `validateListResponse` checks a payload against the contract. It returns issues rather than throwing, split by severity: an `error` means the table cannot render the response, a `warning` means it can but the endpoint is not doing what it claimed: ```ts import { isContractValid, validateListResponse } from "@/lib/data-table"; const issues = validateListResponse(payload, { capabilities: { facets: true, totalRowCount: true }, }); if (!isContractValid(issues)) { console.error(issues.filter((issue) => issue.severity === "error")); } ``` Passing `capabilities` is what turns "this field is absent" into a warning: a response with no `meta.facets` is fine unless the endpoint said it computes them. To check a whole endpoint rather than one response, see [Headless Tables](/docs/headless#proving-an-endpoint-conforms). ### Three-Pass Filtering A strategy to keep slider bounds stable when users adjust them. Without it, dragging a slider collapses its own min/max range. **Pass 1 — Date only** Apply only the date range filter. This gives the base time window for all facet computation. **Pass 2 — Date + non-slider filters** Add checkbox, input, and other non-slider filters. Use these conditions to compute slider facet bounds (min/max). Because slider values are excluded, moving a slider doesn't shrink its own range. **Pass 3 — All filters (including sliders)** The final set of conditions used for the data query, counts, and non-slider facets. ``` Date filters ──────────────────────────┐ ├─▶ Pass 1 → (used for date-only facets) │ + Non-slider filters ─────────────────├─▶ Pass 2 → Slider min/max bounds │ + Slider filters ─────────────────────└─▶ Pass 3 → Data, counts, checkbox facets ``` ### Faceted Search Facets provide grouped counts for filter options and min/max ranges for sliders. They're returned in `meta.facets`: ```ts type FacetMetadataSchema = { rows: Array<{ value: string | number | boolean; total: number }>; total: number; min?: number; // For slider and numeric columns max?: number; }; ``` **Checkbox facets** — grouped counts per value (e.g. `{ success: 120, error: 8 }`). Computed from Pass 3 conditions. **Slider facets** — `min`/`max` of the column. Computed from Pass 2 conditions so they don't collapse. **Array columns** — unnest the array values before grouping (e.g. PostgreSQL `unnest()`). All facet queries can run in parallel for better performance. #### Client-Side Usage Inject server-side facets into your filter fields at runtime so checkboxes show counts and sliders get dynamic bounds: ```tsx const dynamicFilterFields = React.useMemo(() => { return filterFields.map((field) => { const facetsField = facets?.[field.value as string]; if (!facetsField) return field; if (field.options && field.options.length > 0) return field; const options = facetsField.rows.map(({ value }) => ({ label: `${value}`, value, })); if (field.type === "slider") { return { ...field, min: facetsField.min ?? field.min, max: facetsField.max ?? field.max, options, }; } return { ...field, options }; }); }, [facets]); ``` ### Cursor Pagination Cursor-based pagination (not offset) is used for infinite scroll. It's stable under concurrent inserts and performs better on large tables. - Uses a timestamp column (e.g. `date`) as the cursor - **Next page** (older rows): `WHERE date < cursor ORDER BY date DESC LIMIT size` - **Previous page** (newer rows, for live mode): `WHERE date > cursor ORDER BY date ASC LIMIT size` (then reverse the results) - Client sends `cursor` + `direction`, server returns `nextCursor` + `prevCursor` ### Chart Data Optional time-series aggregation for the timeline chart. Group rows into time buckets and count by category: ```ts type BaseChartSchema = { timestamp: number; // UNIX ms (bucket start) [key: string]: number; // e.g. "success", "warning", "error" }; ``` Use `date_bin()` (PostgreSQL) or equivalent for intelligent bucketing. The interval should adapt to the date range — smaller ranges get finer buckets. ### Indexing Add database indexes on columns used for filtering, sorting, and pagination: - **Cursor column** (e.g. `date`) — required for cursor pagination performance - **Frequently filtered columns** (e.g. `level`, `status`) — speeds up WHERE clauses - **Composite indexes** — useful when filters are often combined Use `EXPLAIN ANALYZE` to verify your indexes are being used. ```ts import { index, pgTable } from "drizzle-orm/pg-core"; export const logs = pgTable( "logs", { // ... columns }, (table) => [ index("logs_date_idx").on(table.date), index("logs_level_idx").on(table.level), index("logs_status_idx").on(table.status), ], ); ``` --- # Drizzle ORM Source: https://data-table.openstatus.dev/docs/drizzle-orm A step-by-step guide to wiring up the data table with [Drizzle ORM](https://orm.drizzle.team) and PostgreSQL. See the [live example](/drizzle) and the [Database](/docs/data-layer) page for the concepts behind the implementation. ### File Checklist Every file you need to create: | File | Purpose | | ------------------------------- | ---------------------------------------------------------------- | | `db/drizzle/schema.ts` | Drizzle table definition (enums, columns) | | `db/drizzle/index.ts` | Database client | | `app/[route]/column-mapping.ts` | Maps schema keys → Drizzle columns | | `app/[route]/table-schema.tsx` | UI column config (display, filters, sorting) | | `app/[route]/schema.ts` | Zod validation + ColumnSchema type | | `app/[route]/search-params.ts` | URL search param parsers (derived from schema) | | `app/[route]/query-options.ts` | React Query infinite query config | | `app/[route]/api/route.ts` | API handler with `createDrizzleHandler` | | `app/[route]/api/ai/route.ts` | AI filter handler (optional, see [AI Filters](/docs/ai-filters)) | | `app/[route]/client.tsx` | Client component | | `app/[route]/page.tsx` | Page wrapper | ### Step 1: Define Your Drizzle Table Define your PostgreSQL table with Drizzle's schema builder: ```ts // db/drizzle/schema.ts import { integer, jsonb, pgEnum, pgTable, text, timestamp, uuid, } from "drizzle-orm/pg-core"; export const levelEnum = pgEnum("level", ["success", "warning", "error"]); export const methodEnum = pgEnum("method", ["GET", "POST", "PUT", "DELETE"]); export const logs = pgTable("logs", { uuid: uuid("uuid").defaultRandom().primaryKey(), level: levelEnum("level").notNull(), method: methodEnum("method").notNull(), host: text("host").notNull(), pathname: text("pathname").notNull(), status: integer("status").notNull(), latency: integer("latency").notNull(), regions: text("regions").array().notNull(), date: timestamp("date", { withTimezone: true }).notNull(), timingDns: integer("timing_dns").notNull(), timingConnection: integer("timing_connection").notNull(), timingTls: integer("timing_tls").notNull(), timingTtfb: integer("timing_ttfb").notNull(), timingTransfer: integer("timing_transfer").notNull(), headers: jsonb("headers").$type>().notNull(), message: text("message"), }); ``` Set up the database client using the `node-postgres` driver: ```ts // db/drizzle/index.ts import { drizzle } from "drizzle-orm/node-postgres"; import * as schema from "./schema"; const connectionString = process.env.DATABASE_URL; if (!connectionString) { throw new Error("DATABASE_URL environment variable is not set"); } export const db = drizzle(connectionString, { schema }); ``` > **Note:** If you're using a connection pooler (Supabase transaction mode, PgBouncer, Neon), make sure to use the transaction pooler URL (port `6543` for Supabase). Install `pg` as a dependency: `pnpm add pg @types/pg`. ### Step 2: Column Mapping The column mapping bridges your table schema keys to Drizzle columns. This is the **only file that couples UI to DB** — one file per table: ```ts // app/drizzle/column-mapping.ts import { logs } from "@/db/drizzle/schema"; import type { ColumnMapping } from "@/lib/drizzle"; export const columnMapping = { level: logs.level, date: logs.date, status: logs.status, latency: logs.latency, method: logs.method, host: logs.host, pathname: logs.pathname, regions: logs.regions, "timing.dns": logs.timingDns, "timing.connection": logs.timingConnection, "timing.tls": logs.timingTls, "timing.ttfb": logs.timingTtfb, "timing.transfer": logs.timingTransfer, } satisfies ColumnMapping; ``` Keys can use dot notation (`"timing.dns"`) to map nested UI names to flat database columns (`timingDns`). This mapping is also the **projection**: the handler selects with it, so rows come back keyed by your schema keys. No caller ever sees `timingDns`. A key that is missing from the mapping throws at handler construction with the exact snippet to paste — it used to make the filter silently stop filtering. ### Step 3: Table Schema Define how each column appears in the UI — labels, display format, filter type, sizing: ```tsx // app/drizzle/table-schema.tsx import { col, createTableSchema } from "@/lib/table-schema"; export const tableSchema = createTableSchema({ // Checkbox filter with enum options level: col .enum(["success", "warning", "error"]) .label("Level") .filterable("checkbox", { options: [ { label: "success", value: "success" }, { label: "warning", value: "warning" }, { label: "error", value: "error" }, ], }) .size(80), // Timerange filter for date column date: col .timestamp() .label("Date") .display("timestamp") .defaultOpen() .size(200) .sortable(), // Checkbox filter with number options status: col .number() .label("Status") .filterable("checkbox", { options: [ { label: "200", value: 200 }, { label: "400", value: 400 }, { label: "404", value: 404 }, { label: "500", value: 500 }, ], }) .size(60), // Slider filter with range latency: col .number() .label("Latency") .filterable("slider", { min: 0, max: 5000, unit: "ms" }) .size(110) .sortable(), // Text input filter host: col.string().label("Host").filterable("input").size(125), // ... add more columns as needed }); ``` The filter type you choose here (`checkbox`, `slider`, `input`, `timerange`) determines how the [three-pass filtering strategy](/docs/data-layer#three-pass-filtering) handles each column. Additional methods: `hidden()` to hide by default, `sortable()` for sort support, `sheet()` for the row detail drawer, `resizable()` for resizable columns. ### Step 4: API Route The API route uses `createDrizzleHandler` which implements the [three-pass filtering strategy](/docs/data-layer#three-pass-filtering), faceted search, counts, and cursor pagination: ```ts // app/drizzle/api/route.ts import { db } from "@/db/drizzle"; import { logs } from "@/db/drizzle/schema"; import { createDrizzleHandler } from "@/lib/drizzle"; import { defineFilters } from "@/lib/filters"; import { NextRequest } from "next/server"; import SuperJSON from "superjson"; import { columnMapping } from "../column-mapping"; import { searchParamsCache } from "../search-params"; import { tableSchema } from "../table-schema"; export const dynamic = "force-dynamic"; const handler = createDrizzleHandler({ db, table: logs, filters: defineFilters(tableSchema.definition), // declared filter semantics columnMapping, cursorColumn: "date", defaultSize: 40, }); export async function GET(req: NextRequest): Promise { const _search: Map = new Map(); req.nextUrl.searchParams.forEach((value, key) => _search.set(key, value)); const search = searchParamsCache.parse(Object.fromEntries(_search)); const result = await handler.execute(search as Record); // No remap needed — rows are already keyed by schema keys. const data = result.data as unknown as ColumnSchema[]; return Response.json( SuperJSON.stringify({ data, meta: { totalRowCount: result.totalRowCount, filterRowCount: result.filterRowCount, chartData: [], // Add chart data query if needed facets: result.facets, }, prevCursor: result.prevCursor, nextCursor: result.nextCursor, }), ); } ``` Rows are ordered by `cursorColumn`, then by the user's sort, then by a unique tiebreak column — without that last one, rows sharing a timestamp come back in whatever order the query plan produced, and that order changes as rows are written. The tiebreak defaults to the table's single-column primary key. A table with a composite primary key (or none) has to name one itself: ```ts const handler = createDrizzleHandler({ // ... cursorColumn: "date", tiebreakColumn: "uuid", // must be unique }); ``` `filters` carries the declared filter semantics — the slider, facet, and date key groupings are derived from it. When `tableSchema` lives in a `"use client"` file and can't be imported on the server, pass the serialized schema instead: `defineFilters(schemaJson)`. The declaration crosses the boundary as data, so the server still gets real semantics rather than three untyped `string[]`s. > To add AI-powered filtering, create a second route at `app/[route]/api/ai/route.ts` using `createAIFilterHandler` from `@/lib/ai`. It reuses the same `tableSchema` to generate the AI prompt and output schema. See [AI Filters — API Route Setup](/docs/ai-filters#api-route-setup). ### Step 5: Client Component Wire up `useInfiniteQuery` to `DataTableInfinite`. The key pattern is populating filter fields dynamically from the facet data returned by the API: ```tsx // app/drizzle/client.tsx "use client"; import { DataTableInfinite } from "@/components/data-table/data-table-infinite"; import { getMetaPage } from "@/lib/data-table"; import { DataTableStoreProvider, useFilterState } from "@/lib/store"; import { useNuqsAdapter } from "@/lib/store/adapters/nuqs"; import { generateColumns, generateFilterFields, getDefaultColumnVisibility, } from "@/lib/table-schema"; import { useInfiniteQuery } from "@tanstack/react-query"; import * as React from "react"; import { dataOptions } from "./query-options"; import type { ColumnSchema, FilterState } from "./schema"; import { filterSchema } from "./schema"; import { tableSchema } from "./table-schema"; const columns = generateColumns(tableSchema.definition); const filterFields = generateFilterFields(tableSchema.definition); const defaultColumnVisibility = getDefaultColumnVisibility( tableSchema.definition, ); export function Client() { const adapter = useNuqsAdapter(filterSchema.definition, { id: "drizzle" }); return ( ); } function ClientInner() { const search = useFilterState(); const { data, isFetching, isLoading, fetchNextPage, hasNextPage } = useInfiniteQuery(dataOptions(search)); const flatData = React.useMemo( () => data?.pages?.flatMap((page) => page.data ?? []) ?? [], [data?.pages], ); const metaPage = getMetaPage(data); const facets = metaPage?.meta?.facets; // Populate filter options from server-side facet data const dynamicFilterFields = React.useMemo(() => { return filterFields.map((field) => { const facetsField = facets?.[field.value as string]; if (!facetsField) return field; if (field.options && field.options.length > 0) return field; const options = facetsField.rows.map(({ value }) => ({ label: `${value}`, value, })); if (field.type === "slider") { return { ...field, min: facetsField.min ?? field.min, max: facetsField.max ?? field.max, options, }; } return { ...field, options }; }); }, [facets]); const { sort, size, uuid, cursor, direction, live, ...filter } = search; const defaultColumnFilters = React.useMemo(() => { return Object.entries(filter) .map(([key, value]) => ({ id: key, value })) .filter(({ value }) => { if (value === null || value === undefined) return false; if (Array.isArray(value) && value.length === 0) return false; return true; }); }, [filter]); return ( ); } ``` ### Step 6: Page Wrapper A minimal server component with `HydrationBoundary` for React Query: ```tsx // app/drizzle/page.tsx import { getQueryClient } from "@/providers/get-query-client"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { Client } from "./client"; export const dynamic = "force-dynamic"; export default function Page() { return ( ); } ``` ### Helper Functions Reference The `@/lib/drizzle` module exports four functions that `createDrizzleHandler` uses internally. You can also use them directly for custom query logic: ```ts import { buildWhereConditions, buildOrderBy, buildCursorPagination, computeFacets, } from "@/lib/drizzle"; ``` - **`buildWhereConditions(filters, values, mapping, selection?)`** — converts filter state to SQL WHERE clauses. It never inspects the shape of a filter value: `filters.plan()` turns declared semantics into a closed set of six operations, and each compiles to exactly one SQL form (`ilike`, `=`, `IN`, `&&`, `BETWEEN`, or a date range). - **`buildOrderBy(mapping, sort)`** — returns an `asc` or `desc` SQL clause from a sort descriptor. - **`buildCursorPagination({ cursor, direction, size, cursorColumn, tiebreakColumn? })`** — returns cursor condition, order clause, an optional tiebreak clause, and a `needsReverse` flag for bidirectional infinite scroll. - **`computeFacets(db, table, mapping, conditions, facetKeys, options?)`** — computes grouped counts (or min/max for slider keys) via parallel SQL queries. - **`evaluateIntervalMs(durationMs)`** — the bucket-size ladder used for time-series aggregation. ### Writing your own aggregate SQL `result.scope` is one typed handle onto the query the handler just ran, so a chart or rollup query does not have to re-derive anything: ```ts const { db, table, where, range, bucketMs } = result.scope; ``` | field | what it is | | --------------------- | ------------------------------------------------------------------ | | `where` | the fully filtered set | | `whereWithoutSliders` | the set slider bounds are computed over | | `range` | resolved from an explicit date filter, else `MIN`/`MAX` of the set | | `bucketMs` | bucket size for `range`, interval ladder already applied | | `columns` | the column mapping | This replaces the older `result.allConditions`, which exposed just enough to make every caller rebuild the range, the bucket size, and the WHERE composition by hand. ## Row actions The write-side sibling of `createDrizzleHandler` is `createActionHandler`, from the `data-table-actions` block. It shares the same `filters` and `columnMapping`, so an action's availability guard and a filter-scoped request compile through the very same `buildWhereConditions` the list endpoint uses. See [Row Actions](/docs/actions). --- # Features Source: https://data-table.openstatus.dev/docs/features Try all features live in the [/infinite](/infinite) demo. ### Timeline Chart The timeline chart visualizes data distribution over time as a stacked bar chart. ```ts type BaseChartSchema = { timestamp: number; // UNIX ms [key: string]: number; // Level counts, e.g. "success", "warning", "error" }; ``` Pass chart data via the `chartData` and `chartDataColumnId` props on `DataTableInfinite`. Selecting a range on the chart sets the corresponding date filter. #### Zoom The timeline chart supports click-and-drag zoom. Click and drag across bars to select a time range — a `ReferenceArea` marks the selection with a line and grip on each end, fades the buckets outside it, and a card shows the range, duration, and row count as you drag. On mouse up the selection is parked rather than applied: the same card grows **Cancel** and **Zoom** buttons, so confirm with **Zoom** (or `Enter`) to set the date filter on the table, or discard with **Cancel** (or `Escape`). The axis tick format adapts automatically based on the visible time span (seconds for ≤10 min, hours for ≤1 day, day+hour for ≤1 week, full date otherwise). ### Live Mode Live mode polls for new data every 5 seconds using `fetchPreviousPage` from `useInfiniteQuery`. New rows are prepended to the data array. The `live` field in the filter schema controls this: ```ts live: field.boolean().default(false), ``` When live mode is active, rows older than the activation timestamp are visually dimmed. ### Keyboard Shortcuts Built-in keyboard shortcuts for common interactions: - `Cmd+K` — Toggle command palette - `Cmd+B` — Toggle filter sidebar - `Cmd+U` — Reset column state (order, visibility) - `Cmd+J` — Toggle live mode - `Esc` — Reset table filters - `Cmd+.` — Reset element focus - `Cmd+Shift+X` — Deselect all rows (when rows are selected) > Row selection requires `col.select()` in your [Table Schema](/docs/table-schema#row-selection-with-colselect). Use [DataTableFloatingBar](/docs/ui-components#datatablefloatingbar) to add bulk action buttons to the selection bar. --- # AI Filters Source: https://data-table.openstatus.dev/docs/ai-filters The `data-table-filter-command-ai` block adds natural language filtering to your data table. Type a query like _"5xx errors in production last 24h"_ and the AI translates it into structured filters — applied progressively as the response streams in. Provider-agnostic. Works with any LLM via the [Vercel AI SDK](https://sdk.vercel.ai). ## Installation ```bash npx shadcn@latest add @data-table-filters/data-table-filter-command-ai ``` This installs the AI command palette component and the `@/lib/ai` utilities. ## Prerequisites - The **data-table-filter-command** block must be installed (auto-installed as a dependency) - The **data-table-schema** block must be installed — AI context is generated from your table schema - An LLM provider package (e.g., `@ai-sdk/anthropic`, `@ai-sdk/openai`) ```bash pnpm add @ai-sdk/anthropic ``` ## API Route Setup Create a POST route that streams AI-inferred filter state. The `createAIFilterHandler` factory generates the system prompt and Zod output schema from your table schema automatically. ### Direct Provider ```ts // app/api/ai-filters/route.ts import { anthropic } from "@ai-sdk/anthropic"; import { createAIFilterHandler } from "@/lib/ai"; import { tableSchema } from "../table-schema"; export const POST = createAIFilterHandler({ model: anthropic("claude-sonnet-4-20250514"), schema: tableSchema.definition, }); ``` Set your API key in `.env`: ``` ANTHROPIC_API_KEY=sk-ant-... ``` Swap `anthropic(...)` for any Vercel AI SDK provider — `openai("gpt-4o-mini")`, `google("gemini-2.0-flash")`, etc. ### Vercel AI Gateway The [live demo](/drizzle) uses the Vercel AI Gateway, which provides a unified endpoint for multiple providers: ```ts // app/api/ai-filters/route.ts import { createAnthropic } from "@ai-sdk/anthropic"; import { createAIFilterHandler } from "@/lib/ai"; import { tableSchema } from "../table-schema"; const anthropic = createAnthropic({ baseURL: "https://ai-gateway.vercel.sh/v1", apiKey: process.env.AI_GATEWAY_API_KEY, }); export const POST = createAIFilterHandler({ model: anthropic("anthropic/claude-sonnet-4-20250514"), schema: tableSchema.definition, }); ``` ## Client Integration Drop `DataTableFilterAICommand` into the `commandSlot` prop of `DataTableInfinite`: ```tsx import { DataTableFilterAICommand } from "@/components/data-table/data-table-filter-command-ai"; } />; ``` ### Props | Prop | Type | Description | | ------------- | ----------------------- | --------------------------------------------------------- | | `schema` | `SchemaDefinition` | BYOS filter schema for structured query parsing | | `tableSchema` | `TableSchemaDefinition` | Table schema for AI context and output schema generation | | `api` | `string` | API endpoint path (e.g., `"/api/ai-filters"`) | | `tableId` | `string` | Unique ID for localStorage history (default: `"default"`) | ## How It Works The AI command palette shares the same input as the regular command palette. What happens when you press Enter depends on the input: 1. **Structured input** (`host:api`, `regions:ams,gru`, `latency:100-500`) — parsed instantly by the existing command palette. No AI involved. 2. **Natural language** (_"slow requests from eu regions"_) — sent to your API route. The AI streams back a structured JSON object matching your table schema's filter types. The response is **applied progressively** as it streams: - **Input** and **checkbox** filters update immediately as values arrive - **Slider** and **timerange** filters wait until both bounds are present before applying (to avoid flashing partial ranges) After the stream completes, a final validation pass clamps slider values to defined bounds, strips invalid checkbox options, and converts ISO date strings to Date objects. The command palette also stores the last 5 searches in localStorage (namespaced by `tableId`) for quick re-use. ## Schema Considerations The quality of AI filter inference depends directly on the metadata in your table schema. Two things matter most: ### Add descriptions to your columns Descriptions are essential for AI filtering accuracy. Without them, the AI only sees field names and types, which can lead to ambiguous results. A `description` tells the AI what each column represents: ```ts const tableSchema = createTableSchema({ host: col .string() .label("Host") .description("Origin server hostname, e.g. api.example.com") .filterable("input"), latency: col .number() .label("Latency") .description("Response time in milliseconds") .filterable("slider", { min: 0, max: 5000, unit: "ms" }), }); ``` The description, label, allowed values, min/max bounds, and unit are all included in the AI prompt. The more context you provide, the better the inference. ### `commandDisabled` fields are still available to AI Fields with `.commandDisabled()` are hidden from the manual command palette suggestions, but they are **still included in the AI prompt**. This is intentional — some fields are hard to use with `key:value` syntax but easy to express in natural language. For example, the `date` column in the [live demo](/drizzle) has `commandDisabled()` because manually typing ISO date ranges is impractical. But saying _"last 24 hours"_ or _"this week"_ works naturally with the AI. ```ts date: col .timestamp() .label("Date") .commandDisabled() // Hidden from manual palette, available to AI .sortable(), ``` ## Customization ### Swapping providers Replace the model in your API route. Any [Vercel AI SDK provider](https://sdk.vercel.ai/providers) works: ```ts import { openai } from "@ai-sdk/openai"; export const POST = createAIFilterHandler({ model: openai("gpt-4o-mini"), schema: tableSchema.definition, }); ``` ### Prompt caching The handler automatically applies Anthropic prompt caching to the static system prompt via `cacheControl: { type: "ephemeral" }`. This means repeated queries reuse the cached prompt, reducing latency and cost. This works out of the box with Anthropic models — no configuration needed. ### Using the library exports directly If you don't use the Vercel AI SDK, you can use the library exports to build a custom integration: ```ts import { generateAIPrompt, generateAIOutputSchema, parseAIResponse, } from "@/lib/ai"; // Generate the prompt and Zod schema from your table schema const prompt = generateAIPrompt(tableSchema.definition, { now: new Date() }); const schema = generateAIOutputSchema(tableSchema.definition); // Call your LLM with the prompt and schema const response = await yourLLM({ system: prompt, schema }); // Validate and apply the response const state = parseAIResponse(tableSchema.definition, response); if (state) { for (const [key, value] of Object.entries(state)) { table.getColumn(key)?.setFilterValue(value); } } ``` --- # Builder Source: https://data-table.openstatus.dev/docs/builder The [/builder](/builder) page lets you visually build a table schema: 1. **Paste JSON data** (or upload a CSV) in the left panel 2. Click **Generate Schema** — the schema is auto-inferred from your data 3. A **live table preview** appears with filters, sorting, and row details 4. Edit the **Schema JSON** directly and click **Apply** for instant updates 5. Click **Export TS** to copy the generated `createTableSchema(...)` code ## Type Inference The inference engine detects column types from your data: - ISO 8601 strings / Unix-ms numbers → `timestamp` + `timerange` filter - Booleans → `boolean` + `checkbox` filter - Numbers with varying values → `number` + `slider` filter - Strings with ≤10 distinct values → `enum` + `checkbox` filter - Arrays of strings → `array` with enum items - Plain objects → `record` (not filterable) Post-inference heuristics apply smart defaults (ID-like → code display, latency → "ms" unit, log level → `defaultOpen`, etc.). > The same inference engine powers the [zero-config DataTableAuto](/auto) component — pass raw data and get a fully rendered table with no schema definition needed. ## Example Datasets Example datasets (Pokemon, HTTP Logs, Space Missions, Cocktails, Orders, Issues, Movies, RPG Characters) are included to explore different configurations. ## Serialization & AI The schema serializes to a function-free JSON descriptor for AI agents, MCP tools, or remote storage. ```ts // Schema → JSON const json = tableSchema.toJSON(); // JSON.stringify(tableSchema) also works // JSON → Schema (e.g. from an AI agent) // Takes `unknown` — untrusted, hand-pasted, or older JSON is migrated and // validated on the way in, and throws with a message if it cannot be. const reconstructed = createTableSchema.fromJSON(json); // JSON → TypeScript code import { schemaToTypeScript } from "@/lib/table-schema/to-typescript"; const tsCode = schemaToTypeScript(json); ``` The descriptor carries a `version` field. `createTableSchema.fromJSON` runs `migrateSchemaJSON` first, so JSON written by an older build still loads. > Custom renderers (`display("custom", ...)`, `filter.component`, `sheet.component`, `sheet.condition`) are functions and cannot be serialized. They are stripped during `toJSON()` and must be applied manually on reconstructed schemas. The column's _other_ state is not affected: a column with a custom cell still serializes its real display type, so the reconstructed schema renders the built-in cell for that kind rather than falling back to plain text. --- # Full Example Source: https://data-table.openstatus.dev/docs/full-example This wires together everything from the previous sections. Each step links back to its guide page for details. ## 1. Define Your Table Use `createTableSchema` to declare every column — display, filters, sorting, and row details — in one place. See [Table Schema](/docs/table-schema) for the full API. ```tsx // table-schema.tsx import { col, createTableSchema, type InferTableType, } from "@/lib/table-schema"; export const tableSchema = createTableSchema({ level: col.presets.logLevel(["error", "warn", "info", "debug"]), date: col.presets.timestamp().label("Date").size(200).sheet(), latency: col.presets .duration("ms") .label("Latency") .sortable() .size(110) .sheet(), status: col.presets.httpStatus().label("Status").size(60), method: col.presets.httpMethod(["GET", "POST", "DELETE"]).size(69), host: col.string().label("Host").size(125).sheet(), }); export type ColumnSchema = InferTableType; ``` ## 2. Create Filter Schema Bridge the table schema to filter state with `generateFilterSchema`, then add non-column fields like sort and pagination. See [State Management](/docs/state-management) for details. ```tsx // schema.ts import { createSchema, field } from "@/lib/store/schema"; import { generateFilterSchema } from "@/lib/table-schema"; export const filterSchema = createSchema({ ...generateFilterSchema(tableSchema.definition).definition, sort: field.sort(), live: field.boolean().default(false), size: field.number().default(40), }); export type FilterState = typeof filterSchema._type; ``` ## 3. Set Up the API Route Create an API handler with `createDrizzleHandler` that implements three-pass filtering, faceted search, and cursor pagination. See [Drizzle ORM](/docs/drizzle-orm) and [Data Fetching](/docs/data-fetching) for the underlying concepts. ```tsx // app/api/route.ts import { createDrizzleHandler } from "@/lib/drizzle"; const handler = createDrizzleHandler({ db, table: logs, schema: tableSchema.definition, columnMapping, cursorColumn: "date", }); export async function GET(req: NextRequest) { const search = searchParamsCache.parse( Object.fromEntries(req.nextUrl.searchParams), ); const result = await handler.execute(search); return Response.json( SuperJSON.stringify({ data: result.data, meta: { totalRowCount: result.totalRowCount, filterRowCount: result.filterRowCount, facets: result.facets, }, prevCursor: result.prevCursor, nextCursor: result.nextCursor, }), ); } ``` ## 4. Render Wire up `useInfiniteQuery` with `DataTableInfinite` and populate filter fields dynamically from server-side facet data. See [Components](/docs/ui-components) for all available props. ```tsx // client.tsx import { DataTableStoreProvider, useFilterState } from "@/lib/store"; import { useNuqsAdapter } from "@/lib/store/adapters/nuqs"; import { generateColumns, generateFilterFields, generateSheetFields, } from "@/lib/table-schema"; import { useInfiniteQuery } from "@tanstack/react-query"; const columns = generateColumns(tableSchema.definition); const filterFields = generateFilterFields(tableSchema.definition); const sheetFields = generateSheetFields(tableSchema.definition); export function Client() { const adapter = useNuqsAdapter(filterSchema.definition, { id: "logs" }); return ( ); } function DataTableContent() { const search = useFilterState(); const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery( dataOptions(search), ); const flatData = React.useMemo( () => data?.pages?.flatMap((page) => page.data ?? []) ?? [], [data?.pages], ); const { sort, live, size, ...filter } = search; const defaultColumnFilters = Object.entries(filter) .map(([key, value]) => ({ id: key, value })) .filter( ({ value }) => value != null && !(Array.isArray(value) && !value.length), ); return ( } /> ); } ``` > The `commandSlot` above uses the AI-enhanced command palette. It handles both structured queries (`host:api`) and natural language (_"slow requests from eu"_) in the same input. See [AI Filters](/docs/ai-filters) for the full setup including the API route. ## Live Examples | Route | Pagination | Data Source | Live Mode | Chart | | ---------------------- | --------------- | ----------- | --------- | ----- | | [/auto](/auto) | Infinite scroll | In-memory | No | No | | [/default](/default) | Client-side | In-memory | No | No | | [/infinite](/infinite) | Infinite scroll | Mock API | Yes | Yes | | [/drizzle](/drizzle) | Infinite scroll | PostgreSQL | Yes | Yes | | [/light](/light) | Infinite scroll | Tinybird | Yes | Yes | --- # Debugging Source: https://data-table.openstatus.dev/docs/debugging ## Type errors right after install `shadcn add` finished, but `tsc` or `next build` fails inside the files it just wrote, with errors like these: ``` src/components/data-table/data-table-toolbar.tsx Type '{ children: Element[]; delayDuration: number; }' is not assignable to type 'IntrinsicAttributes & Props'. Property 'delayDuration' does not exist on type 'TooltipProviderProps'. src/components/data-table/data-table-sheet/data-table-sheet-details.tsx Property 'render' does not exist on type 'DialogCloseProps'. src/components/data-table/data-table-filter-controls.tsx Property 'type' does not exist on type 'AccordionProps'. src/components/data-table/data-table-infinite/hover-card-timestamp.tsx Property 'openDelay' does not exist on type 'HoverCardProps'. src/lib/table-schema/generators/columns.tsx Type 'boolean | "indeterminate"' is not assignable to type 'boolean | undefined'. ``` With npm, it can fail one step earlier — `shadcn add` itself exits during "Installing dependencies": ``` npm error ERESOLVE could not resolve npm error While resolving: @base-ui/react@1.8.0 npm error Found: date-fns@3.6.0 npm error Could not resolve dependency: npm error peerOptional date-fns@"^4.0.0" from @base-ui/react@1.8.0 ``` **Cause.** A copy of the blocks from before they supported Base UI, installed into a Base UI project. Since shadcn v4, `npx shadcn@latest init -d` defaults to the `base-nova` preset, which installs Base UI primitives (`@base-ui/react`) whose props differ from Radix — `delay` instead of `delayDuration`, `render` instead of `asChild`, no `type` on `Accordion`. The blocks ship no `ui/` files of their own, so the CLI resolves `tooltip`, `dialog` and the rest from whatever library your `components.json` names, and the older blocks assumed Radix. (The npm variant is the same mismatch one layer down: those blocks pinned `date-fns` v3, and `@base-ui/react` asks for v4 as a peer.) **Fix.** Re-add the blocks. The current ones install on either library: ```bash npx shadcn@latest add @data-table-filters/data-table @data-table-filters/data-table-schema --overwrite ``` `--overwrite` rewrites the block files — if you edited them, re-apply your changes afterwards. How the blocks manage it: the shadcn CLI rewrites `asChild` into `render` on the way into a Base UI project, the few props it doesn't translate are spread from `components/data-table/ui-compat.ts`, and `accordion` ships with the block rather than resolving from your `ui/` (as `sheet` and `sortable` already did), because the two libraries disagree there on more than a prop name. Both paths are installed and typechecked in CI on every change, so neither can rot. ## Performance We use `react-scan` for performance debugging: ``` # .env.local NEXT_PUBLIC_REACT_SCAN=true ``` TanStack Query devtools appear automatically in development. For table debug logs: ``` NEXT_PUBLIC_TABLE_DEBUG=true ``` Try the debugging tools on the [/infinite](/infinite) demo — it has all three enabled in development. --- # MCP Server Source: https://data-table.openstatus.dev/docs/mcp The `data-table-mcp` block exposes your data table as an MCP endpoint. AI agents can discover the table's schema via `tools/list` and query it with filters, cursor-based pagination, sorting, and faceted stats via a single `query_table` tool. Stateless. Serverless-compatible. Schema auto-generated from your existing field definitions. > Looking for the other direction? These docs are themselves an MCP server, so > an agent can ask how to build the table before it queries one — see > [For AI Agents](/docs/agents). ## Installation ```bash npx shadcn@latest add @data-table-filters/data-table-mcp ``` This installs `@/lib/mcp` and the `@modelcontextprotocol/sdk` dependency. ## Prerequisites - The **data-table** block must be installed (provides the BYOS schema system) - A data source — any async function that accepts filters and returns rows + total ## Route Setup Create a route handler co-located with your table's API. Export POST, GET, and DELETE for Streamable HTTP spec compliance. POST carries every request; GET is answered with 405, since the handler is stateless and has nothing to push on the standalone SSE stream. The recommended pattern derives the MCP schema from your existing `filterSchema`, excluding UI-only fields (`live`, `uuid`) via destructuring. This automatically includes all filter fields, sorting, and cursor-based pagination. ### In-Memory Example ```ts // app/infinite/api/mcp/route.ts import { createTableMCPHandler } from "@/lib/mcp"; import { filterData, getFacetsFromData, sortData, splitData, } from "@/app/infinite/api/helpers"; import { filterSchema } from "@/app/infinite/filter-schema"; import { mock, mockLive } from "@/app/infinite/api/mock"; import type { SchemaDefinition } from "@/lib/store/schema"; // Derive MCP schema from filterSchema, excluding UI-only fields const { live, uuid, ...mcpSchema } = filterSchema.definition satisfies SchemaDefinition; const handler = createTableMCPHandler({ name: "data-table-filters", description: "Query HTTP request logs with filters for level, method, host, pathname, latency, status, regions, date range, sort, and cursor-based pagination (cursor, size, direction)", schema: mcpSchema, getData: async ({ filters }) => { const totalData = [...mockLive, ...mock]; const filtered = filterData(totalData, filters); const sorted = sortData(filtered, filters.sort ?? null); const paginated = splitData(sorted, { cursor: filters.cursor ?? null, size: filters.size ?? 40, direction: filters.direction ?? "next", }); const facets = getFacetsFromData(filtered); const total = filtered.length; const rows = paginated.map((row) => ({ ...row, date: row.date.toISOString(), })); return { rows, total, facets }; }, }); export { handler as POST, handler as GET, handler as DELETE }; ``` ### Drizzle/Postgres Example ```ts // app/drizzle/api/mcp/route.ts import { createTableMCPHandler } from "@/lib/mcp"; import { db } from "@/db/drizzle"; import { logs } from "@/db/drizzle/schema"; import { createDrizzleHandler } from "@/lib/drizzle"; import { filterSchema } from "@/app/infinite/filter-schema"; import { columnMapping } from "../../column-mapping"; import { tableSchema } from "../../table-schema"; import type { SchemaDefinition } from "@/lib/store/schema"; const { live, uuid, ...mcpSchema } = filterSchema.definition satisfies SchemaDefinition; const drizzleHandler = createDrizzleHandler({ db, table: logs, schema: tableSchema.definition, columnMapping, cursorColumn: "date", defaultSize: 40, }); const handler = createTableMCPHandler({ name: "data-table-drizzle", description: "Query HTTP request logs (Drizzle/Postgres) with filters for level, method, host, pathname, latency, status, regions, date range, sort, and cursor-based pagination (cursor, size, direction)", schema: mcpSchema, getData: async ({ filters }) => { const result = await drizzleHandler.execute( filters as Record, ); type LogRow = typeof logs.$inferSelect; const rows = result.data.map((row) => { const r = row as LogRow; return { uuid: r.uuid, level: r.level, method: r.method, host: r.host, pathname: r.pathname, status: r.status, latency: r.latency, regions: r.regions, date: r.date.toISOString(), message: r.message ?? undefined, "timing.dns": r.timingDns, "timing.connection": r.timingConnection, "timing.tls": r.timingTls, "timing.ttfb": r.timingTtfb, "timing.transfer": r.timingTransfer, }; }); return { rows, total: result.filterRowCount, facets: result.facets }; }, }); export { handler as POST, handler as GET, handler as DELETE }; ``` ## Config | Option | Type | Default | Description | | ------------- | ------------------------ | -------------- | ---------------------------------------- | | `schema` | `SchemaDefinition` | — | BYOS field definitions for filter params | | `description` | `string` | — | Tool description (shown to agents) | | `getData` | `(opts) => Promise<...>` | — | Data source function | | `name` | `string` | `"data-table"` | MCP server name in `serverInfo` | ## The `query_table` Tool The handler registers a single MCP tool with these parameters: | Parameter | Type | Default | Description | | --------- | ------------------- | -------- | ------------------------------- | | `filters` | object (optional) | `{}` | Auto-generated from your schema | | `format` | `"json" \| "stats"` | `"json"` | Output format | ### Pagination & Sorting Pagination and sorting are part of the `filters` object, derived from your `filterSchema`: | Filter field | Type | Default | Description | | ------------ | ------------------ | -------- | -------------------- | | `cursor` | `number` (Unix ms) | now | Cursor timestamp | | `size` | `number` | `40` | Rows per page | | `direction` | `"prev" \| "next"` | `"next"` | Pagination direction | | `sort` | `{ id, desc }` | — | Sort descriptor | ### Output formats **`json`** — returns rows with total count: ```json { "rows": [...], "total": 1234 } ``` **`stats`** — returns total count and facet metadata (no rows): ```json { "total": 1234, "facets": { "level": { "rows": [{ "value": "error", "total": 42 }], "total": 1234 } } } ``` ## Schema Mapping Your BYOS field types are automatically converted to typed tool parameters: | Field type | Tool parameter type | | --------------------------------------- | ---------------------------- | | `field.string()` | `string` (optional) | | `field.number()` | `number` (optional) | | `field.boolean()` | `boolean` (optional) | | `field.timestamp()` | `number` (optional, Unix ms) | | `field.stringLiteral(["a", "b"])` | `enum ["a", "b"]` (optional) | | `field.array(field.number())` | `array of number` (optional) | | `field.array(field.stringLiteral(...))` | `array of enum` (optional) | Timestamp fields accept Unix milliseconds over the wire and are deserialized to `Date` objects before reaching your `getData` function. ## Connecting Clients ### Claude Code Add to `.mcp.json` in your project root: ```json { "mcpServers": { "data-table-infinite": { "type": "http", "url": "http://localhost:3000/infinite/api/mcp" }, "data-table-drizzle": { "type": "http", "url": "http://localhost:3000/drizzle/api/mcp" } } } ``` ### Verification (curl) ```bash # Initialize curl -X POST http://localhost:3000/infinite/api/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' # List tools (see auto-generated schema) curl -X POST http://localhost:3000/infinite/api/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' # Query with filters curl -X POST http://localhost:3000/infinite/api/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query_table","arguments":{"filters":{"level":["error"],"size":5},"format":"stats"}}}' ``` ## Use Cases ### Conversational data exploration Connect your MCP endpoint to Claude Desktop or Cursor. Ask questions in natural language — the agent translates them into `query_table` calls with the right filters, paginates through results, and summarizes findings. > "Show me all 500 errors from the HKG region in the last hour" ### Monitoring and alerting An autonomous agent polls `format: "stats"` on a schedule, watching facet distributions for anomalies. When the error rate spikes or latency percentiles drift, it triggers alerts via Slack, PagerDuty, or email — no dashboard required. ### Post-deploy validation After a deploy, a CI/CD agent queries the logs table for error spikes in a short window. If the error count exceeds a threshold compared to the pre-deploy baseline, it flags the release for rollback. ### Cross-table orchestration Multiple MCP endpoints (logs, orders, customers) each expose a `query_table` tool. An agent queries across all of them to answer cross-cutting questions like "which customers experienced 500 errors today?" — joining data that lives in separate tables or services. ### Internal tooling for non-developers Teams that don't have dashboard access or SQL knowledge can query production data through a chat interface. The MCP schema provides guardrails — agents can only filter on defined fields, preventing arbitrary queries. ### Agent-to-agent pipelines One agent's output feeds another's input. A triage agent identifies error patterns from the logs table, then passes those patterns to a root-cause agent that correlates with deploy metadata from a different MCP endpoint. ## How It Works 1. `createTableMCPHandler` converts your BYOS schema to a Zod schema at startup 2. The MCP SDK auto-converts Zod to JSON Schema for `tools/list` responses 3. On each request, a fresh `McpServer` + `WebStandardStreamableHTTPServerTransport` is created (stateless) 4. Tool calls are validated by the SDK via Zod, then timestamp values are deserialized (`number` → `Date`) 5. Your `getData` function is called with typed filters (including pagination and sort) 6. Results are formatted as JSON (`rows` + `total`) or stats (`total` + `facets`) ## Dependencies - `@modelcontextprotocol/sdk` — MCP protocol implementation - `zod` — Schema validation (already installed with the core block) --- # For AI Agents Source: https://data-table.openstatus.dev/docs/agents Most decisions about which data table to use now happen inside an agent's context window. These endpoints exist so an agent can read the library, pick the right blocks, and wire them up without guessing. ## Machine-readable endpoints | Endpoint | What it is | | ----------------------------------------------------------------------- | ---------------------------------------------------- | | `/api/mcp` | These docs as an MCP server — see below | | [`/llms.txt`](https://data-table.openstatus.dev/llms.txt) | Index: blocks, install recipes, links to every page | | [`/llms-full.txt`](https://data-table.openstatus.dev/llms-full.txt) | Every documentation page inlined in one file | | [`/r/index.md`](https://data-table.openstatus.dev/r/index.md) | Block catalog with install commands and dependencies | | [`/r/registry.json`](https://data-table.openstatus.dev/r/registry.json) | The shadcn registry manifest | | `/docs/.md` | Any docs page as raw markdown | Every docs page has a **Copy as markdown** button, and appending `.md` to any docs URL returns the source — [`/docs/drizzle-orm.md`](https://data-table.openstatus.dev/docs/drizzle-orm.md), for example. Agents fetching the rendered HTML pay several times the tokens and lose code-fence fidelity on the way through. The registry is `@data-table-filters` in the shadcn directory, so an agent with a shell can list and inspect the blocks with the CLI alone, no docs fetch and no `components.json` entry needed: ```bash npx shadcn@latest search @data-table-filters npx shadcn@latest view @data-table-filters/data-table ``` ## Ask the docs instead of reading them The endpoints above hand an agent pages. `/api/mcp` serves the same content as an MCP server, so the agent asks a question and gets back the section that answers it — a targeted section costs a fraction of the page it lives in, and the page costs a fraction of `llms-full.txt`. ```bash claude mcp add --transport http data-table-filters https://data-table.openstatus.dev/api/mcp ``` Any MCP client works — it speaks Streamable HTTP, needs no auth, and holds no session: ```json { "mcpServers": { "data-table-filters": { "type": "http", "url": "https://data-table.openstatus.dev/api/mcp" } } } ``` Four read-only tools: | Tool | Ask it | | ------------------ | ----------------------------------------------------------------- | | `get_install_plan` | "I need a table over 2M Postgres rows" → the exact shadcn command | | `search_docs` | "how do faceted counts work" → the sections that answer it | | `get_doc` | A page in full, or one section of it, as markdown | | `list_blocks` | The catalog: what each block adds and when it applies | Start with `get_install_plan` — it returns the command, the wiring notes, and the pages to read next, which is the whole decision an agent has to make before it writes any code. > This is the docs server. The [`data-table-mcp`](/docs/mcp) block is the other > direction: it turns _your_ table into an MCP endpoint so agents can query your > data. ## Agent rules and skills **Claude Code** — install the plugin, which ships the full skill with reference docs for every block: ```bash /plugin marketplace add openstatushq/data-table-filters /plugin install data-table-filters@openstatus ``` **Any agent with the `skills` CLI:** ```bash npx skills add https://github.com/openstatushq/data-table-filters --skill data-table-filters ``` **Cursor** — copy [`.cursor/rules/data-table-filters.mdc`](https://github.com/openstatushq/data-table-filters/blob/main/.cursor/rules/data-table-filters.mdc) into your project. **Everything else** — [`AGENTS.md`](https://github.com/openstatushq/data-table-filters/blob/main/AGENTS.md) in the repository root covers both using the library and contributing to it. Then describe what you want: > Add a filterable table for my `logs` Postgres table with server-side filtering > and infinite scroll ## Install recipes > **Prerequisite.** Works on either shadcn library: the CLI default, Base UI (`npx shadcn@latest init -d`), or Radix (`npx shadcn@latest init -b radix -p lyra`). CI installs into both and typechecks them on every registry change and nightly. ### Large table — 100k+ rows, filtered in SQL ```bash npx shadcn@latest add \ @data-table-filters/data-table \ @data-table-filters/data-table-schema \ @data-table-filters/data-table-cell \ @data-table-filters/data-table-sheet \ @data-table-filters/data-table-drizzle \ @data-table-filters/data-table-query \ @data-table-filters/data-table-nuqs ``` Define the table once with [`createTableSchema`](/docs/table-schema), pass it to [`createDrizzleHandler`](/docs/drizzle-orm) in a route handler and to [`createDataTableQueryOptions`](/docs/data-fetching) on the client. Filtering, faceted counts, sorting, and cursor pagination execute in SQL, and rows are virtualized — the client only ever holds the pages it rendered. ### Client-side table — data already in memory ```bash npx shadcn@latest add \ @data-table-filters/data-table \ @data-table-filters/data-table-cell \ @data-table-filters/data-table-sheet ``` Use `useMemoryAdapter`. No API route and no schema block required. ### Unknown data shape Install `data-table` and `data-table-schema`, then render `` — see [Auto-infer](/docs/quick-start#zero-config). ## Why the schema matters for agents One `createTableSchema` definition drives six surfaces: 1. Column definitions and cell renderers 2. Filter controls 3. The row detail sheet 4. The server-side query handler 5. The natural-language filter parser 6. The [MCP tool schema](/docs/mcp) An agent writing a table from scratch has to keep those six in sync by hand, which is where hand-rolled tables quietly go wrong. Here the schema is written once and everything else is derived from it. ## Expose your table to agents at runtime The docs above are for agents that _build_ the table. The [`data-table-mcp`](/docs/mcp) block is for agents that _query_ it: it turns the same schema into an MCP endpoint, so an agent can filter and page through your data with typed parameters. --- # Row Actions Source: https://data-table.openstatus.dev/docs/actions The `data-table-actions` block adds a write path to a server-side table: row menus and a bulk bar for selections, both rendered from metadata the list endpoint publishes. Actions are declared once, next to their Drizzle handler, and the UI never learns what a "replay" is. ## Installation ```bash npx shadcn@latest add @data-table-filters/data-table-actions ``` Requires the [Drizzle](/docs/drizzle-orm) block: `createActionHandler` shares its `filters` and `columnMapping`. ## The contract The list response gains two things: ```json { "data": [ { "uuid": "msg_1", "level": "error", "_actions": ["acknowledge", "delete"] } ], "meta": { "actions": [ { "id": "acknowledge", "label": "Acknowledge", "scope": ["row", "bulk", "filter"], "href": "/drizzle/api/actions/acknowledge" }, { "id": "delete", "label": "Delete", "scope": ["row", "bulk"], "variant": "destructive", "confirm": "Delete {count} {log|logs}?", "href": "/drizzle/api/actions/delete" } ] } } ``` A click is one request: ```http POST /drizzle/api/actions/acknowledge { "scope": "ids", "ids": ["msg_1", "msg_7"], "cmd_id": "cmd_…" } → { "applied": 2 } ``` or, for every row matching the current filters: ```http POST /drizzle/api/actions/acknowledge { "scope": "filter", "filter": { "level": ["error"] }, "expected_count": 40, "cmd_id": "cmd_…" } → { "applied": 40 } // or 409 { "error": "count_mismatch", "actual": 38 } ``` Errors are `{ "error": code }` with `unknown_action` (404), `scope_not_allowed` (400), `invalid_request` (400), `count_mismatch` (409, with `actual`), `forbidden` (403) and `failed` (500). Three rules make the contract hold: - **`_actions` is a hint; the WHERE is the authority.** The handler's `ctx.where` is the requested ids (or filter) intersected with the action's `when` guard. A stale client cannot make it touch a row the action does not apply to; `applied` simply comes back lower. - **Actions enqueue, they don't execute.** A replay sets `status = pending` and lets the worker do the work. That is why one short transaction is enough and why a handler does no I/O. - **At-least-once.** There is no ledger. `cmd_id` reaches the handler and the audit hook; if an action is not idempotent, that is where to dedupe. ## Server ### Declare the actions ```ts // app/drizzle/api/actions.ts import { createActionHandler } from "@/lib/drizzle/actions"; import { defineFilters } from "@/lib/filters"; export const actionHandler = createActionHandler({ db, table: logs, filters: defineFilters(tableSchema.definition), // same as createDrizzleHandler columnMapping, // same as createDrizzleHandler — with the id column added idColumn: "uuid", // a schema key in columnMapping basePath: "/drizzle/api/actions", // href = `${basePath}/${id}` actions: { acknowledge: { label: "Acknowledge", scope: ["row", "bulk", "filter"], // default: ["row", "bulk"] when: { level: ["error"] }, handler: async (ctx, tx) => { const rows = await tx .update(logs) .set({ level: "warning" }) .where(ctx.where) .returning({ uuid: logs.uuid }); return rows.length; }, }, delete: { label: "Delete", variant: "destructive", confirm: "Delete {count} {log|logs}?", handler: async (ctx, tx) => (await tx.delete(logs).where(ctx.where).returning()).length, }, }, audit: (event) => auditLog.insert(event), // after commit }); ``` `idColumn` is the row identity every request is keyed by. It is usually not filtered or sorted, so it may not be in the `columnMapping` you wrote for the list handler yet — add it (`uuid: logs.uuid`); the mapping doubles as the projection, so the rows the client gets carry it. Like `createDrizzleHandler`, construction throws if any filterable column is missing from the mapping — a `when` guard on an unmapped column would otherwise silently match every row. `when` is **filter values**, in exactly the shape the list endpoint reads from its search params. One declaration, two engines: `filters.matches` evaluates it per row to compute `_actions`, and the SQL engine compiles it into the handler's WHERE guard. Keys must be filterable columns — a typo throws at construction instead of silently matching every row, and when `filters` was built from `tableSchema.definition` it does not get that far: `when` is typed as `FilterValues`, so an unknown key, a `.notFilterable()` column, or a value the column cannot filter on (`{ level: ["fatal"] }` against `col.enum(LEVELS)`) is a compile error. `defineFilters(schemaJson)` and `defineFilters(specs)` know nothing about the columns at compile time, so there `when` stays untyped and only the runtime check applies. For availability the filter semantics cannot express, `available: (row) => boolean` is a JS-only escape hatch; it shapes `_actions` but has no SQL counterpart, so the handler must guard itself. The handler runs inside `db.transaction` and **returns the applied row count** (`.returning()` makes that driver-agnostic). Throwing rolls everything back. What the client sees is built by an explicit pick-list — `id`, `label`, `scope`, `variant`, `confirm`, `href` — never by spreading the definition, so a handler, a `when` clause, or a stray secret cannot leak into `meta.actions`. ### Advertise them from the list route ```ts // app/drizzle/api/route.ts const result = await handler.execute(search); const data = actionHandler.annotate(result.data); // adds `_actions` per row return Response.json( SuperJSON.stringify({ data, meta: { totalRowCount: result.totalRowCount, filterRowCount: result.filterRowCount, chartData, facets: result.facets, actions: actionHandler.descriptors, }, prevCursor: result.prevCursor, nextCursor: result.nextCursor, }), ); ``` ### Accept commands ```ts // app/drizzle/api/actions/[id]/route.ts import { ActionHandlerError } from "@/lib/drizzle/actions"; export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; const session = await auth(); // your auth — the actor is never read from the body try { const result = await actionHandler.execute(id, await req.json(), { actor: session.email, }); return Response.json(result); } catch (error) { if (error instanceof ActionHandlerError) { return Response.json(error.toJSON(), { status: error.status }); } return Response.json({ error: "failed" }, { status: 500 }); } } ``` Per-action authorization belongs here too: check the session's role before calling `execute`. Hiding a button is not authorization. ## Client ```tsx // client.tsx import { createActionsColumn, DataTableActionsBar, DataTableActionsProvider, } from "@/components/data-table/data-table-actions"; import { DataTableFloatingBar } from "@/components/data-table/data-table-floating-bar"; const baseColumns = generateColumns(tableSchema.definition); // The actions column ships hidden by default (`columnVisibility: { actions: // false }`) and is enabled from the view options. Append it only once the // server advertises actions. Module constants keep the table's options stable. const columnsWithActions = [ ...baseColumns, createActionsColumn(), ]; function ClientInner() { const { data } = useInfiniteQuery(dataOptions(search)); const actions = data?.pages.at(-1)?.meta.actions; const columns = actions?.length ? columnsWithActions : baseColumns; return ( actions={actions} getRowId={(row) => row.uuid} getRowLabel={(row) => row.pathname} // names the row for screen readers queryKeyPrefix="drizzle" // invalidated after every applied action > > {({ rows }) => } } // … /> ); } ``` Two surfaces, both reading the same provider: | Component | Scope | Where | Sends | | ----------------------- | ------ | ----------------------------- | ---------------------------------- | | `createActionsColumn()` | `row` | a `⋯` menu cell, per row | `{ scope: "ids", ids: [rowId] }` | | `DataTableActionsBar` | `bulk` | inside `DataTableFloatingBar` | only the **eligible** selected ids | Outcomes are reported with [sonner](https://sonner.emilkowal.ski) toasts, so mount a `` once in your layout (`npx shadcn@latest add sonner`) — without it a failed or partial action gives no visible feedback. A bulk button stays enabled while _any_ selected row qualifies; the request carries only the eligible ids and the confirmation says how many were skipped. The handler's `maxIds` (default 1000) is published on every bulk descriptor, and a selection past it disables the button with the limit as its tooltip rather than earning an `invalid_request` — past that many rows, reach for the filter scope below. `confirm` on a descriptor opens an alert dialog with `{count}` interpolated and `{one|other}` picking a form by it (`"Delete {count} {log|logs}?"`), and stays open with its buttons disabled until the request settles; `variant: "destructive"` styles both the button and the dialog's action. After a success the provider toasts the result, clears the selection, and invalidates `[queryKeyPrefix]`, so rows that no longer match leave the view. The filter scope has no shipped surface. It is part of the wire contract and the handler implements it, but a trigger that promises "all 40 matching" is only honest when the host can name that number, so the block leaves it to you: call `trigger` from `useDataTableActions` with `{ scope: "filter", filter, expected_count }`, taking `expected_count` from the server's own `filterRowCount` and only while the table is not fetching — a stale count describes the previous query. The server counts and mutates under `REPEATABLE READ`, so the set it counted is the set it applies to, and answers 409 `count_mismatch` with the actual number if it drifted; the provider reopens the confirmation with that number and offers to apply anyway, which resends without `expected_count`. It then applies within the action's `when` guard, so "applied to 12 of 40 matching" is the guard at work, not a partial failure. Ids are validated as non-empty strings by default. When the id column is stricter, say so with `idSchema` — `z.uuid()` for a `uuid` column — so a malformed id is a 400 `invalid_request`, not a cast error surfacing as a 500. Multi-row selection needs `col.select()` in the schema, as for any floating bar. ### Descriptor validation Descriptors are server-authored and choose the URL the provider POSTs row ids to, so they are validated once on arrival and anything that fails is dropped rather than rendered — a button that cannot be sent safely is worse than no button. Rejections are reported to the console, not the UI. An `href` is accepted when it is a root-relative path (`/api/actions/replay`), or an absolute `http(s)` URL on the page's own origin. Everything else is rejected: protocol-relative URLs that read like a path (`//evil.example.com/steal`), `javascript:`, `data:`, `blob:`, directory-relative paths, control characters, and anything over 2048 chars. The rest of the descriptor is bounded too — `id`, `label`, `scope`, `variant`, `confirm` and `maxIds` are all range- and type-checked, and a duplicate `id` is dropped. When the actions endpoint lives on another origin, name it: ```tsx actions={actions} getRowId={(row) => row.uuid} allowedActionOrigins={["https://api.example.com"]} > ``` This matters most when the table renders a schema from a server you do not control — see [Headless Tables](/docs/headless#trust-boundary). ## Demo The [/drizzle](/drizzle) demo declares `acknowledge` (error → warning) and `delete`. In development they are on by default against your own database. The public site runs against a shared database, so there writes are opt-in: set `ALLOW_DEMO_ACTIONS=1` and the list endpoint starts advertising actions; otherwise `POST /drizzle/api/actions/*` answers 403 and the UI renders no buttons (`ALLOW_DEMO_ACTIONS=0` forces that locally too). --- # Headless Tables Source: https://data-table.openstatus.dev/docs/headless The `data-table-remote` block renders a table from an API endpoint. You publish a **manifest** describing your data; the table derives columns, filters, sheet fields, row identity and URL state from it. You keep full control of the data — the only thing you hand over is the shape of it. This is the same engine as every other page in these docs. The difference is where the schema lives: in a TypeScript file you import, or in a response from an endpoint you own. ## Installation ```bash npx shadcn@latest add \ @data-table-filters/data-table \ @data-table-filters/data-table-schema \ @data-table-filters/data-table-query \ @data-table-filters/data-table-nuqs \ @data-table-filters/data-table-remote ``` ## The manifest A manifest is what an endpoint says about itself. It is JSON, so it crosses the network intact: ```ts type TableManifest = { version: number; schema: SchemaJSON; /** The column that identifies a row on the wire. */ primaryKey: string; /** A template over column keys: "{method} {pathname}". */ rowLabel?: string; capabilities: TableCapabilities; chart?: TableChartConfig; actions?: ActionDescriptor[]; defaults?: { sort?; size?; columnVisibility? }; }; ``` `primaryKey` and `rowLabel` replace the closures every client used to write by hand (`getRowId={(row) => row.uuid}`) — exactly the kind of thing a table pointed at an endpoint cannot supply for itself. ### Serving it `createTableManifest` builds one from a schema you already have; `createTableManifestHandler` serves it with ETag revalidation. ```ts // app/logs/api/schema/route.ts import { createTableManifest, createTableManifestHandler, } from "@/lib/table-schema"; import { tableSchema } from "../table-schema"; const handler = createTableManifestHandler( createTableManifest({ schema: tableSchema, primaryKey: "uuid", rowLabel: "{method} {pathname}", capabilities: { facets: true, totalRowCount: true, filterRowCount: true, chart: true, backwardPagination: true, }, defaults: { sort: { id: "date", desc: true }, size: 40 }, }), ); export async function GET(request: Request) { return handler(request); } ``` Pass a **function** instead of a value when the manifest depends on the request — per-tenant columns, or actions that depend on the caller's permissions: ```ts const handler = createTableManifestHandler((request) => createTableManifest({ schema: tableSchema, primaryKey: "uuid", capabilities: { facets: true, actions: canWrite(request) }, ...(canWrite(request) ? { actions: actionHandler.descriptors } : {}), }), ); ``` `createTableManifest` throws if `primaryKey` is not a column in the schema. Better to fail on the server than to ship a manifest whose rows the client cannot identify. ## Rendering it ```tsx "use client"; import { DataTableRemote } from "@/components/data-table/data-table-remote"; import { searchParamsSerializer } from "./search-params"; export function Client() { return ( ); } ``` The data endpoint defaults to the manifest URL with a trailing `/schema` stripped, so `/logs/api/schema` pairs with `/logs/api`. Set `dataEndpoint` explicitly when they do not line up. ### Why it is two components The URL-state adapter's parsers are derived from the schema, and `useNuqsAdapter(schema, …)` needs one at first render. A hook cannot be called conditionally, so the manifest has to resolve one level **above** everything built from it. `DataTableRemote` resolves it; the inner table is mounted only once it has, and therefore has a stable hook order for its whole life. The inner table is keyed on the column set, so a schema change resets table state. That is deliberate: sorting, visibility and filters are keyed by column, and carrying them across a schema swap resurrects state for columns that no longer exist. A capability or action change does **not** remount — it should not throw away the user's sorting. ## Capabilities The client used to assume the server did everything. An endpoint that cannot group facets or count matching rows had no way to say so, and the UI had no way to degrade — it rendered empty filters and a blank count. | Flag | When false | | -------------------- | --------------------------------------------------------------- | | `facets` | The table facets the rows it has loaded, client-side | | `totalRowCount` | The total is left blank, not shown as `0` | | `filterRowCount` | The filtered count is left blank | | `chart` | The chart slot is not rendered at all | | `backwardPagination` | The live button is not rendered, rather than rendered and inert | | `actions` | No action column, no bulk bar | Every capability defaults to **off**. A table that renders no chart against a server that has one is a missing feature; a table that renders a chart against a server that has none is a broken screen. Use `facetedColumns` when the server can only facet some columns: ```ts capabilities: { facets: true, facetedColumns: ["level", "status", "latency"], } ``` ## Transport Everything about _how_ the table talks to an endpoint is one option — base URL, headers, credentials, and how the body is parsed. See [Data Fetching](/docs/data-fetching#transport) for the full surface. ```tsx ({ authorization: `Bearer ${await getToken()}` }), credentials: "include", // The endpoint returns ordinary JSON rather than SuperJSON. parseResponse: jsonParser(), }} pagination={offsetPagination({ size: 50 })} /> ``` To revive timestamp columns from ISO strings you need the schema, which means the manifest — so reach for `schemaJsonParser` where you already have one. With a snapshot that is straightforward: ```tsx import { manifest } from "./manifest"; // a build-time snapshot ; ``` A cross-origin endpoint needs CORS, and its action `href`s need to be allow-listed — see [Trust boundary](#trust-boundary). ## Avoiding the round trip Fetching the manifest before the first row request costs a round trip. Two ways out, both through `initialManifest`. ### Prefetch on the server ```tsx // page.tsx import { fetchTableManifest } from "@/lib/table-schema"; export default async function Page() { const manifest = await fetchTableManifest( "http://localhost:3000/logs/api/schema", ); return ; } ``` Or seed the React Query cache under the exported `tableManifestKey(endpoint)` and let the hook read it. ### Snapshot at build time Check the endpoint's answer into the repo: ```ts // scripts/pull-manifest.ts import { writeFileSync } from "node:fs"; import { pullManifestModule } from "@/lib/table-schema"; writeFileSync( "src/app/logs/manifest.ts", await pullManifestModule("https://api.example.com/logs/schema"), ); ``` That writes a typed module with a header recording where and when it came from. Pass it as `initialManifest` and the first paint has a schema; the query still revalidates behind it, so an endpoint whose schema has moved on corrects itself. Regenerate the file when that happens, or every first paint starts from stale columns. `manifestToModule` validates on the way in, so a snapshot cannot freeze an invalid manifest into your repo. ## Custom renderers A schema carries **named** displays — `badge`, `bar`, `heatmap`, `status-code`, `timestamp` and the rest — and those travel as data. Renderer closures do not. `applyRenderers` is the escape hatch: supply the handful of columns you want to draw yourself, by key, and every other column stays declarative. ```tsx }, timing: { sheetComponent: (row) => }, }} /> ``` The **descriptor is untouched**, so a column with a custom cell still declares `bar` on the wire and the schema still round-trips — another consumer of the same endpoint renders something sane rather than nothing. An override naming a column that is not in the schema warns instead of throwing, because a remote schema can drop a column between deploys and a stale override should not blank the table. ## Slots Anything a manifest cannot describe is a slot: ```tsx } errorSlot={(error) => } chartSlot={(data, config) => config ? ( ) : null } sheetSlot={(fields) => } getRowClassName={(row) => getLevelRowClassName(row.original.level)} /> ``` Row styling is a slot on purpose. It is presentation policy that varies per app against the same data, and encoding conditional CSS in a JSON manifest would mean inventing a rules language for one caller. The manifest says what a row _is_; the app decides what it looks like. ## Trust boundary A remote manifest is untrusted input that drives both rendering and network calls. It is validated once, on the way in: - **Bounds** — at most 200 columns and 50 actions, with key and label length caps. Columns that are unusable are dropped with a warning rather than blanking the table; the parser throws only when nothing is renderable. - **Action hrefs** — a root-relative path is always allowed; an absolute URL only on the page's own origin or one you allow-list. Protocol-relative (`//evil.example.com`), `javascript:`, `data:`, `blob:` and directory-relative paths are rejected, and a descriptor that fails is never rendered — a button that cannot be sent safely is worse than no button. ```tsx row.uuid} allowedActionOrigins={["https://api.example.com"]} > ``` ## Proving an endpoint conforms `runEndpointConformance` probes a live endpoint against the contract: ```ts import { formatConformanceReport, runEndpointConformance, } from "@/lib/data-table"; const report = await runEndpointConformance({ url: "https://api.example.com/logs", manifest, }); console.log(formatConformanceReport(report)); if (!report.ok) process.exit(1); ``` It checks that the endpoint answers, that the shape matches, that `size` is honoured, that every row carries the `primaryKey` and those keys are unique, that it computes the facets it claims, that a second page differs from the first — the check that catches an endpoint silently ignoring `cursor`, which is an infinite scroll that never advances — that `direction=prev` works when declared, and that an unknown query parameter is ignored. It does not check filter _semantics_; that needs known data and belongs to the filter corpus. See [Data Layer](/docs/data-layer) for the request and response contract itself. ## Demo The [/drizzle](/drizzle) route serves a real manifest at `/drizzle/api/schema`, describing its own capabilities, chart and actions. --- # Upgrading from shadcn/ui Table Source: https://data-table.openstatus.dev/docs/migrating-from-shadcn The official [shadcn/ui Data Table](https://ui.shadcn.com/docs/components/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: 1. **Single-field text search instead of faceted filters.** The tutorial provides an `` that filters one column. Real applications need multi-select checkbox lists with item counts (`Error (42)`, `Warning (8)`), numeric sliders, and date range pickers. 2. **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. 3. **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. 4. **Repetitive boilerplate.** Every column requires 30–50 lines of manual `ColumnDef` code 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[]` boilerplate | Declarative `createTableSchema` + `col.*` | | **Filtering** | Single-column text `` | 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: ```bash npx shadcn@latest add \ @data-table-filters/data-table \ @data-table-filters/data-table-schema \ @data-table-filters/data-table-nuqs ``` > **Prerequisite.** Works on either shadcn library: the CLI default, Base UI (`npx shadcn@latest init -d`), or Radix (`npx shadcn@latest init -b radix -p lyra`). The CLI resolves primitives from whichever base your `components.json` specifies. 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`) ```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[] = [ { accessorKey: "status", header: "Status", }, { accessorKey: "email", header: ({ column }) => ( ), }, { accessorKey: "amount", header: () =>
Amount
, cell: ({ row }) => { const amount = parseFloat(row.getValue("amount")); return
${amount.toFixed(2)}
; }, }, { accessorKey: "createdAt", header: "Created At", }, ]; ``` > **Tip on timestamp fields:** The basic shadcn tutorial data only defines `id`, `amount`, `status`, and `email`. Adding a timestamp field such as `createdAt: Date` to 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: ```tsx // 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) => ${Number(val).toFixed(2)}, }), 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: ```tsx // Standard shadcn: local state lost on refresh const [columnFilters, setColumnFilters] = React.useState( [], ); const [sorting, setSorting] = React.useState([]); ``` 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`): ```tsx // 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 ( ); } ``` > **Setup Reminder:** As with any `nuqs` usage in Next.js App Router, ensure `` wraps your root layout (`app/layout.tsx`) and the client component is wrapped in a `` 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: 1. `filterFields` passed to `` 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 2. **Add a ⌘K Command Palette:** Install the palette block: ```bash npx shadcn@latest add @data-table-filters/data-table-filter-command ``` Pass it into `commandSlot`: ```tsx import { DataTableFilterCommand } from "@/components/data-table/data-table-filter-command"; } />; ``` --- ## 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: ```bash npx shadcn@latest add @data-table-filters/data-table-drizzle ``` Pass the exact same `paymentSchema.definition` to `createDrizzleHandler` in your Next.js Route Handler: ```ts // 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: 1. **Day 1:** Install `data-table` and `data-table-schema`. Replace your manual `ColumnDef[]` with `createTableSchema` and render ``. Start with in-memory state via `useMemoryAdapter`. 2. **Day 2:** Switch `useMemoryAdapter` to `useNuqsAdapter` to enable shareable URL filter links. 3. **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` 4. **When Scaling:** Add `data-table-drizzle` to push filtering and facets into SQL. --- ## Next Steps - Explore all column types and presets in [Table Schema](/docs/table-schema) - Learn about URL state options in [State Management](/docs/state-management) - Check out full server-side walkthroughs in [Drizzle ORM](/docs/drizzle-orm) - Try the interactive [Table Builder](/builder) to experiment with schema options