Better Tables

Quick Start

Go from empty project to a typed, filterable table in a few steps.

Install packages

bun add @better-tables/core @better-tables/adapters-drizzle drizzle-orm better-sqlite3
bun add -d @types/better-sqlite3
npm install @better-tables/core @better-tables/adapters-drizzle drizzle-orm better-sqlite3
npm install -D @types/better-sqlite3
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):

bunx @better-tables/cli init
npx @better-tables/cli init
pnpm dlx @better-tables/cli init

init also checks for UI runtime deps (zustand, @dnd-kit/*) used by the copied source — see UI & CLI.

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:

// 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<typeof buildTables> | 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<ReturnType<typeof getTables>>;

Define a table (client-safe module)

Keep defineTable in a separate file from the DB factory so client components never pull in the adapter:

// 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<Tables>()('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

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

This mounts the first page of rows. Filter / sort / page controls update client state until you wire a server refetch or URL sync — pass initialPagination so the UI page size matches the fetch:

// 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 (
    <BetterTable
      id="tickets"
      columns={ticketsTable.columns}
      data={rows}
      totalCount={total}
      initialPagination={{
        page: 1,
        limit: 20,
        totalPages: Math.max(1, Math.ceil(total / 20)),
        hasNext: total > 20,
        hasPrev: false,
      }}
    />
  );
}
// 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 <TicketsTable rows={result.data} total={result.total} />;
}

Next

On this page