Better Tables

Troubleshooting

Common failures — build-time DB crashes, init prerequisites, read-only cells, HTTP 403s, and multi-table SchemaErrors.

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:

let tablesPromise: Promise<SupportTables> | 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 and the reference implementation in apps/marketing/src/lib/demo/support/db.ts.

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.

Bulk actions don't render

The actions toolbar is an opt-in module. 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.

An editable cell renders read-only

Save resolution walks a fixed order (see Inline editing) 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

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.

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.

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).

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.

Filter options never load for an option column

Option choices resolve in order: declared .options() → schema enum (via resolveTableColumns, needs adapter on <BetterTable> 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.

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.

Maps arrive as arrays over HTTP

That's the wire format: getFacetedValues and fetchData.faceted travel as [value, count][] and the httpAdapter client rebuilds Maps. Only raw fetch callers need to handle the arrays themselves — see HTTP adapter.

On this page