00open source · mit licensed
Tables that reach your database
A data table is really two problems: the grid your users see, and the queries behind it. Most libraries hand you the grid and leave the SQL to you. Better Tables does both — define your columns once in TypeScript, and filtering, sorting, pagination, even inline edits become real queries against Postgres, MySQL, or SQLite. The UI is source you copy into your repo and own.
01live demo
~1M rows. Real Postgres.
This table queries a Neon Postgres database (users + profiles) through the Drizzle adapter. Filter by joined profile fields, stack sorts, select rows — and watch the readout below show the exact SQL generated for every interaction. (Postgres · Neon)
Name | Email | Age | Role | Status | Has Bio | Role Tags | Posts | Joined | Bio | Website | Location | GitHub | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Jakye Bly | trusting_beverlyn@hotmail.com | 20 | viewer | inactive | Yes | viewer | 0 | September 30th, 2023 | Nam scelerisque, turpis eget ... | - | - | ||
Jacob Sellman | oxymoronic_dario@hotmail.fr | 60 | editor | active | Yes | editor | 0 | November 22nd, 2024 | Nulla non dapibus nibh, id ul... | https://portfolio.io | Beaumont | @hacker | |
Deondra Dinino | mirthful_vila@live.com | 43 | editor | active | Yes | editor | 0 | March 12th, 2025 | Praesent cursus lobortis accu... | - | Abai | @techie | |
Yuli Leggins | mocking_elois@free.fr | 60 | viewer | pending | Yes | viewer | 0 | February 6th, 2023 | Donec pellentesque feugiat lo... | https://example.dev | @builder | ||
Erline Mcgarrigle | mocking_niko@rediffmail.com | 30 | viewer | active | Yes | viewer | 0 | September 5th, 2025 | Maecenas faucibus metus sed i... | https://example.dev | Hammamet | - | |
Leasha Taketa | mocking_regena@gmail.com | 28 | viewer | active | Yes | viewer | 0 | September 19th, 2024 | Nam pellentesque nisi a laore... | - | Kitahiroshima | - | |
Kawika Galyon | moaning_gaylin@hotmail.com | 34 | admin | active | Yes | admin | 0 | August 11th, 2022 | Etiam et viverra felis, in vu... | - | Sido | @techie | |
Jayanthony Sanor | tumultuous_ming@yahoo.co.uk | 41 | contributor | inactive | Yes | contributor | 1 | September 12th, 2024 | Integer ligula nisl, finibus ... | - | Mosrah | - | |
Jenette Madole | mocking_raley@web.de | 27 | viewer | suspended | Yes | viewer | 0 | December 22nd, 2025 | Etiam interdum pellentesque e... | - | @hacker | ||
Yonatan Croley | posthumous_vianca@web.de | 31 | viewer | active | Yes | viewer | 0 | January 13th, 2023 | Sed sit amet est eu tortor ef... | https://example.dev | @builder |
select "users"."id", "users"."name", "users"."email", "users"."age",
"users"."role", "users"."status", "users"."created_at",
"profiles"."id", "profiles"."user_id", "profiles"."bio",
"profiles"."avatar", "profiles"."website", "profiles"."location",
"profiles"."github", (select count(*)
from "posts"
where "posts"."user_id" = "users"."id") as "postsCount"
from "users"
left join "profiles" on "users"."id" = "profiles"."user_id"
limit 10+ 1 more statement (count query)− collapse
select count(distinct "users"."id")
from "users"
left join "profiles" on "users"."id" = "profiles"."user_id"Captured from the drizzle logger during this render. Bound parameters inlined for readability.
02how it works
Columns in. Queries out.
One column definition drives everything. When a user filters or sorts, that action becomes SQL for Postgres, MySQL, or SQLite; the results render straight back into the UI — typed all the way from the database to the cell.
define
Define columns
One builder chain per column. A dotted path like customer.company pulls in a field from a related table — and any typo in that path is a compile error, not a 2 a.m. surprise.
export const tickets = defineTable<Tables>()(
'tickets',
(t) => ({
columns: [
...t.auto(), // inferred from schema
t.text('customer.company') // joined
.searchable()
.editable(),
t.number('reopenCount').filterable(),
t.date('createdAt').sortable(),
],
})
);compile
State becomes SQL
Every filter, sort, and page turns into a real query against your database. The adapter joins only the tables that query actually needs — nothing speculative, nothing over-fetched.
select "tickets"."id", "tickets"."subject",
"customers"."company"
from "tickets"
left join "customers"
on "customers"."id"
= "tickets"."customer_id"
where "customers"."company" like '%acme%'
order by "tickets"."created_at" desc
limit 10 offset 0own
Own the UI
The CLI copies the table and filter components into your repo as source you can edit, already wired to your shadcn/ui theme. No black-box dependency to fight later.
$ npx @better-tables/cli@latest init
# detects your shadcn/ui setup
# installs core + the drizzle adapter
# copies table + filter source into
# your components dir — yours to edit03examples
all examplesLive tables, not screenshots.
Examples run against real databases — Postgres on the homepage when DATABASE_URL is set, in-memory SQLite for the ticket demos. Open one, filter it, and watch the URL carry your state.
- 00Users directoryFilter across joined tables, run bulk actions, and watch the exact SQL appear as you go. Lives on the homepage — Postgres when DATABASE_URL is set, SQLite otherwise.~1M · Postgres / 5k · SQLite
- 01Relationship filteringFilter tickets by fields on related tables — the customer's plan, the assignee's team. A query trail shows every JOIN the adapter builds to answer you.20 tickets · SQLite
- 02Query groupsBuild nested AND/OR filters, read back to you as a plain sentence and saved into a shareable URL — including “is empty” filters.20 tickets · SQLite
- 03Big boardScroll 12,000 rows without the browser breaking a sweat. Only the rows near your viewport are ever rendered, even when row heights vary.12,000 rows · SQLite
- 04FacetsA faceted sidebar whose counts and ranges are computed in SQL and update with every filter you apply — without a facet ever hiding its own options.20 tickets · SQLite
- 05CSV exportExport exactly what you are looking at — the current filtered, sorted view — to CSV or JSON, straight from the toolbar.20 tickets · SQLite
04capabilities
roadmap on githubThe spec sheet.
What ships today — verifiable in the source — and what's next. No adjectives where a fact will do.
Cross-table filtering
shippedDot-path columns like customer.company detect Drizzle relations and generate only the JOINs a query needs.
shipped34 filter operators
shippedAcross text, number, date, option, multi-option, boolean, and JSON groups — null-aware throughout.
shippedEditable cells
shippedType-aware inline editors with optimistic updates and rollback. Editing a joined column writes the related row.
shippedSchema-inferred columns
shippedt.auto() reads your database schema; enum columns become dropdowns with humanized labels, no options list needed.
shippedZero-boilerplate saves
shippedtables.cellEditAction(def) returns a save handler that's ready to export as a Next.js server action.
shippedURL-synced state
shippedFilters, sorting, pagination, column visibility, and column order — compressed, shareable, SSR-hydration safe.
shippedVirtualized rows
shippedScroll 12,000+ rows with variable heights — see the big board example.
shippedMulti-column sort
shippedPriority-ordered and reorderable, with a configurable cap.
shippedTyped end to end
shippedColumn paths, filter values, and row types infer from your schema. A renamed column fails the build, not production.
shippedThree databases
shippedPostgreSQL, MySQL, and SQLite through the Drizzle adapter — plus an HTTP adapter with a ready-made route handler.
shippedVisual query-group builder
plannedThe nested AND/OR data model ships today; the drag-to-build UI is on the roadmap.
plannedCSV export
plannedExport the current filtered view from the toolbar.
plannedMore adapters
plannedREST and in-memory adapters, for tables without a Drizzle schema.
planned05install
Own your tables.
One command copies the table and filter source into your repo — components you edit, not a dependency you fight. Requires an existing shadcn/ui setup; the CLI checks, and installs any missing primitives.