# Architecture (/docs/architecture) Better Tables keeps database concerns, table contracts, and React rendering in separate packages so you can swap adapters and own your UI source. ## Package map [#package-map] | Package | Role | | --------------------------------- | ------------------------------------------------------------------------------------------------- | | `@better-tables/core` | Column builders, filter/sort/pagination managers, adapter contract types. No React, no DB driver. | | `@better-tables/adapters-drizzle` | Schema introspection, JOIN/filter/pagination query generation for Postgres/MySQL/SQLite. | | `@better-tables/adapters-toolkit` | ORM-agnostic machinery for adapter authors. | | `@better-tables/ui` | React table/filter components (shadcn-style). Distributed via CLI copy, not npm. | | `@better-tables/cli` | `better-tables init` copies UI into a consumer project. | ## Data flow [#data-flow] ``` defineTable() columns ↓ betterTables({ database: adapter }) ↓ adapter.fetchData / getFacetedValues / … ↓ @better-tables/ui (copied) renders + manages client state ↓ optional URL sync (filters, sort, page) ``` ## Flagship API surface [#flagship-api-surface] Prefer these entry points everywhere: * `betterTables({ database, defaults? })` — factory for `fetchData`, `define`, cell-edit actions * `defineTable()('name', (t) => ({ columns }))` — RSC-safe table definitions * `t.text` / `t.number` / `t.date` / `t.option` / … — path builders with fluent modifiers * `tables.fetchData(table, params)` — server/query boundary For tables without a database — playgrounds, tests, client-only arrays — `memoryAdapter(rows)` runs the same pipeline in memory, and `defineTableRow()` defines a table from hand-built columns. See [Custom adapters](/docs/adapters/custom) and [Columns](/docs/columns#standalone-columns-no-schema). ## Plugins (data hooks) [#plugins-data-hooks] `betterTables({ plugins: [...] })` takes **plugins** — instance-level data hooks that wrap the query path. Each plugin can implement `beforeFetch` (rewrite the params before the adapter sees them) and/or `afterFetch` (transform the result). They run in array order around `tables.fetchData`; a throwing hook fails the fetch (nothing is swallowed), and the no-plugin path is unaffected. ```ts import { betterTables, logPlugin } from '@better-tables/core'; const tables = betterTables({ database: adapter, plugins: [ logPlugin({ onFetch: ({ table, rowCount, total }) => track(table, rowCount, total) }), { name: 'only-active', // AND `activeFilter` onto whatever filters are present — preserving an // existing group (a bare `[]` fallback would silently drop an OR tree). beforeFetch: ({ params }) => ({ ...params, filters: { kind: 'group', logic: 'and', children: [ ...(Array.isArray(params.filters) ? params.filters : params.filters ? [params.filters] : []), activeFilter, ], }, }), }, ], }); ``` **Plugins vs. modules.** Plugins are npm-distributed **data** hooks on `betterTables({ plugins })` (this section). **Modules** are copied **UI** source added with `better-tables add` — see [UI & CLI](/docs/ui-and-cli#modules). Different tiers, kept distinct. ## Authoritative demos [#authoritative-demos] Shipped examples live under `apps/marketing` — especially `src/lib/demo/support/` and the routes under [/examples](/examples). Those use the same 0.6 API this handbook teaches. # BetterTable Props (/docs/better-table) `` is copied into your app by `better-tables init` (see [UI & CLI](/docs/ui-and-cli)) — import it from your local path: ```tsx import { BetterTable } from '@/components/better-tables-ui/table/table'; ``` Pass `data` plus either a `table` definition (supplies identity and columns) or both `id` and `columns`. `id` alone is not enough — the component throws without columns. ## Identity & columns [#identity--columns] | Prop | Type | Notes | | --------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `string` | Unique table instance id (keys the state store and URL params). Defaults to `table.tableName` when `table` is given | | `name` | `string` | Human-readable display name. Defaults to `table.tableName` | | `columns` | `ColumnDefinition[]` | Column definitions. Defaults to `table.columns`. Server aggregates: [`t.count` / `t.aggregate`](/docs/columns#derived-columns-server-aggregates) | | `table` | `TableDefinition` | A `defineTable()` / `tables.define()` definition — sugar for `columns`/`id`/`name` (explicit props still win). Enables [auto columns](/docs/columns/auto-columns) resolution when paired with `adapter` | ## Data & fetching [#data--fetching] | Prop | Type | Notes | | -------------- | -------------------- | -------------------------------------------------------------------------------------------------- | | `data` | `TData[]` (required) | The rows to render | | `adapter` | `TableAdapter` | Optional — powers lazy filter options, facets, auto-column resolution, and adapter cell-edit saves | | `totalCount` | `number` | Filtered total for pagination — always pass `result.total` with server pagination | | `loading` | `boolean` | Render the loading state | | `error` | `Error \| null` | Render the error state | | `onRetry` | `() => void` | Retry button handler for the error state | | `emptyMessage` | `string` | Empty-state text (wins over `emptyState.description`) | ## Initial state [#initial-state] | Prop | Type | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `initialFilters` | `FilterState[] \| FilterGroupNode` — a `FilterGroupNode` tree seeds the initial query, but the filter bar edits flat: the first user edit replaces the tree with a flat implicit-AND array (tree editing is not shipped) | | `initialSorting` | `SortingState` | | `initialPagination` | `PaginationState` | | `initialSelectedRows` | `Set` | | `initialColumnVisibility` | `ColumnVisibility` | | `defaultVisibleColumns` | `string[]` — ids visible by default; others start hidden | ## Change callbacks (controlled usage) [#change-callbacks-controlled-usage] | Prop | Fires when | | -------------------------------- | ------------------------- | | `onFiltersChange(filters)` | Filter state changes | | `onSortingChange(sorting)` | Sort state changes | | `onPaginationChange(pagination)` | Page or page size changes | | `onPageChange(page)` | Page changes | | `onPageSizeChange(pageSize)` | Page size changes | | `onSelectionChange(selected)` | Row selection changes | | `onRowClick(row)` | A row is clicked | Typical server-driven wiring: react to the callbacks, refetch via `tables.fetchData`, pass fresh `data` + `totalCount` back in. ## Filtering behavior [#filtering-behavior] | Prop | Notes | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `autoShowFilteredColumns` | Auto-reveal a column while it has an active filter (default `true`). Removing the filter re-hides the column only when `defaultVisibleColumns` is provided (and the column isn't in it); without that prop, revealed columns stay visible | | `isFilterProtected(filter)` | Return `true` to lock a filter — shown with a lock icon, not removable | | `groups` | Manual filter-dropdown grouping (`FilterGroup[]`) | | `autoGroupFilters` | Automatic grouping config — see [Filtering](/docs/filtering#filter-bar-grouping) | ## Editing [#editing] | Prop | Notes | | ----------------- | ------------------------------------------------------------ | | `editing` | `false` disables all inline editing | | `saveAction` | Serializable server action from `tables.cellEditAction(def)` | | `onCellEdit` | Full-control save callback (wins over every other save path) | | `onCellEditError` | Called when a save fails (after rollback) | See [Inline editing](/docs/inline-editing) for the save-resolution order. ## URL sync [#url-sync] | Prop | Notes | | --------- | -------------------------------------------------------------------------------------------------------------------------------- | | `urlSync` | `{ adapter: UrlSyncAdapter, config?: UrlSyncConfig }` — the component runs the sync internally; see [URL State](/docs/url-state) | ## Rendering & layout [#rendering--layout] | Prop | Notes | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `virtualized` | `true` or `{ height?, rowHeight?, overscan? }` — see [Large datasets](/docs/large-datasets) | | `className` | Root class name | | `rowConfig` | `getId(row)` (row identity — required for selection/editing when rows have no `id` field) and `onClick(row)` are wired; the remaining `RowConfig` fields (`isSelectable`, `isExpandable`, `expandedContent`, `className`, `style`) are contract-only today — the copied `table.tsx` doesn't read them yet | | `emptyState` | `{ description?, icon? }` empty-state config (`emptyMessage` wins for text) | | `errorState` | Error-state config | | `actions` | Bulk actions on selected rows — see [Selection & actions](/docs/selection-and-actions) | ## Feature flags [#feature-flags] `features` toggles whole subsystems: | Flag | Default | Effect | | ------------------- | ------- | ------------------------------------------------------------------------------------------ | | `filtering` | `true` | Filter bar + filter dropdowns | | `sorting` | `true` | Header sort controls | | `pagination` | `true` | Pagination footer | | `columnVisibility` | `true` | Column show/hide menu | | `rowSelection` | `false` | Checkbox column + selection state — see [Selection & actions](/docs/selection-and-actions) | | `columnReordering` | `false` | Drag-and-drop header reordering | | `headerContextMenu` | — | Right-click header menu | ```tsx ``` Turning `pagination` off renders every row — pair with `virtualized` for big datasets. Those are the only feature flags. Bulk-action controls appear for selected rows when `actions` is non-empty and at least one action is visible (`isVisible` can hide them) — there's no `bulkActions` flag — and virtualization is the `virtualized` prop, not a feature flag. Initial filters use the `initialFilters` prop and the loading state (skeleton rows) uses the `loading` boolean prop. # Export (/docs/export) Export downloads the table's **current view** — the same filters and sorting the user sees — as CSV or JSON. It comes in two pieces: a UI module (the button) and, for programmatic use, a core plugin. ## The export button (UI module) [#the-export-button-ui-module] The `ExportButton` is an opt-in [module](/docs/ui-and-cli#modules). Add it, then wire it into the `toolbarExtra` slot: ```bash bunx @better-tables/cli add export ``` ```tsx import { ExportButton } from '@/components/better-tables-ui/table/export-button'; ``` The button renders only when the adapter can export (`adapter.meta.features.export`), and offers CSV and JSON. It exports the current filters and sorting, so what downloads matches what's on screen. ## `useTableExport` [#usetableexport] The button is a thin wrapper over `useTableExport`, which you can use directly to build your own control: ```tsx const { exportData, exporting, error, canExport } = useTableExport({ adapter, columns, filters, sorting, }); // exportData('csv') fetches the current view and triggers a browser download. ``` ## Row cap [#row-cap] An "export all" on a large table could pull an unbounded result set into memory, so `exportData` bounds the fetch at **50,000 rows** (`DEFAULT_EXPORT_ROW_CAP`) by default. Raise it per-call when you need a bigger (costlier) export: ```tsx }} /> ``` ## Adapter support [#adapter-support] Export rides the adapter's optional `exportData(params)`: * **Drizzle** — runs a bounded, filtered/sorted query server-side and formats CSV/JSON (Excel is not implemented yet). * **HTTP** — reuses the fetch proxy to pull the filtered rows, then formats CSV/JSON in the browser. No extra route: it advertises `features.export` and works wherever reads already work. Adapters that don't implement `exportData` leave `canExport` false, and the button renders nothing. ## `csvExport()` plugin [#csvexport-plugin] For programmatic export, `csvExport()` is a core-tier [plugin](/docs/architecture#plugins-data-hooks). It observes each fetch (via the `afterFetch` hook) and formats the most recent result: ```ts import { betterTables, csvExport } from '@better-tables/core'; const csv = csvExport(); const tables = betterTables({ database: adapter, plugins: [csv] }); await tables.fetchData(usersTable, { pagination: { page: 1, limit: 20 } }); csv.toCsv(); // CSV of that page csv.toJson(); // JSON of that page ``` It formats the most recent fetched page. For a full filtered export, use the button / `exportData` above. # Facets (/docs/facets) Facets show live counts next to filter options ("Open (34)") and min/max ranges for numeric columns, computed by the adapter with the current filters applied. Self-exclusion is built in: a column's own filter never narrows its own facet, so picking "Open" doesn't collapse the status facet to one entry. ## Client wiring [#client-wiring] `useFacets` fetches counts and ranges; `FacetedFilterSidebar` renders them (both copied by `init`): ```tsx 'use client'; import { FacetedFilterSidebar } from '@/components/better-tables-ui/filters/faceted-filter-sidebar'; import { useFacets } from '@/hooks/use-facets'; export function TicketsWithFacets({ adapter, columns }) { const [filters, setFilters] = useState([]); const { facets, ranges, loading, error, refetch } = useFacets({ adapter, columnIds: ['status', 'priority'], // checkbox facets via adapter.getFacetedValues rangeColumnIds: ['reopenCount'], // [min, max] via adapter.getMinMaxValues filters, // pass the FULL filter state, unmodified }); return (
{/* table filtered by the same `filters` state */}
); } ``` `UseFacetsOptions`: `adapter`, `columnIds`, `rangeColumnIds`, `filters`, `enabled`. Results: `facets` (`columnId → [{ value, count }]`), `ranges` (`columnId → [min, max]`), `loading`, `error`, `refetch()`. Array literals for `columnIds` are fine on every render — the hook interns them with `JSON.stringify` so IDs that contain spaces cannot collide. Do **not** pre-filter the `filters` you pass per column — self-exclusion is the adapter's job, and pre-filtering fights it. ## Adapter contract [#adapter-contract] Facets ride on two adapter methods, both implemented by the Drizzle adapter and proxied by the HTTP adapter: ```ts adapter.getFacetedValues(columnId, { filters, limit, signal }); // Map adapter.getMinMaxValues(columnId, { filters, signal }); // [min, max] ``` `FacetQueryParams`: `filters` (flat array or `FilterGroupNode` tree), `signal` (abort), and `limit` — cap on distinct values, ordered by count descending (default `100`; `null` disables the cap). `fetchData` can also return counts inline via its `faceted` result field (`Record>`) — over HTTP, Maps travel as `[value, count][]` and are rebuilt client-side (see [HTTP adapter](/docs/adapters/http)). ## Live example [#live-example] Source: `apps/marketing/src/components/sections/facets-sidebar.tsx`. # Filtering (/docs/filtering) Filters are typed `FilterState` objects (or nested `FilterGroupNode` trees for AND/OR). The UI reads column metadata from `defineTable()`; the adapter translates filters to SQL. ```ts import type { FilterState } from '@better-tables/core'; const filters: FilterState[] = [ { columnId: 'status', type: 'option', operator: 'is', values: ['open'] }, { columnId: 'customer.company', type: 'text', operator: 'contains', values: ['Acme'] }, ]; await tables.fetchData(ticketsTable, { filters }); ``` A bare `FilterState[]` means **implicit AND**. Relationship paths in `columnId` (like `customer.company`) trigger JOIN generation in the Drizzle adapter. Each filter's `operator` field is type-narrowed per column type — passing a number operator to a text column is a compile error. ## Operator reference [#operator-reference] Wrong-type operators are compile errors, so this table is the complete legal set. `isNull` / `isNotNull` are available on every type; operators marked *(no value)* take an empty `values` array. | Column type | Operators | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `text` | `contains`, `equals`, `startsWith`, `endsWith`, `isEmpty` *(no value)*, `isNotEmpty` *(no value)* | | `number` | `equals`, `notEquals`, `greaterThan`, `greaterThanOrEqual`, `lessThan`, `lessThanOrEqual`, `between` *(2 values)*, `notBetween` *(2 values)* | | `date` | `is`, `isNot`, `before`, `after`, `between` *(2 values)*, `notBetween` *(2 values)*, `isToday`, `isYesterday`, `isThisWeek`, `isThisMonth`, `isThisYear` *(relative ones take no value)* | | `option` | `is`, `isNot`, `isAnyOf`, `isNoneOf` | | `multiOption` | `includes`, `excludes`, `includesAny`, `includesAll`, `excludesAny`, `excludesAll` | | `boolean` | `isTrue`, `isFalse` *(no value)* | | `json` | `contains`, `equals`, `isEmpty`, `isNotEmpty` | Source of truth: `packages/core/src/types/filter-operators.ts` — labels, value arity, and per-type unions are all derived from it. ## Per-column filter config [#per-column-filter-config] Builders accept a `FilterConfig` to constrain or tune the filter UI and adapter behavior: ```ts t.number('reopenCount') .filterable() .numberOperators(['between', 'greaterThan']) // restrict the operator menu .range(0, 50); // min/max for the input t.text('subject').searchable({ debounce: 500, includeNull: true }); ``` | `FilterConfig` field | Purpose | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `operators` | Optional UI allow-list, **intersected** with the built-in registry for the column type. When omitted, built-in column types get the full `getOperatorsForType(type)` menu (includes `isNull` / `isNotNull`); restrict with `.textOperators()` / `.numberOperators()` / etc. `custom` columns have an empty registry entry, so the operator menu renders empty for them — setting `operators` explicitly does **not** populate it (an explicit list only seeds the initial operator when the chip is added). | | `options` | Choices for `option` / `multiOption` filters (schema enums fill this automatically — see [Auto columns](/docs/columns/auto-columns)); each option takes `value`, `label`, and optional `color` / `icon` / `count` | | `min` / `max` | Bounds for number inputs | | `debounce` | Text input debounce in ms (default 300) | | `includeNull` | Also match null/undefined values | | `validation` | `(value) => boolean \| string` — reject or message invalid filter input | | `includeTime` | Date filters: include the time component | | `format` | Date filters: display format | | `customComponent` | Replace the filter input with your own component | Note that `.searchable()` on a text column is filter sugar: it enables the common text operators (`contains`, `equals`, `startsWith`, `endsWith`, `isEmpty`, `isNotEmpty`) plus debounce / `includeNull`. It does **not** add `isNull` / `isNotNull`, and it does not add the column to a separate global search. ## Query groups (AND / OR) [#query-groups-and--or] For OR and nesting, pass a `FilterGroupNode` tree instead of a flat array. A bare `FilterState[]` means implicit AND. Core does **not** wrap arrays into a group before dispatch — adapters must accept both shapes: ```ts await tables.fetchData(ticketsTable, { filters: { kind: 'group', logic: 'or', children: [ { columnId: 'status', type: 'option', operator: 'is', values: ['open'] }, { kind: 'group', logic: 'and', children: [ { columnId: 'status', type: 'option', operator: 'is', values: ['resolved'] }, { columnId: 'reopenCount', type: 'number', operator: 'greaterThan', values: [2] }, ], }, ], }, }); ``` Nested groups round-trip through URL sync and programmatic `setFilterNode` (see [URL State](/docs/url-state)). Server-derived aggregate columns (`t.count` / `t.aggregate` — see [Derived columns](/docs/columns#derived-columns-server-aggregates)) participate in the same trees: Drizzle substitutes correlated-subquery predicates in place so OR groups keep union semantics. The filter bar stays a flat implicit-AND chip list — visual AND/OR tree editing is not shipped yet: ## Filter bar grouping [#filter-bar-grouping] The filter dropdown groups columns automatically (dates together, statuses together, …). Tune it with `autoGroupFilters` on ``: ```tsx ``` `AutoGroupConfig` accepts `enabled`, `autoGroup`, `customGroups` (explicit `FilterGroup[]` definitions), and `groupLabels`. Pass `groups` directly for fully manual grouping. ## Client managers [#client-managers] `FilterManager` and `TableStateManager` in `@better-tables/core` power the UI store. Pair with [URL state](/docs/url-state) so filters survive refresh and share. # Better Tables (/docs) Better Tables builds both halves of a data table from one definition: define your columns once in TypeScript, let an adapter turn filter, sort, and page state into real queries, and render with React components you own. ## Packages [#packages] | Package | Role | Distribution | | --------------------------------- | ---------------------------------------------------- | ------------------ | | `@better-tables/core` | Column builders, managers, adapter contracts | npm | | `@better-tables/adapters-drizzle` | Drizzle query generation (Postgres / MySQL / SQLite) | npm | | `@better-tables/ui` | React table & filter components | CLI copy (private) | | `@better-tables/cli` | `better-tables init` — copies UI into your app | npm | **Data flow:** `defineTable()` column definitions → adapter executes queries → UI renders and manages client state, optionally synced to the URL. ```ts import { betterTables, defineTable } from '@better-tables/core'; import { drizzleAdapter } from '@better-tables/adapters-drizzle'; export const tables = betterTables({ database: drizzleAdapter(db), defaults: { pageSize: 20 }, }); export const ticketsTable = defineTable()('tickets', (t) => ({ columns: [ t.text('subject').searchable().filterable().sortable(), t.option('status').options([{ value: 'open', label: 'Open' }]).filterable(), ], })); const result = await tables.fetchData(ticketsTable, { pagination: { page: 1, limit: 20 }, }); ``` ## AI-friendly docs [#ai-friendly-docs] * Index: [`/llms.txt`](/llms.txt) * Full corpus: [`/llms-full.txt`](/llms-full.txt) * Any page as Markdown: append `.mdx` (for example [`/docs/quick-start.mdx`](/docs/quick-start.mdx)) # Inline Editing (/docs/inline-editing) Enable per-column with `.editable()` on the builder. Double-click (or Enter on a focused cell) opens a type-appropriate editor; Enter/blur commits; Escape cancels. Option/boolean/date editors commit on selection/toggle/day pick. ```ts t.text('subject').editable() t.option('status').options([...]).editable() t.boolean('slaBreached').editable({ when: (row) => row.status !== 'resolved' }) t.number('reopenCount').editable({ field: 'reopenCount' }) // field override when id ≠ storage key ``` `editable()` takes `true` or an `EditableConfig` — the complete set: | `EditableConfig` field | Purpose | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `when(row)` | Per-row gate — return `false` to render that row's cell read-only | | `field` | Storage field written by the adapter save path (defaults to the column id; required when the id is not the storage key; setting it on a relationship path makes the save an own-table write instead). Relationship paths like `customer.company` are not editable through `saveAction` alone — the UI needs an `adapter` with `resolveCellWriteTarget` to resolve the write target, and adapter-path saves additionally need `features.update` + `updateRecord` — see [Joined-table editing](#joined-table-editing) below. Unsupported / unresolvable paths fall back to callback-only (`onCellEdit`) or read-only | | `multiline` | Text editor renders a textarea instead of a single-line input | | `placeholder` | Placeholder for empty text/number editors | | `editRenderer(props)` | Custom editor (and the only editor for `custom` columns) — receives `value`, `row`, `column`, `commit(value)`, `cancel()`; the table still owns save/validation/rollback | ## Validation [#validation] Attach rules with `.validation(rules)` on any column — they run before every save path (callback, action, or adapter), and a failing rule blocks the save and shows the message on the cell: ```ts t.text('subject') .editable() .validation([ { id: 'subject-length', validate: (value) => value.length >= 3 || 'Subject needs at least 3 characters', }, { id: 'no-caps', validate: (value) => value !== value.toUpperCase(), message: 'No shouting' }, ]) ``` Each `ValidationRule` is `{ id, validate, message? }`. `validate` returns `true` to pass, `false` to fail with `message`, or a string used as the error message directly. ## Persistence paths [#persistence-paths] Pick by deployment shape: | # | Deployment | Wiring | Notes | | - | ---------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | | 1 | **Monolith (recommended)** — Next.js, TanStack Start | `tables.cellEditAction(def)` through the framework server boundary, passed as `saveAction` | Zero boilerplate: allow-list, coercion, and write target derive from the table definition | | 2 | **Split frontend/backend** | `httpAdapter({ url, writes: true })` + handler `writes` + `authorize` | Double opt-in; see [HTTP adapter](/docs/adapters/http) | | 3 | **Full control** | `onCellEdit` callback | You own persistence; wins over every other path | ## Save resolution [#save-resolution] First match wins: | Condition | Result | | ----------------------------------------------------------------------- | ----------------------------------------------- | | `editing={false}` on `` | read-only | | column has no `editable` / `when(row)` false | read-only | | `onCellEdit` provided | callback save | | `saveAction` provided | serializable action save | | adapter `features.update` + `updateRecord` + resolvable target + row id | adapter save | | otherwise | read-only (+ one dev `console.warn` per column) | ## Joined-table editing [#joined-table-editing] A relationship-path column (`t.text('customer.company').editable()`) edits the **related** row — the write target resolves through the adapter's `resolveCellWriteTarget`. One-to-many paths are never cell-editable. Row-level authorization stays the app's concern (`authorize` / wrap the action). Optimistic updates show immediately; on failure the cell rolls back and `onCellEditError` fires. Validation rules run before any save. Edits are last-write-wins. ## V1 editors [#v1-editors] text (+ email/url/phone), number (+ currency/percentage), option, boolean, date. `multiOption` / `json` stay read-only; `custom` needs `editable.editRenderer`. **Option choices** resolve as: declared `.options()` → schema enum (via `resolveTableColumns`) → lazy `adapter.getFilterOptions(columnId)` on first open. ```tsx ``` # Large Datasets (/docs/large-datasets) Two tools keep big tables fast, and they compose: 1. **Server pagination** — fetch one page at a time (`tables.fetchData({ pagination })`); the default. 2. **Row virtualization** — render only the rows in view from a large `data` array. ## The `virtualized` prop [#the-virtualized-prop] `true` uses the defaults; pass an object to tune. Everything else — filtering, sorting, selection, column visibility/reordering, URL sync — behaves identically; virtualization is purely a rendering detail, not a separate component or data path. ```tsx ``` | Option | Default | Meaning | | ----------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `height` | `480` | Scroll viewport height in px | | `rowHeight` | `52` | Fixed row height in px — `` does not measure dynamic rows. For variable-height rows, use the lower-level `VirtualizedTable` path with `dynamicRowHeight` | | `overscan` | `5` | Extra rows rendered above/below the viewport | ## Choosing a strategy [#choosing-a-strategy] | Rows | Strategy | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | Hundreds | Plain pagination — nothing extra needed | | Thousands–tens of thousands | One large fetch + `virtualized`, usually with `features={{ pagination: false }}` since windowing already handles arbitrarily many rows | | More than fits in memory | Server pagination stays on; virtualize within the page if pages are large | The [/examples/big-board](/examples/big-board) demo renders \~12k rows this way — virtualization plus filters, sorting, and URL sync. ## Related hooks [#related-hooks] The copied UI includes `use-virtualization.ts` and `use-virtualized-table-data.ts`, which power `` internally — you own the source if you need custom windowing behavior. # Next.js Integration (/docs/nextjs) ### Define tables with lazy DB access [#define-tables-with-lazy-db-access] Native drivers like `better-sqlite3` must not run during `next build`. Use a lazy getter — see `apps/marketing/src/lib/demo/support/db.ts`. ```ts let tablesPromise: Promise | null = null; async function initSupportTables() { // open driver + create drizzle instance here (not at module scope) const db = drizzle(/* … */); return betterTables({ database: drizzleAdapter(db) }); } export function getSupportTables() { tablesPromise ??= initSupportTables(); return tablesPromise; } ``` ### Server-fetch with `tables.fetchData` [#server-fetch-with-tablesfetchdata] Next.js may expose a repeated query param as `string[]`. Normalize before `Number(...)` so malformed URLs (`?page=abc`, `?page=1.25`) do not send `NaN` or fractional pages to the adapter (or use `deserializeTableStateFromUrl` from [URL State](/docs/url-state)): ```tsx import { connection } from 'next/server'; function firstParam(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } export default async function TicketsPage({ searchParams, }: { searchParams: Promise<{ page?: string | string[] }>; }) { // Force request-time rendering (`connection()` from 'next/server') so // `next build` never prerenders the page and opens the native driver. await connection(); const sp = await searchParams; const pageRaw = Number(firstParam(sp.page) ?? 1); const page = Number.isInteger(pageRaw) && pageRaw >= 1 ? pageRaw : 1; const tables = await getSupportTables(); const result = await tables.fetchData(ticketsTable, { pagination: { page, limit: 10 }, }); return ; } ``` ### Copy UI into the app [#copy-ui-into-the-app] ```bash bunx @better-tables/cli init ``` Pass `columns={table.columns}` (or `table={def}`) to ``. ### Client URL sync [#client-url-sync] Use `useNextjsUrlAdapter()` with `useTableUrlSync` — see [URL State](/docs/url-state). ## HTTP adapter for client components [#http-adapter-for-client-components] When the adapter cannot run in the browser (native SQLite, secrets), mount `createAdapterRouteHandler` and use `httpAdapter` on the client. See [HTTP adapter](/docs/adapters/http). ## Live demos [#live-demos] # Pagination (/docs/pagination) Pagination uses `{ page, limit }` (1-based page). When `limit` is omitted, the Drizzle adapter and `` default to **10** — custom adapters define their own fallback. `betterTables({ defaults: { pageSize } })` stores a preferred size on the factory but does **not** apply it to `fetchData` or the UI yet — pass `pagination.limit` / `initialPagination` explicitly. ```ts const result = await tables.fetchData(ticketsTable, { pagination: { page: 2, limit: 10 }, }); ``` `result.data` is the page of rows; `result.total` is the filtered count for page controls. ## The pagination controls [#the-pagination-controls] `` renders page navigation plus a page-size selector (10 / 20 / 50 / 100 by default). Feed it the filtered total and react to changes with the controlled callbacks: ```tsx refetch({ page })} onPageSizeChange={(pageSize) => refetch({ page: 1, limit: pageSize })} /> ``` Without `totalCount`, `totalItems` falls back to `pagination.totalPages * pagination.limit` (often misleading on first paint). Always pass `result.total` when paginating on the server. The selector options and layout live in the copied `table-pagination.tsx` — `` takes `currentPage`, `totalPages`, `onPageChange`, `pageSize`, `onPageSizeChange`, `totalItems`, `pageSizeOptions`, `showPageSizeSelector`, `showPageInfo`, and `className`. You own that file; change the defaults there or render it yourself. Pagination state can be synced to the URL (`pagination` flag) — see [URL State](/docs/url-state). For very large boards, pair server pagination with client virtualization — see [Large datasets](/docs/large-datasets) and [/examples/big-board](/examples/big-board). # Quick Start (/docs/quick-start) ### Install packages [#install-packages] ```bash bun add @better-tables/core @better-tables/adapters-drizzle drizzle-orm better-sqlite3 bun add -d @types/better-sqlite3 ``` ```bash npm install @better-tables/core @better-tables/adapters-drizzle drizzle-orm better-sqlite3 npm install -D @types/better-sqlite3 ``` ```bash pnpm add @better-tables/core @better-tables/adapters-drizzle drizzle-orm better-sqlite3 pnpm add -D @types/better-sqlite3 ``` `better-sqlite3` is the driver used by the examples on this page — swap it (and its types) for `pg`, `mysql2`, or whatever your database needs. For the React UI, copy components into your app (they are not published on npm; requires a [shadcn setup](/docs/ui-and-cli#prerequisites)): ```bash bunx @better-tables/cli init ``` ```bash npx @better-tables/cli init ``` ```bash pnpm dlx @better-tables/cli init ``` `init` also checks for UI runtime deps (`zustand`, `@dnd-kit/*`) used by the copied source — see [UI & CLI](/docs/ui-and-cli). ### Create a lazy table factory (server-only) [#create-a-lazy-table-factory-server-only] Native drivers like `better-sqlite3` must not run during `next build`. Keep the adapter behind an async getter — do **not** construct `db` / `drizzleAdapter(db)` at module scope: ```ts // lib/tables.ts — server-only; never import from client components import { drizzleAdapter } from '@better-tables/adapters-drizzle'; import { betterTables } from '@better-tables/core'; import Database from 'better-sqlite3'; // swap for your driver import { drizzle } from 'drizzle-orm/better-sqlite3'; import * as schema from './schema'; // your Drizzle schema let tablesPromise: ReturnType | null = null; async function getDb() { // your drizzle setup — open the driver inside the getter, not at module scope return drizzle(new Database('app.db'), { schema }); } async function buildTables() { const db = await getDb(); return betterTables({ database: drizzleAdapter(db), }); } export function getTables() { tablesPromise ??= buildTables(); return tablesPromise; } export type Tables = Awaited>; ``` ### Define a table (client-safe module) [#define-a-table-client-safe-module] Keep `defineTable` in a separate file from the DB factory so client components never pull in the adapter: ```ts // lib/tickets-table.ts — safe to import from client components import { defineTable } from '@better-tables/core'; import type { Tables } from './tables'; export const ticketsTable = defineTable()('tickets', (t) => ({ columns: [ t.text('subject').searchable().filterable().sortable(), t.option('status') .options([ { value: 'open', label: 'Open' }, { value: 'resolved', label: 'Resolved' }, ]) .filterable() .sortable(), t.text('customer.company').displayName('Customer').searchable(), t.date('createdAt').displayName('Opened').sortable(), ], })); export type TicketRow = typeof ticketsTable.$infer.Row; ``` Relationship paths like `customer.company` tell the Drizzle adapter to JOIN automatically. ### Fetch on the server [#fetch-on-the-server] ```ts import { connection } from 'next/server'; // In your server component — force request-time rendering first, so // `next build` never prerenders the page and opens the native driver: await connection(); const tables = await getTables(); const result = await tables.fetchData(ticketsTable, { pagination: { page: 1, limit: 20 }, sorting: [{ columnId: 'createdAt', direction: 'desc' }], }); ``` ### Render with Better Table UI [#render-with-better-table-ui] This mounts the first page of rows. Filter / sort / page controls update client state until you wire a [server refetch](/docs/nextjs) or [URL sync](/docs/url-state) — pass `initialPagination` so the UI page size matches the fetch: ```tsx // app/tickets/tickets-table.tsx 'use client'; import { BetterTable } from '@/components/better-tables-ui/table/table'; import { ticketsTable, type TicketRow } from '@/lib/tickets-table'; export function TicketsTable({ rows, total }: { rows: TicketRow[]; total: number }) { return ( 20, hasPrev: false, }} /> ); } ``` ```tsx // app/tickets/page.tsx (server component) import { connection } from 'next/server'; import { getTables } from '@/lib/tables'; import { ticketsTable } from '@/lib/tickets-table'; import { TicketsTable } from './tickets-table'; export default async function TicketsPage() { // Force request-time rendering so `next build` never opens the native driver. await connection(); const tables = await getTables(); const result = await tables.fetchData(ticketsTable, { pagination: { page: 1, limit: 20 }, }); return ; } ``` ## Next [#next] * [BetterTable props](/docs/better-table) — every prop on the component * [Columns](/docs/columns) — path builders, modifiers, computed fields * [Filtering](/docs/filtering) — operator reference and AND/OR groups * [URL State](/docs/url-state) — shareable filter/sort/page links * [Next.js Integration](/docs/nextjs) — lazy DB init, server refetch from `searchParams` # Selection & Actions (/docs/selection-and-actions) Row selection adds a checkbox column (with select-all in the header) and tracks selected row ids. Enable it explicitly, or implicitly by passing `actions` — the checkbox column appears whenever `actions` is non-empty or `features.rowSelection` is `true`. ```tsx console.log([...selected])} /> ``` Row identity comes from `rowConfig.getId(row)` when provided, falling back to the row's `id` field — set `getId` whenever your rows have a different key. Plain row selection needs no module — it is part of core. ## Bulk actions [#bulk-actions] The actions toolbar is an opt-in [module](/docs/ui-and-cli#modules). Add it once: ```bash bunx @better-tables/cli add actions ``` Then wire it into the table's `actionsToolbar` slot. `actions` is a `TableAction[]`; the toolbar renders above the table and invokes handlers with the current selection: ```tsx import { Archive, Trash2 } from 'lucide-react'; import { ActionsToolbar } from '@/components/better-tables-ui/table/actions-toolbar'; { await archiveTickets(selectedIds); }, }, { id: 'delete', label: 'Delete', icon: Trash2, variant: 'destructive', confirmationDialog: { title: 'Delete tickets?', description: 'This cannot be undone.', confirmLabel: 'Delete', }, handler: async (selectedIds) => deleteTickets(selectedIds), }, ]} /> ``` ## TableAction (complete) [#tableaction-complete] | Field | Purpose | | --------------------------------------- | -------------------------------------------------------------------------------------------- | | `id` | Unique action identifier | | `label` | Button label | | `icon` | Icon component (optional) | | `variant` | Button visual variant: `'default' \| 'destructive' \| 'secondary'` | | `handler(selectedIds, selectedData?)` | The work — sync or async | | `confirmationDialog` | `{ title, description, confirmLabel?, cancelLabel?, destructive? }` — confirm before running | | `isVisible(selectedIds, selectedData?)` | Hide the action for some selections | | `isEnabled(selectedIds, selectedData?)` | Disable (but show) the action | | `meta` | Arbitrary metadata passthrough | After a handler resolves, refetch your data — the table doesn't mutate rows itself. Selection state can also be seeded with `initialSelectedRows` and observed via `onSelectionChange` (see the [props reference](/docs/better-table)). To customize the toolbar (for example, the selected-count label), edit the copied `actions-toolbar.tsx` directly; add app-specific toast handling there as needed. # Sorting (/docs/sorting) Sorting is `{ columnId, direction }[]` (`SortingParams[]`, direction `'asc' | 'desc'`) passed to `tables.fetchData()` or managed via `SortingManager`. Order in the array is precedence — earlier entries win ties. ```ts await tables.fetchData(ticketsTable, { sorting: [ { columnId: 'createdAt', direction: 'desc' }, { columnId: 'subject', direction: 'asc' }, ], }); ``` Mark columns `.sortable()` in `defineTable` so the UI exposes sort controls and the adapter knows which fields are legal. Relationship-path columns (`customer.company`) sort through the same JOINs as filters. ## Multi-column sorting in the UI [#multi-column-sorting-in-the-ui] Single-column sort is the default: clicking a header replaces the sort. Enable multi-sort on `` to let users stack sorts and reorder precedence by dragging in the sort-order list: ```tsx ``` `multiSort` is the only `SortingConfig` field `` reads — everything else about click behavior lives in the copied `table.tsx` / `sort-order-list.tsx`, which you own and can change. ## SortingConfig (core managers) [#sortingconfig-core-managers] When driving state yourself with `SortingManager` / `TableStateManager`, the full `SortingConfig` applies: | Field | Purpose | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | Master switch | | `multiSort` | Allow more than one active sort | | `maxSortColumns` | Cap on simultaneously sorted columns | | `defaultSort` | `SortingParams[]` applied on initialization | | `resetOnClick` | Clicking a header clears other sorts | | `comparator` | **Unwired / reserved** — typed on `SortingConfig` but neither `SortingManager` nor `` invokes it; sorting is server/adapter-driven today | Initial state for the component goes through `initialSorting` — see the [BetterTable props reference](/docs/better-table). # Troubleshooting (/docs/troubleshooting) ## `next build` crashes loading the database [#next-build-crashes-loading-the-database] Native drivers like `better-sqlite3` must not run at build/import time. Initialize the DB behind a lazy singleton and call it per request: ```ts let tablesPromise: Promise | null = null; async function initSupportTables() { // open driver + create drizzle instance here (not at module scope) const db = drizzle(/* … */); return betterTables({ database: drizzleAdapter(db) }); } export function getSupportTables() { tablesPromise ??= initSupportTables(); return tablesPromise; } ``` See [Next.js Integration](/docs/nextjs) and the reference implementation in `apps/marketing/src/lib/demo/support/db.ts`. ## `better-tables init` exits immediately [#better-tables-init-exits-immediately] `init` requires a working shadcn setup (`components.json`). It will print remediation steps; set up shadcn first, then re-run. It also checks for `@better-tables/core`, `@better-tables/adapters-drizzle`, and UI runtime deps (`zustand`, `@dnd-kit/*`) and offers to install them. Invoke as `bunx @better-tables/cli init`. Flags: `--skip-shadcn`, `-y`, `--components-path`, `--cwd` — see [UI & CLI](/docs/ui-and-cli). ## Bulk actions don't render [#bulk-actions-dont-render] The actions toolbar is an opt-in [module](/docs/ui-and-cli#modules). If you pass `actions` but the toolbar never appears, either the module isn't added or the slot isn't wired — the table warns once in dev: > `actions` were provided but no `slots.actionsToolbar` is set, so the bulk-actions toolbar will not render. Install the actions module (`bunx better-tables add actions`) and pass `slots={{ actionsToolbar: ActionsToolbar }}`. Run `bunx @better-tables/cli add actions`, then pass `slots={{ actionsToolbar: ActionsToolbar }}` (imported from the copied `table/actions-toolbar`). Row selection itself is core and still turns on whenever `actions` is non-empty — only the toolbar needs the module. See [Selection & actions](/docs/selection-and-actions#bulk-actions). ## An editable cell renders read-only [#an-editable-cell-renders-read-only] Save resolution walks a fixed order (see [Inline editing](/docs/inline-editing#save-resolution)) and falls back to read-only, logging **one dev `console.warn` per column** explaining which condition failed. Check, in order: `editing={false}` on the table, missing/false `editable` config (`when(row)` returning false), and — for the adapter path — `adapter.meta.features.update`, an `updateRecord` implementation, a resolvable write target, and a row id (`rowConfig.getId` or an `id` field). ## `cellEdit` returns 403 over HTTP [#celledit-returns-403-over-http] Writes are double opt-in: the route handler needs `writes` config **and** the client needs `httpAdapter({ url, writes: true })`. Without both, the server answers 403 by design. See [HTTP adapter](/docs/adapters/http#writes-opt-in). ## `SchemaError` on reads with a multi-table schema [#schemaerror-on-reads-with-a-multi-table-schema] For `fetchData` / `describeColumns`, when a call provides neither `columns` nor `primaryTable`, the adapter throws instead of guessing the table. Name the table on each call — `fetchData({ primaryTable: 'users' })` in the params, `describeColumns('users')` as the positional argument — or set `options.defaultPrimaryTable` (reads) / `options.defaultMutationTable` (writes) on `drizzleAdapter` — see [Drizzle adapter](/docs/adapters/drizzle#factory-options-complete). Facet methods (`getFacetedValues`, `getFilterOptions`, `getMinMaxValues`) have **no** per-call `primaryTable` — they resolve the table from the column id. Keep facet column ids unambiguous on multi-table schemas, or expose a table-scoped adapter / HTTP allow-list (see [HTTP adapter](/docs/adapters/http)). ## Facet counts look wrong after filtering [#facet-counts-look-wrong-after-filtering] Pass your **full, unmodified** filter state to `useFacets` / `getFacetedValues`. Self-exclusion (a column's own filter not narrowing its own facet) is the adapter's responsibility — pre-filtering per column fights it. See [Facets](/docs/facets). ## Filter options never load for an option column [#filter-options-never-load-for-an-option-column] Option choices resolve in order: declared `.options()` → schema enum (via `resolveTableColumns`, needs `adapter` on `` with `describeColumns` support) → lazy `adapter.getFilterOptions(columnId)` on first open. If all three are missing, the dropdown has nothing to show. An adapter without `describeColumns` also degrades auto columns to the declared list with a dev warning — see [Auto columns](/docs/columns/auto-columns). ## Pagination shows the wrong total [#pagination-shows-the-wrong-total] Always pass `totalCount={result.total}` with server pagination — without it `totalItems` falls back to `pagination.totalPages * pagination.limit`. See [Pagination](/docs/pagination). ## Maps arrive as arrays over HTTP [#maps-arrive-as-arrays-over-http] That's the wire format: `getFacetedValues` and `fetchData.faceted` travel as `[value, count][]` and the `httpAdapter` client rebuilds `Map`s. Only raw `fetch` callers need to handle the arrays themselves — see [HTTP adapter](/docs/adapters/http#wire-contract). # UI & CLI (/docs/ui-and-cli) `@better-tables/ui` is **private** — it is not installed from npm. The CLI copies shadcn-style source into your project so you can theme and extend it. ## Prerequisites [#prerequisites] `init` refuses to run without a working [shadcn](https://ui.shadcn.com) setup (`components.json` in your project) — the copied components build on shadcn primitives and your Tailwind tokens. During `init` the CLI: 1. Verifies shadcn is configured (exits with remediation steps if not) 2. Installs the shadcn primitives the table needs (badge, button, dialog, …) via the shadcn CLI 3. Checks that `@better-tables/core`, `@better-tables/adapters-drizzle`, and the UI runtime deps (`zustand`, `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/modifiers`, `@dnd-kit/utilities`) are installed, offering to add them 4. Copies the Better Tables source into your project ## Init [#init] ```bash bunx @better-tables/cli init # or npx @better-tables/cli init ``` | Flag | Effect | | -------------------------- | ------------------------------------------------------------------------------------------ | | `--components-path ` | Output directory relative to your shadcn components directory (default `better-tables-ui`) | | `-y, --yes` | Skip confirmation prompts (also skips per-file overwrite prompts) | | `--skip-shadcn` | Skip the shadcn component check/install | | `--cwd ` | Run against a different project directory | What lands in your project (default paths, resolved through your `components.json` aliases): * `components/better-tables-ui/table/` — table, virtualized table, pagination, column visibility/ordering, editable cells, states * `components/better-tables-ui/filters/` — filter bar, dropdown, active filters, faceted sidebar, per-type inputs * `hooks/` — `use-table-data`, `use-table-url-sync`, `use-facets`, `use-editable-cells`, virtualization hooks, … * `lib/` — small shared utilities Import from the copied paths — there is no `@better-tables/ui` package to import in your app: ```tsx import { BetterTable } from '@/components/better-tables-ui/table/table'; ``` ## Modules [#modules] The copied UI ships in **modules** — named groups of source files you opt into. `init` copies the **core** module (the table, filters, hooks, and utilities). Everything else is added on demand: ```bash bunx @better-tables/cli add actions # bulk-action toolbar over selected rows ``` `init` also accepts `--modules` to opt in at init time: ```bash bunx @better-tables/cli init --modules actions ``` | Module | What it copies | Wire-up | | --------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `core` | Table, filters, hooks, lib (always copied by `init`) | — | | `actions` | `actions-toolbar.tsx`, `action-confirmation-dialog.tsx` | `slots={{ actionsToolbar: ActionsToolbar }}` — see [Selection & actions](/docs/selection-and-actions) | | `export` | `export-button.tsx`, `use-table-export.ts` | `slots={{ toolbarExtra: ExportButton }}` — see [Export](/docs/export) | Modules plug into the table through the `slots` prop — an absent module is a clean no-op, so core has no dead imports. `add ` reuses your init-time config; pass the same `--components-path` if you changed it. An unknown module name lists the valid ones and exits non-zero. **Modules vs. plugins.** These are different tiers. **Modules** are copied UI source added with `better-tables add` (this page). **Plugins** are npm-distributed data hooks passed to `betterTables({ plugins: [...] })` — no UI. Keep the words distinct. ## Why copy instead of npm? [#why-copy-instead-of-npm] * You own the JSX — no fighting a black-box design system * Works with your existing shadcn / Tailwind tokens * Avoids bundling conflicts with app-local UI primitives ## Docs command [#docs-command] ```bash bunx @better-tables/cli docs # this site bunx @better-tables/cli docs core # /docs/packages/core bunx @better-tables/cli docs drizzle # /docs/adapters/drizzle bunx @better-tables/cli docs ui # /docs/packages/ui ``` ## URL sync hook [#url-sync-hook] After init, use `useTableUrlSync` from your copied hooks directory — see [URL State](/docs/url-state). # URL State (/docs/url-state) Sync table state to query parameters via a framework-agnostic adapter. Filters write a compressed group-aware wire format (`c2:` prefix); legacy `c:` payloads still deserialize as a flat `FilterState[]`. See `serializeFiltersToURL` / `deserializeFiltersFromURL` in `@better-tables/core` (or `serializeTableStateToUrl` / `deserializeTableStateFromUrl` for the full state in one call). ## Adapter interface [#adapter-interface] The adapter type lives in `@better-tables/core`: ```ts import type { UrlSyncAdapter } from '@better-tables/core'; interface UrlSyncAdapter { getParam: (key: string) => string | null; setParams: (updates: Record) => void; } ``` ## Next.js App Router adapter [#nextjs-app-router-adapter] ```ts // lib/nextjs-url-adapter.ts 'use client'; import type { UrlSyncAdapter } from '@better-tables/core'; import { useRouter, useSearchParams } from 'next/navigation'; export function useNextjsUrlAdapter(): UrlSyncAdapter { const router = useRouter(); const searchParams = useSearchParams(); return { getParam: (key: string) => searchParams.get(key), setParams: (updates: Record) => { const params = new URLSearchParams(searchParams); for (const [key, value] of Object.entries(updates)) { if (value === null) { params.delete(key); } else { params.set(key, value); } } router.push(`?${params.toString()}`, { scroll: false }); }, }; } ``` ## Wiring it up [#wiring-it-up] Components and hooks are copied into your app by `better-tables init` — import them from your local paths (defaults shown; adjust to your `--components-path` and aliases). Inside this repo's own demos you'll see `from '@better-tables/ui'` instead; that workspace package is not published, so as a consumer you always import the copied files. The simplest wiring is the `urlSync` prop — `` runs the sync internally: ```tsx 'use client'; import { BetterTable } from '@/components/better-tables-ui/table/table'; import { useNextjsUrlAdapter } from '@/lib/nextjs-url-adapter'; export function MyTable({ data }) { const urlAdapter = useNextjsUrlAdapter(); return ( ); } ``` Or call the hook yourself (equivalent, useful when the table id is shared with other state consumers): ```tsx 'use client'; import { BetterTable } from '@/components/better-tables-ui/table/table'; import { useTableUrlSync } from '@/hooks/use-table-url-sync'; import { useNextjsUrlAdapter } from '@/lib/nextjs-url-adapter'; export function MyTable({ data }) { const urlAdapter = useNextjsUrlAdapter(); useTableUrlSync( 'my-table', { filters: true, pagination: true, sorting: true }, urlAdapter ); return ; } ``` ## Other frameworks [#other-frameworks] The adapter is two functions, so any router works. React Router v6: ```ts // lib/react-router-url-adapter.ts import type { UrlSyncAdapter } from '@better-tables/core'; import { useSearchParams } from 'react-router-dom'; export function useReactRouterAdapter(): UrlSyncAdapter { const [searchParams, setSearchParams] = useSearchParams(); return { getParam: (key: string) => searchParams.get(key), setParams: (updates: Record) => { const params = new URLSearchParams(searchParams); for (const [key, value] of Object.entries(updates)) { if (value === null) { params.delete(key); } else { params.set(key, value); } } setSearchParams(params, { replace: true }); }, }; } ``` TanStack Router and Remix follow the same shape against their own search-param hooks. For plain React (no router), the copied hooks file exports a ready-made adapter over the History API: ```ts import { createVanillaUrlAdapter } from '@/hooks/use-table-url-sync'; const urlAdapter = createVanillaUrlAdapter(); ``` ## What can be synced [#what-can-be-synced] `UrlSyncConfig` is five independent flags: | Flag | URL params | Notes | | ------------------ | --------------------------------------------------------- | -------------------------------------------------------- | | `filters` | compressed `c2:`-prefixed param (legacy `c:` still reads) | Includes AND/OR query groups — trees survive share links | | `pagination` | page / page size | 1-based page | | `sorting` | multi-column sort descriptors | | | `columnVisibility` | shown/hidden column ids | Pairs with `.hideable()` columns | | `columnOrder` | column order | Pairs with drag-and-drop column reordering | Enable only what should be shareable — anything not synced stays client-local state. ## URL parameter format [#url-parameter-format] Simple values (`page`, `limit`) are plain strings; complex state is lz-string–compressed. Filters use the `c2:` prefix (group-aware); other fields use `c:`: ``` /users?page=2&limit=20&filters=c2:...&sorting=c:... ``` ## SSR: hydrate initial state from the URL [#ssr-hydrate-initial-state-from-the-url] On the server, parse the same params with `deserializeTableStateFromUrl` (handles decompression) and fetch the right page before render: ```tsx // app/users/page.tsx (Next.js App Router) import { deserializeTableStateFromUrl } from '@better-tables/core'; export default async function UsersPage({ searchParams }) { const params = await searchParams; const tableState = deserializeTableStateFromUrl({ filters: params.filters ?? undefined, page: params.page ?? undefined, limit: params.limit ?? undefined, sorting: params.sorting ?? undefined, }); const page = tableState.pagination?.page ?? 1; const limit = tableState.pagination?.limit ?? 10; const result = await tables.fetchData(usersTable, { pagination: { page, limit }, filters: tableState.filters ?? [], sorting: tableState.sorting ?? [], }); return ( ); } ``` The client then mounts with matching state and `useTableUrlSync` keeps it in sync from there. Reference implementation: `apps/marketing/src/lib/nextjs-url-adapter.ts`. Package notes: `packages/ui/docs/URL_SYNC.md` (canonical teaching is this page). # Why Better Tables (/docs/why-better-tables) Data tables have two halves: the **server half** (turn filter/sort/page state into correct SQL — JOINs, counts, facets) and the **client half** (render, manage state, sync the URL). Most libraries pick one half. Better Tables covers both from a single column definition: ``` defineTable() columns → adapter generates queries → UI renders + syncs state ``` Define `t.text('customer.company').filterable()` once and you get the JOIN plus a filterable column in the UI. Wire shareable URL state separately via `urlSync` / `useTableUrlSync` (see [URL State](/docs/url-state)). `$infer.Row` / `$infer.ColumnId` are typed today; `$infer.FilterState` is still reserved (`unknown`) for a future typed filter registry. ## Compared to headless table libraries (TanStack Table) [#compared-to-headless-table-libraries-tanstack-table] Headless libraries solve client-side table *rendering state* extremely well, and stay agnostic about where rows come from. That agnosticism means the server half — translating filter/sort/pagination into queries, keeping allowed fields in sync with the UI, faceted counts — stays yours to build and keep consistent. Better Tables starts from the schema instead: the column definition is shared by the query generator and the UI, so a column can't be filterable in the UI but unsupported in SQL. The trade-off is scope — Better Tables is opinionated about the stack (TypeScript, an adapter for your data layer, shadcn-style React UI) where headless libraries fit anywhere. ## Compared to hand-rolling [#compared-to-hand-rolling] The hand-rolled version of this stack is boilerplate with sharp edges: operator-by-operator SQL translation, JOIN aliasing for relationship filters, facet counts with self-exclusion, URL round-tripping for AND/OR filter trees, and write allow-listing for inline edits. That's the exact code the core + adapter packages contain, tested (including SQL-injection and wire-format suites), so your app keeps only the interesting parts: schema, columns, and UI polish. ## You own the UI [#you-own-the-ui] `@better-tables/ui` is deliberately **not** an npm dependency — `bunx @better-tables/cli init` copies the source (shadcn-style) into your repo. Restyle it, rewrite a component, or delete what you don't use; upgrades are diffs you apply on your terms, not breaking releases. See [UI & CLI](/docs/ui-and-cli). ## When Better Tables is the wrong tool [#when-better-tables-is-the-wrong-tool] * **You just need to render arrays with zero features** — a headless table library is lighter. (Though for filtered/faceted client-side tables, `memoryAdapter(rows)` runs the whole pipeline with no backend — see [Custom adapters](/docs/adapters/custom#memory-adapter).) * **Your data layer has no adapter** and you can't invest in one — Drizzle is first-party; anything else means implementing the [adapter contract](/docs/adapters/custom) (the toolkit does much of the work, but it's real work). * **You need a hosted admin panel** with auth, CRUD scaffolding, and dashboards — Better Tables is a library, not an admin framework. * **Pre-1.0 tolerance is zero** — the API is stabilizing around `betterTables()` / `defineTable()`; breaking changes still happen between minors. ## Where to start [#where-to-start] # Custom Adapters (/docs/adapters/custom) An adapter is anything that implements the `TableAdapter` contract from `@better-tables/core`. The Drizzle adapter is the first-party reference; `@better-tables/adapters-toolkit` packages its ORM-agnostic machinery so a Prisma/Kysely/raw-SQL adapter composes primitives instead of re-implementing them. ## Memory adapter [#memory-adapter] Core ships one adapter of its own: `memoryAdapter(rows)`, the full pipeline over a plain array — every filter operator for the built-in column types, AND/OR trees, sorting, pagination, facets with self-exclusion, schema inference for [auto columns](/docs/columns/auto-columns), and in-place cell edits. `custom` columns are the one exception: their operators are user-defined, so there's no generic way to evaluate them in memory — only the universal `isNull` / `isNotNull` are supported there. Use it for client-only tables, playgrounds, tests, and as a small readable reference for the contract below. ```ts import { betterTables, memoryAdapter } from '@better-tables/core'; const tables = betterTables({ database: memoryAdapter(tickets) }); const ticketsTable = tables.define('memory'); // auto columns inferred from the data const result = await tables.fetchData(ticketsTable, { filters: [{ columnId: 'status', type: 'option', operator: 'is', values: ['open'] }], pagination: { page: 1, limit: 20 }, }); ``` Options: `getRowId` (row identity for edits; defaults to the `id` field), `tableName`, and `enumInferenceLimit` (distinct-string ceiling for inferring `option` columns; default 12, `0` disables). Scope: own-table columns only — dot-path ids walk nested objects, but there's no relationship model or JOIN semantics; use a database adapter for relational data. `memoryAdapter` supports **derived aggregates** when rows already carry nested arrays (`posts: [...]`): it materializes `t.count` / `t.aggregate` values before filter/sort and declares `meta.capabilities.aggregates` (all fns, render/filter/sort). ## The contract [#the-contract] ```ts import type { TableAdapter } from '@better-tables/core'; ``` **Required** — every adapter implements these four methods plus `meta`: | Member | Purpose | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `fetchData(params)` | Filtered/sorted/paginated rows → `{ data, total, pagination, faceted?, meta? }` | | `getFilterOptions(columnId, params?)` | Distinct choices for option filters → `FilterOption[]` | | `getFacetedValues(columnId, params?)` | Value counts → `Map` (self-exclusion is YOUR job: a column's own filter must not narrow its own facet) | | `getMinMaxValues(columnId, params?)` | `[min, max]` for range facets | | `meta` | `AdapterMeta` — name, version, `features`, and optional `capabilities` (e.g. `aggregates` for `t.count` / `t.aggregate`) | **Optional** — declare only what you support; `features` must match: | Member | Enables | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | `describeColumns(table?)` | [Auto columns](/docs/columns/auto-columns): name/type/nullability/enums/writability per column (`InferredColumnSpec[]`) | | `resolveCellWriteTarget(columnId, table?)` | Inline editing on relationship paths — maps a column id to `{ table, field, relatedIdPath, single, writable }` | | `createRecord` / `updateRecord` / `deleteRecord` | Mutations (`updateRecord` powers adapter-path [inline editing](/docs/inline-editing)) | | `bulkUpdate` / `bulkDelete` | Bulk mutations | | `exportData(params)` | Export (`csv` / `json` / `excel`) | | `subscribe(callback)` | Real-time data events | `AdapterFeatures` flags: `create`, `read`, `update`, `delete`, `bulkOperations`, `realTimeUpdates`, `export`, `transactions`. The UI checks these before offering behavior — `features.update: false` renders adapter-saved editable cells read-only. `fetchData` params to honor: `pagination` (1-based), `sorting[]`, `filters` (`FilterState[]` = implicit AND, **or** a `FilterGroupNode` tree — core does not canonicalize before dispatch; support both), `columns`, `derived` (serializable aggregate specs from `t.count` / `t.aggregate`), `primaryTable`, `params`, `signal`. The `search` param is declared on the contract but not yet consumed by first-party callers. If you do not implement aggregates, omit `capabilities.aggregates` — instance fetch throws a clear error when specs are present. ## What the toolkit provides [#what-the-toolkit-provides] ```bash bun add @better-tables/adapters-toolkit ``` | Export | Job | | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `FilterRouter` + `PredicateEmitter` | Classifies every filter operator and routes it to small per-kind emit callbacks you implement for your backend; includes `computeDatePeriodRange` for `isToday`/`isThisWeek`/… and `FilterRouterError` | | `DataTransformer` | Flat JOINed rows → nested objects (`customer.company` → `row.customer.company`) | | `PrimaryTableResolver` | Infers the primary table from column ids / heuristics (`RelationshipMapLike`) | | `SchemaIntrospectionPort` / `RelationshipManagerPort` | The two ORM-specific interfaces you implement; everything else composes against them | | `RelationshipMap` / `RelationshipPath` / `ColumnPath` / `AggregateColumn` / `PrimaryKeyInfo` / `ColumnInfoLike` | Shared types for relationship-path resolution | | `getPrimaryKeyInfo` / `getPrimaryKeyMap` | Primary-key introspection helpers | | `generateAlias` / `generatePathAlias` / `generatePathKey` | Stable JOIN alias generation for relationship paths | | `escapeSqlIdentifier` / `quoteIdentifier` | Safe SQL identifier handling | | `calculateLevenshteinDistance` | "Did you mean …?" suggestions in `SchemaError`s | | `SchemaError` | The error type to throw for unknown tables/columns | ## Suggested shape [#suggested-shape] 1. Implement the ORM-specific ports: `SchemaIntrospectionPort`, `RelationshipManagerPort`, and a `PredicateEmitter` for your backend's predicate type (Drizzle's reference is `DrizzlePredicateEmitter`). 2. Build `fetchData`: resolve the primary table (`PrimaryTableResolver`), construct `new FilterRouter(emitter)`, route filters through it, apply sorting/pagination, and nest results with `DataTransformer`. Accept both flat `FilterState[]` and `FilterGroupNode` inputs. 3. Add facets: `getFacetedValues` / `getMinMaxValues` are grouped-count / min-max variants of the same query pipeline — remember self-exclusion. 4. Fill in `meta.features` honestly; add mutations and `describeColumns` last. Study `packages/adapters/drizzle` side by side — it composes exactly these primitives, and its test suites (`packages/adapters/drizzle/tests`) show the expected behavior per contract method, including the HTTP wire cases in [HTTP adapter](/docs/adapters/http). # Drizzle Adapter (/docs/adapters/drizzle) `@better-tables/adapters-drizzle` turns `defineTable` columns + filter/sort/page state into Drizzle queries. ```ts import { drizzleAdapter } from '@better-tables/adapters-drizzle'; import { betterTables, defineTable } from '@better-tables/core'; export const tables = betterTables({ database: drizzleAdapter(db), }); export const ticketsTable = defineTable()('tickets', (t) => ({ columns: [ t.text('subject').filterable().sortable(), t.text('customer.company').displayName('Customer').filterable(), t.option('status').filterable(), ], })); ``` ## Factory options (complete) [#factory-options-complete] `drizzleAdapter(db, factoryOptions?)` auto-detects the schema, driver, and relations from your Drizzle instance; every option is an override: | `factoryOptions` field | Purpose | | ------------------------- | ----------------------------------------------------- | | `schema` | Override the auto-detected schema | | `driver` | Override the auto-detected driver | | `relations` | Override or provide Drizzle relations | | `relationships` | Manual `RelationshipMap` — see below | | `autoDetectRelationships` | Auto-detect from FK metadata (default `true`) | | `meta` | Override adapter metadata | | `options` | `DrizzleAdapterOptions` — behavior tuning, next table | | `options` field | Purpose | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `defaultPrimaryTable` | Table for reads when a call provides neither `columns` nor `primaryTable` (required for that case on multi-table schemas — the adapter throws a `SchemaError` instead of guessing) | | `defaultMutationTable` | Table for writes on multi-table schemas | | `onInvalidFilter` | `'skip'` (default) or `'throw'`. What to do when a filter can't be translated — see [Invalid filters](#invalid-filters) below | | `cache` | Query result caching: `{ enabled, ttl, maxSize? }` (LRU, default max 500 entries) | | `optimization` | **Reserved / unimplemented** — typed only; join caps and related knobs are not wired at runtime | | `batching` | **Reserved / unimplemented** — typed only; the emitter uses fixed batch constants (`batchSize` 50, `maxBatchesPerGroup` 200) | | `logging` | **Reserved / unimplemented** — typed only; no query logging today | | `performance` | **Reserved / unimplemented** — typed only; no slow-query warnings today | ## Relationship paths [#relationship-paths] Dot paths in column ids (`customer.company`, `customer.plan`) drive automatic JOINs from Drizzle relations / FK metadata. Filter and sort on those paths the same way as own-table columns. When auto-detection doesn't capture a relationship (or you need to override one), provide a manual `RelationshipMap` keyed by column id: ```ts const adapter = drizzleAdapter(db, { relationships: { 'preferences.theme': { from: 'users', to: 'user_preferences', foreignKey: 'userId', // FK field in the target table localKey: 'id', // key field in the source table cardinality: 'one', // 'one' | 'many' nullable: true, joinType: 'left', // 'left' | 'inner' isArray: false, // true for array-FK relationships }, }, }); ``` PostgreSQL `.array()` foreign keys (with `.references()`) are detected automatically. MySQL `json()` / SQLite text-or-JSON array FKs are **not** auto-detected — provide a manual `RelationshipMap` entry with `isArray: true`. Deeper patterns — junction tables, aggregates, subqueries — are covered with worked examples in the package's [advanced usage notes](https://github.com/Better-Tables/better-tables/blob/main/packages/adapters/drizzle/docs/ADVANCED_USAGE.md) (some samples still show a removed pre-0.6 builder API; the concepts apply unchanged, the syntax doesn't). Try it live: [/examples/relationship-filtering](/examples/relationship-filtering). ## Derived aggregates (t.count) [#derived-aggregates-tcount] Prefer declarative columns over hand-rolled `computedFields` for relation counts: ```ts t.count('posts').displayName('Posts'), // id: postsCount ``` The adapter validates the many-relation on the primary table, lowers the spec to a correlated subquery on SELECT / WHERE / ORDER BY (via the existing computed-fields pipeline), and supports filters inside `FilterGroupNode` trees. Index the FK on the related table (`posts.user_id`) — each row pays one subquery. See [Derived columns](/docs/columns#derived-columns-server-aggregates). ## Facets [#facets] `getFacetedValues` / `getMinMaxValues` / `fetchData({ faceted })` power filter option counts and range facets, with self-exclusion handled by the adapter. See the [Facets guide](/docs/facets) and [/examples/facets](/examples/facets). ## Invalid filters [#invalid-filters] When a filter leaf can't be translated to SQL — an operator that isn't valid for the column's type, or a supported operator with missing/incomplete values — the adapter has to decide between dropping it and failing. Dropping **widens** the result set: under implicit-AND, a discarded predicate reads as "no restriction", so a filter meant to scope rows silently returns everything. `options.onInvalidFilter` controls this: * **`'skip'` (default)** — drop the leaf and keep going, so partial URL/UI filter state still fetches. An invalid operator logs a `[better-tables]` warning; genuinely incomplete values stay silent (normal mid-edit state). * **`'throw'`** — raise a `QueryError` instead of dropping. Use this when filters are built programmatically and every one *must* apply — a dropped tenant or owner guard is a data-exposure bug, not a UI convenience. ```ts // A server-side scoping filter that must never be silently dropped: const adapter = drizzleAdapter(db, { options: { defaultPrimaryTable: 'users', onInvalidFilter: 'throw' }, }); ``` The legitimate "match null rows only" request (`includeNull` with empty values) is a real condition, not a drop, and is unaffected by either mode. ## Databases [#databases] Postgres, MySQL, and SQLite are supported. Peer dependencies are your driver's (`postgres`, `mysql2`, `better-sqlite3`) plus `drizzle-orm`. Integration tests for MySQL/Postgres live under `packages/adapters/drizzle/tests` and **run in CI against real Postgres and MySQL service containers** (they self-skip locally when no DB URL is set; a CI guard fails the build if those URLs ever go missing so the suites can't silently skip). ## Performance tuning [#performance-tuning] * **Caching**: `options.cache` memoizes query results with TTL + LRU eviction — effective for facet counts on hot tables. * **Reserved options**: `optimization`, `batching`, `logging`, and `performance` appear in the type surface but are not applied at runtime yet. Large-array filters already use fixed internal batching; do not expect configuring those fields to change behavior. ## Cell writes [#cell-writes] When your app uses adapter-level `updateRecord`, the Drizzle adapter resolves write targets (including related rows) through `resolveCellWriteTarget`. Prefer monolith `tables.cellEditAction` when you can — see [Inline editing](/docs/inline-editing). ## Package docs [#package-docs] npm README: `packages/adapters/drizzle/README.md`. Building your own adapter instead? Start at [Custom adapters](/docs/adapters/custom). # HTTP Adapter (/docs/adapters/http) Pair a browser-safe `httpAdapter` client with a server `createAdapterRouteHandler` so client components can call a server-only adapter (for example Drizzle wrapping `better-sqlite3`) without hand-rolling fetch plumbing. ## Safe Next.js App Router mount [#safe-nextjs-app-router-mount] Pin table-scoped methods **and** allow-list every column id referenced by facet/filter reads. `authorize: () => true` alone is not safe on a multi-table schema — `getFacetedValues` / `getFilterOptions` / `getMinMaxValues` resolve tables from `columnId`, not from a `primaryTable` pin on `fetchData`. And validating the facet `columnId` alone is not enough either: facet requests also carry the **active filters** in `params.filters`, so an allow-listed facet target combined with a filter on an off-limits column would leak that column's contents through the counts. Validate every filter leaf — `filters` can be a flat `FilterState[]` or a nested `FilterGroupNode` tree — on every method that carries filters. Write the guard defensively, too: body validation checks the shape envelope, not that `params` is present, so a malformed `fetchData` body reaches `authorize`. Dereference optionally and return `false` on shapes you cannot read — a throw inside `authorize` is caught as a `server_error` and answered 500, when a malformed request should be a client error. ```ts // app/api/tables/tickets/route.ts import { type AdapterRequestBody, createAdapterRouteHandler, type FilterGroupNode, type FilterState, flattenFilterNode, isFilterGroupNode, } from '@better-tables/core'; import { getTicketsAdapter } from '@/lib/tickets'; const ALLOWED_COLUMNS = new Set([ 'subject', 'status', 'customer.company', 'createdAt', // …every column id this endpoint may read ]); /** Leaf column ids from either filter shape: flat array or nested group tree. */ function collectFilterColumnIds(filters?: FilterState[] | FilterGroupNode): string[] { if (!filters) return []; const leaves = isFilterGroupNode(filters) ? flattenFilterNode(filters) : filters; return leaves.map((filter) => filter.columnId); } /** Every column id a request references — facet targets AND their active filters. */ function collectColumnIds(body: AdapterRequestBody): string[] { switch (body.method) { case 'fetchData': // Optional access throughout: the handler validates the shape envelope // only, so `params` can be absent here. A throw would surface as a 500. return [ ...collectFilterColumnIds(body.params?.filters), ...(body.params?.sorting ?? []).map((sort) => sort.columnId), ...(body.params?.columns ?? []), ]; case 'getFilterOptions': case 'getFacetedValues': case 'getMinMaxValues': // The facet target — plus the active filters the facet is scoped by. return [body.columnId, ...collectFilterColumnIds(body.params?.filters)]; case 'describeColumns': return []; // no column ids — pinned to a table in constrainRequest case 'resolveCellWriteTarget': return [body.columnId]; case 'cellEdit': return [body.field]; // 403 anyway without `writes`, but keep the list total } } export const POST = createAdapterRouteHandler(() => getTicketsAdapter(), { authorize: async (_request, body) => { // Fail closed on shapes the handler accepts but this guard cannot read: // `{ method: 'fetchData' }` with no `params` passes body validation. if (body.method === 'fetchData' && !body.params) return false; const ids = collectColumnIds(body); if (!ids.every((id) => ALLOWED_COLUMNS.has(id))) { return new Response( JSON.stringify({ ok: false, error: 'Column not allowed on this endpoint.', kind: 'bad_request' }), { status: 403, headers: { 'content-type': 'application/json' } } ); } return true; }, constrainRequest: (body) => { if (body.method === 'fetchData') { return { ...body, params: { ...body.params, primaryTable: 'tickets' } }; } if (body.method === 'describeColumns' || body.method === 'resolveCellWriteTarget') { return { ...body, table: 'tickets' }; } return body; }, onError: (error) => console.error('[api/tables/tickets]', error), }); ``` ```ts // client import { httpAdapter } from '@better-tables/core'; const adapter = httpAdapter({ url: '/api/tables/tickets' }); ``` A bare `createAdapterRouteHandler(adapter)` exposes the entire read surface (`fetchData`, facets, filter options, min/max, schema/write-target metadata). Always allow-list every referenced column id — facet targets **and** filter leaves — on every method, pin `primaryTable` / `table` where the protocol supports it, and add real `authorize` for multi-table adapters. Reference guard used by the marketing demo: `apps/marketing/src/lib/demo/support/tickets-adapter-guard.ts`. ## Wire contract [#wire-contract] | Concern | Behavior | | ----------- | --------------------------------------------------------------------------------------------------- | | Maps | `getFacetedValues` and `fetchData.faceted` travel as `[value, count][]`; the client rebuilds `Map`s | | Dates | JSON serializes `Date` values as ISO strings; server emitters parse ISO | | AbortSignal | Never serialized; client uses it to cancel `fetch` | | Mutations | Not proxied by default — `cellEdit` requires double opt-in (below) | ## Writes (opt-in) [#writes-opt-in] Only for genuinely separated frontend/backend deployments. In a monolith, use `tables.cellEditAction(def)` through your framework's server boundary instead. Writes are **disabled by default** and require opting in on **both** sides: ```ts // server export const POST = createAdapterRouteHandler(() => getTicketsAdapter(), { authorize: async (request) => isAllowedToEdit(request), writes: { columns: ['subject', 'status', 'customer.company'] }, }); // client const adapter = httpAdapter({ url: '/api/tables/tickets', writes: true }); ``` Rules: * **Double opt-in** — without handler `writes`, `cellEdit` → 403; without client `writes: true`, the client stays read-only * **Fail closed** — server re-resolves column → table/field via schema introspection and coerces values * **`{ columns }` narrowing is recommended** — `writes: true` alone allows any schema-writable column * **`authorize` runs before the write** ## Status semantics [#status-semantics] | Situation | HTTP | Envelope | | ------------------------------- | --------- | ----------------------------------------------------------------------- | | Success | 200 | `{ ok: true, result }` | | Malformed / authorize false | 400 / 403 | `{ ok: false, error, kind: 'bad_request' }` | | `cellEdit` with writes disabled | 403 | `{ ok: false, error, kind: 'forbidden' }` | | Unknown/unwritable column | 400 | `{ ok: false, error, kind: 'bad_request' }` | | Adapter / DB throw | 500 | `{ ok: false, error: 'Adapter request failed.', kind: 'server_error' }` | Server error messages are generic on purpose — use `onError` for the real exception. Do not echo SQL fragments to clients. Demo route: `apps/marketing/src/app/api/tables/tickets/route.ts`. # Adapters (/docs/adapters) Adapters implement the core contract: describe schema, fetch filtered/sorted/paginated rows, facets, and optional writes. ```ts import { betterTables } from '@better-tables/core'; import { drizzleAdapter } from '@better-tables/adapters-drizzle'; export const tables = betterTables({ database: drizzleAdapter(db), }); ``` For building a custom ORM adapter, start from the [Custom adapters guide](/docs/adapters/custom) and `@better-tables/adapters-toolkit`. # Auto Columns (/docs/columns/auto-columns) The adapter already knows every column's name, type, nullability, and enum values (`adapter.describeColumns`, implemented by the Drizzle adapter and proxied by `httpAdapter`). Two forms consume it: ```ts // Fully inferred: zero column boilerplate. export const usersTable = tables.define('users'); // or: defineTable()('users') // Inferred base + explicit overrides — explicit wins by id: export const ticketsTable = defineTable()('tickets', (t) => ({ columns: [...t.auto(), t.text('subject').editable()], })); ``` ## Resolution rules [#resolution-rules] Resolution is **lazy, at mount**: `` calls core's `resolveTableColumns(def, adapter)` before creating the table store. The curried `defineTable` form stays runtime-adapter-free and RSC-safe. * **Explicit wins by id**; order is explicit first, then inferred in stable schema order. * **Enrichment is independent of `t.auto()`**: an explicit `t.option('status')` with no `.options()` gets choices from the schema enum. Declared config always wins; enrichment only fills gaps. A declared type contradicting the schema type logs a dev warn. * **`t.auto()`'s only job is column-set inclusion** ("and the rest of the table's columns"). Auto-inclusion is never the default — declaring a subset is deliberate. * **Inferred columns are read-only** (`sortable` / `filterable` / `hideable`, never `editable`) until explicitly overridden. * Fully-declared tables skip resolution entirely. An adapter without `describeColumns` degrades to the declared list with a dev warn. Own-table columns only — relations stay explicit. ## Reference [#reference] See `apps/marketing/src/lib/demo/support/columns.tsx` and `packages/core/src/builders/path-builders.ts`. # Column Definition (/docs/columns) 0.6 columns are defined per table with `defineTable()('tableName', (t) => ({ columns: [...] }))`. Path methods (`t.text('customer.company')`) derive accessors and display names from the schema catalog the adapter carries via `$types`. ```ts export const ticketsTable = defineTable()('tickets', (t) => ({ columns: [ t.text('subject').searchable().filterable().sortable(), t.number('reopenCount').displayName('Reopens').filterable().sortable(), t.option('status') .options([ { value: 'open', label: 'Open' }, { value: 'resolved', label: 'Resolved' }, ]) .filterable() .sortable(), t.text('customer.company').displayName('Customer').searchable({ includeNull: true }), t.date('createdAt').displayName('Opened').dateTime({ timeZone: 'America/New_York' }), t.computed('summary', (row) => `${row.subject} (${row.status})`).displayName('Summary'), ], })); export type TicketRow = typeof ticketsTable.$infer.Row; export const ticketColumns = ticketsTable.columns; ``` ## Method form [#method-form] You can also define via the factory: ```ts export const usersTable = tables.define('users', (t) => ({ columns: [t.text('email').searchable()], })); ``` ## Escape hatches [#escape-hatches] * `t.computed(id, accessor)` — client-only derived display values (`type: 'custom'`). Defaults to **not** filterable/sortable — there is no DB column behind the id. * `t.custom()` — full fluent builder when you need total control (same honest defaults) ## Derived columns (server aggregates) [#derived-columns-server-aggregates] For relation counts and aggregates the adapter can render, filter, and sort in SQL, use declarative builders — not hand-rolled `computedFields`: ```ts t.count('posts').displayName('Posts'), // → column id `postsCount`, type number, filterable + sortable t.aggregate('revenueTotal', { relation: 'orders', fn: 'sum', field: 'amount' }), ``` Specs travel on `FetchDataParams.derived` (attached by instance `fetchData` / `useTableData`). Drizzle lowers them to correlated subqueries; `memoryAdapter` evaluates nested arrays. Adapters advertise support via `meta.capabilities.aggregates`. Acceptance one-liner: **a users table with `t.count('posts')` you can render, filter (`greaterThan` 5), and sort**. ## Standalone columns (no schema) [#standalone-columns-no-schema] When there is no adapter schema to derive from — arbitrary row shapes, hand-tuned accessors — build columns directly with the builder classes and collect them with `defineColumns`; `defineTableRow()` turns them into a table definition the factory and `` accept: ```ts import { defineColumns, defineTableRow, NumberColumnBuilder, TextColumnBuilder, } from '@better-tables/core'; interface Ticket { subject: string; reopenCount: number; } const columns = defineColumns()([ new TextColumnBuilder().id('subject').displayName('Subject') .accessor((t) => t.subject).searchable().build(), new NumberColumnBuilder().id('reopenCount').displayName('Reopens') .accessor((t) => t.reopenCount).filterable().build(), ]); export const ticketsTable = defineTableRow()('tickets', () => ({ columns })); ``` `defineColumns` exists because a heterogeneous array of differently-typed columns isn't directly assignable to the table boundary — it type-checks each column against its own value type, then erases to the boundary shape in one audited place. Pair with [`memoryAdapter`](/docs/adapters/custom#memory-adapter) to run the full pipeline over in-memory rows. ## Modifiers on every column type [#modifiers-on-every-column-type] | Modifier | Purpose | | -------------------------------------------------------- | -------------------------------------------------------------------- | | `.displayName(label)` | Header / filter label | | `.icon(component)` | Icon shown next to the header and in filter menus | | `.filterable(true?, filterConfig?)` | Enable filter UI + adapter filter — see [Filtering](/docs/filtering) | | `.sortable()` | Enable sorting | | `.editable(config?)` | Inline cell editing (see [Inline editing](/docs/inline-editing)) | | `.validation(rules)` | Rules checked before any cell edit saves | | `.hideable()` | Allow hiding via the column visibility menu | | `.defaultVisible(false)` | Start hidden (user can reveal when `.hideable()`) | | `.width(px, min?, max?)` / `.minWidth()` / `.maxWidth()` | Column sizing | | `.resizable()` | Drag-resize handle | | `.align('left' \| 'center' \| 'right')` | Cell alignment | | `.cellRenderer(fn)` / `.headerRenderer(fn)` | Custom React rendering | | `.nullable()` / `.normalizeEmptyToNull()` | Null semantics for filters and edits | | `.meta(record)` | Arbitrary metadata passthrough | ## Per-type modifiers (complete) [#per-type-modifiers-complete] | Type | Modifiers | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `t.text` | `.searchable({ debounce?, includeNull?, validation? })` — enables common text operators (`contains`…`isNotEmpty`) with debounce/null handling (not `isNull`/`isNotNull`; this is filter sugar, not a separate global search); `.textOperators([...])`; `.asUrl()`; `.asEmail()`; `.asPhone()`; `.truncate(length?)`; `.transform('uppercase' \| 'lowercase' \| 'capitalize')` | | `t.number` | `.range(min, max, { step? })`; `.currency({ currency?, locale?, minimumFractionDigits?, … })`; `.percentage(options?)`; `.precision(digits)`; `.compact()`; `.format({ locale?, minimumFractionDigits?, … })`; `.numberOperators([...])` | | `t.date` | `.format(pattern)`; `.dateOnly()`; `.dateTime({ timeZone?, … })`; `.timeOnly()`; `.relative()`; `.chronological()`; `.dateRange()`; `.dateOperators([...])` | | `t.option` | `.options([...])` (schema enums fill this automatically — see [Auto columns](/docs/columns/auto-columns)); `.optionOperators([...])`; `.showBadges(options?)` | | `t.multiOption` | `.options([...])`; `.multiOptionOperators([...])`; `.displayFormat({...})`; `.showBadges(options?)`; `.validate({...})` | | `t.boolean` | `.booleanOperators([...])`; `.booleanFilter(options?)`; `.displayFormat(options?)`; `.yesNo()`; `.activeInactive()`; `.enabledDisabled()`; `.checkbox()`; `.switch()`; `.iconDisplay(options?)` | Option entries (`FilterOption`) accept `value`, `label`, and optional `color`, `icon`, and `count`. ## Column visibility and ordering [#column-visibility-and-ordering] Users can hide `.hideable()` columns from the column visibility menu (on by default; disable with `features={{ columnVisibility: false }}`). Header drag-and-drop reordering is opt-in via `features={{ columnReordering: true }}`. Control the initial state from ``: ```tsx ``` Both visibility and order can be synced to the URL (`columnVisibility` / `columnOrder` flags) so shared links reproduce the exact view — see [URL State](/docs/url-state). ## Next [#next] * [Auto columns](/docs/columns/auto-columns) — schema-driven inference * [Filtering](/docs/filtering) — operator reference and per-column filter config # Examples (/docs/examples) These routes run real Better Tables stacks against an in-memory SQLite demo database. Source of truth for the demo schema/columns/fetch helpers: `apps/marketing/src/lib/demo/support/` # @better-tables/adapters-drizzle (/docs/packages/adapters-drizzle) ```bash bun add @better-tables/adapters-drizzle drizzle-orm ``` ```bash npm install @better-tables/adapters-drizzle drizzle-orm ``` ```bash pnpm add @better-tables/adapters-drizzle drizzle-orm ``` `drizzle-orm` and your driver (`better-sqlite3`, `postgres`, `mysql2`) are peer dependencies — install the ones you use. ## Capabilities [#capabilities] * Schema, driver, and relationship auto-detection from your Drizzle instance * Automatic JOINs from relationship dot-paths (`customer.company`), including array foreign keys * Filters (full operator set incl. AND/OR trees), sorting, pagination, global counts * Facets: `getFacetedValues` / `getMinMaxValues` with self-exclusion * Schema introspection (`describeColumns`) for [auto columns](/docs/columns/auto-columns) * Cell writes via `updateRecord` + `resolveCellWriteTarget` for [inline editing](/docs/inline-editing) * Tuning: query caching, join budget, large-IN batching, query logging, timing warnings Full guide with every factory option: [Drizzle adapter](/docs/adapters/drizzle). # @better-tables/adapters-toolkit (/docs/packages/adapters-toolkit) ```bash bun add @better-tables/adapters-toolkit ``` ```bash npm install @better-tables/adapters-toolkit ``` ```bash pnpm add @better-tables/adapters-toolkit ``` Shared, ORM-agnostic primitives for building adapters: filter-operator routing (`FilterRouter` / `PredicateEmitter`), flat-to-nested data transformation (`DataTransformer`), primary-table resolution (`PrimaryTableResolver`), relationship-path aliasing, schema-introspection ports, and safe SQL identifier escaping. Use it when implementing a new adapter (Prisma, Kysely, raw SQL, …) instead of forking the Drizzle adapter — `@better-tables/adapters-drizzle` itself composes these primitives. Full walkthrough of the contract and every export: [Custom adapters](/docs/adapters/custom). # @better-tables/cli (/docs/packages/cli) ```bash bunx @better-tables/cli init # copy the UI into your project bunx @better-tables/cli docs # open this site ``` The published package is `@better-tables/cli`; its binary name is `better-tables`. After a global install (`bun add -g @better-tables/cli`), you can run `better-tables init` directly. ## Commands [#commands] | Command | Purpose | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `init` | Verify shadcn setup, install required shadcn primitives and packages, copy the Better Tables UI source into your project | | `init --components-path ` | Output directory relative to your components dir (default `better-tables-ui`) | | `init -y, --yes` | Skip confirmation prompts | | `init --skip-shadcn` | Skip the shadcn component check/install | | `init --cwd ` | Run against a different project directory | | `docs [main\|core\|ui\|drizzle]` | Open the docs site (optionally a specific section) | Full walkthrough: [UI & CLI](/docs/ui-and-cli). # @better-tables/core (/docs/packages/core) Public npm package. No React, no database driver. ```bash bun add @better-tables/core ``` ```bash npm install @better-tables/core ``` ```bash pnpm add @better-tables/core ``` ## Exports by area [#exports-by-area] | Area | Exports | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Factory | `betterTables`, `defineTable` — the flagship entry points ([Quick Start](/docs/quick-start)) | | Path builders | The `t` callback surface: `t.text` / `t.number` / `t.date` / `t.option` / `t.multiOption` / `t.boolean` / `t.count` / `t.aggregate` / `t.computed` / `t.custom` / `t.auto` ([Columns](/docs/columns)) | | Column resolution | `resolveTableColumns` — auto-column resolution at mount ([Auto columns](/docs/columns/auto-columns)) | | Editing | `cellEditAction` on the factory, `ValidationRule`, `EditableConfig` ([Inline editing](/docs/inline-editing)) | | Managers | `TableStateManager`, `FilterManager`, `PaginationManager`, `SortingManager`, `SelectionManager`, `VirtualizationManager` | | Stores & URL | `getTableStore`, `UrlSyncAdapter`, `UrlSyncConfig`, `serializeFiltersToURL` / `deserializeFiltersFromURL`, `serializeTableStateToUrl` / `deserializeTableStateFromUrl` ([URL State](/docs/url-state)) | | HTTP transport | `httpAdapter`, `createAdapterRouteHandler` ([HTTP adapter](/docs/adapters/http)) | | Adapter contract | `TableAdapter`, `FetchDataParams`, `FetchDataResult`, `FacetQueryParams`, `AdapterFeatures`, `InferredColumnSpec` ([Custom adapters](/docs/adapters/custom)) | | Memory adapter | `memoryAdapter(rows, options?)` — the full pipeline over an in-memory array ([Custom adapters](/docs/adapters/custom#memory-adapter)) | | Standalone columns | `defineTableRow`, `defineColumns`, and the builder classes (`TextColumnBuilder`, …) for hand-built columns ([Columns](/docs/columns#standalone-columns-no-schema)) | | Filter types | `FilterState`, `FilterGroupNode`, `FilterOperator` + per-type operator unions, `FilterConfig` ([Filtering](/docs/filtering)) | # Packages (/docs/packages) | Package | Published? | Install | | --------------------------------- | ----------- | ----------------------------------------------------------- | | `@better-tables/core` | npm | `bun add @better-tables/core` | | `@better-tables/adapters-drizzle` | npm | `bun add @better-tables/adapters-drizzle` | | `@better-tables/adapters-toolkit` | npm | for adapter authors | | `@better-tables/ui` | **private** | `bunx @better-tables/cli init` | | `@better-tables/cli` | npm | `bunx @better-tables/cli` / `bun add -g @better-tables/cli` | # @better-tables/ui (/docs/packages/ui) **Not on npm.** Copy into your project: ```bash bunx @better-tables/cli init ``` ## What you get [#what-you-get] | Directory | Contents | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `components/better-tables-ui/table/` | `BetterTable`, `VirtualizedTable`, `TablePagination`, column visibility toggle, column/sort order lists, editable cells, empty/error states, actions toolbar, header context menu, DnD providers | | `components/better-tables-ui/filters/` | Filter bar, filter dropdown, active filters, faceted filter sidebar, operator select, per-type filter inputs | | `hooks/` | `use-table-data`, `use-table-store`, `use-table-url-sync`, `use-facets`, `use-editable-cells`, `use-filter-validation`, `use-keyboard-navigation`, `use-virtualization`, `use-virtualized-table-data`, `use-column-options`, `use-debounce` | | `lib/` | `cn` utility | The copied files import `@better-tables/core` plus direct runtime deps: `zustand`, `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/modifiers`, and `@dnd-kit/utilities`. `init` offers to install those; keep them in your app `package.json`. ## Learn [#learn] * [UI & CLI](/docs/ui-and-cli) — prerequisites, init flags, why copy instead of npm * [BetterTable props](/docs/better-table) — complete component reference * [URL State](/docs/url-state) — sync setup