React Data Table on Drizzle with Search, Filters, and Types – TableCraft

Build metadata driven data tables for Drizzle apps with search, filters, sorting, codegen, and multi framework adapters.

TableCraft is a Drizzle table query builder and React data table stack that turns one schema into read endpoints, metadata, generated types, and a typed table UI.

It combines a query engine, a React table package built on TanStack Table and Shadcn UI patterns, a client SDK, a code generation CLI, framework adapters for Hono, Express, Next.js, SvelteKit, and Elysia, plus a cache plugin.

The backend starts with defineTable() and turns one table config into search rules, filters, sorting, joins, pagination, export settings, role checks, tenant scoping, and metadata.

The frontend reads the same metadata, renders columns from schema facts, stores table state in the URL, and can fetch data through generated adapters or the standalone client package.

Features

🧱 Schema driven columns: Read Drizzle tables and build column metadata automatically.

🔎 Global search: Query text fields through one search input.

🗂️ Field filters: Match exact values, ranges, lists, and date windows.

📅 Date presets: Send shortcuts like today or last seven days from metadata aware filters.

↕️ Sorting: Sort one field or multiple fields from the same endpoint.

📄 Pagination modes: Switch between offset pagination and cursor pagination.

📤 Export: Write CSV, JSON, and Excel output from the same table definition.

🧭 OpenAPI output: Build an OpenAPI 3.0 document from table configuration.

👁️ Column visibility: Hide fields at the backend and toggle visibility in the UI.

📏 Column sizing: Resize columns and keep the saved width in local storage.

🧩 Nested rows: Render child tables or custom React content inside expanded rows.

🔐 Access rules: Check roles, permissions, tenant fields, and soft delete columns in the engine.

🧠 Generated metadata: Publish labels, formats, widths, operators, enum options, and capabilities through a _meta endpoint.

🧾 Type generation: Generate row types, filter types, column unions, and typed adapter factories.

Cache plugin: Cache deterministic table queries in memory, Redis, or Upstash Redis.

Use Cases

  • Build an internal admin panel that reads Drizzle models and renders searchable tables fast.
  • Ship a SaaS back office that applies tenant scoping and role based column rules from one config.
  • Publish reporting pages that need exports, aggregations, joins, and saved URL state.
  • Generate typed table clients for React pages that must stay aligned with backend schema changes.

How to Use It

Install the packages

Pick the backend adapter that matches your app. Install the engine, the React table package, the adapter, and the code generation CLI.

Hono

pnpm add @tablecraft/engine @tablecraft/table @tablecraft/adapter-hono
pnpm add -D @tablecraft/codegen

Express

pnpm add @tablecraft/engine @tablecraft/table @tablecraft/adapter-express
pnpm add -D @tablecraft/codegen

Next.js

pnpm add @tablecraft/engine @tablecraft/table @tablecraft/adapter-next
pnpm add -D @tablecraft/codegen

SvelteKit

pnpm add @tablecraft/engine @tablecraft/table @tablecraft/adapter-sveltekit
pnpm add -D @tablecraft/codegen

Elysia

pnpm add @tablecraft/engine @tablecraft/table @tablecraft/adapter-elysia
pnpm add -D @tablecraft/codegen

If you want direct API calls outside the React table, install the client package too.

bun add @tablecraft/client
npm install @tablecraft/client
pnpm add @tablecraft/client
yarn add @tablecraft/client

If you want Excel export in the React table, install exceljs.

bun add exceljs

Define a table

Start with a Drizzle table and describe the behavior you want on the endpoint. This config becomes the contract for query parsing, metadata, and export.

// src/tables/orders.ts
import { defineTable } from "@tablecraft/engine";
import { orders } from "../db/schema";
export const ordersConfig = defineTable(orders)
  .name("orders")
  .autoHide()
  .search("status", "customerEmail")
  .filter("status", "createdAt", "total")
  .sort("-createdAt", "status")
  .pageSize(25, { max: 100 })
  .tenant("orgId")
  .softDelete("deletedAt")
  .exportable("csv", "json")
  .format("total", "currency")
  .format("createdAt", "datetime")
  .align("total", "right")
  .width("status", 140)
  .options("status", [
    { value: "pending", label: "Pending", color: "yellow" },
    { value: "paid", label: "Paid", color: "green" },
    { value: "refunded", label: "Refunded", color: "gray" },
  ])
  .datePresets("createdAt", ["today", "last7days", "thisMonth"])
  .toConfig();

You can push the same builder into joins, computed columns, static filters, grouped results, recursive trees, and subqueries.

// src/tables/customers.ts
import { eq, sql } from "drizzle-orm";
import { defineTable } from "@tablecraft/engine";
import { customers, invoices } from "../db/schema";
export const customersConfig = defineTable(customers)
  .searchAll()
  .sortable("name", "createdAt")
  .computed("fullName", sql`${customers.firstName} || ' ' || ${customers.lastName}`)
  .subquery("invoiceCount", invoices, "count", eq(invoices.customerId, customers.id))
  .staticFilter("archived", "eq", false)
  .toConfig();

Mount the API in your framework

All adapters take the database handle, the Drizzle schema, and a config map. Each route exposes table data endpoints plus metadata endpoints for the frontend.

Hono

// src/index.ts
import { Hono } from "hono";
import { createHonoApp } from "@tablecraft/adapter-hono";
import { db } from "./db";
import * as schema from "./db/schema";
import { ordersConfig } from "./tables/orders";
const app = new Hono();
app.route(
  "/api/engine",
  createHonoApp({
    db,
    schema,
    configs: { orders: ordersConfig },
    getContext: async (c) => {
      const auth = c.get("auth");
      return {
        tenantId: auth.orgId,
        user: {
          id: auth.userId,
          roles: auth.roles,
          permissions: auth.permissions,
        },
      };
    },
  }),
);
export default app;

Express

// src/server.ts
import express from "express";
import { createExpressMiddleware } from "@tablecraft/adapter-express";
import { db } from "./db";
import * as schema from "./db/schema";
import { ordersConfig } from "./tables/orders";
const app = express();
app.use(
  "/api/engine",
  createExpressMiddleware({
    db,
    schema,
    configs: { orders: ordersConfig },
    getContext: async (req) => {
      const auth = req.user as {
        id: string;
        orgId: string;
        roles: string[];
        permissions: string[];
      };
      return {
        tenantId: auth.orgId,
        user: {
          id: auth.id,
          roles: auth.roles,
          permissions: auth.permissions,
        },
      };
    },
  }),
);
app.listen(3000);

Next.js

// app/api/engine/[table]/route.ts
import { createNextHandler } from "@tablecraft/adapter-next";
import { db } from "@/db";
import * as schema from "@/db/schema";
import { ordersConfig } from "@/tables/orders";
const handler = createNextHandler({
  db,
  schema,
  configs: { orders: ordersConfig },
  getContext: async () => {
    const session = await getSession();
    return {
      tenantId: session.user.orgId,
      user: {
        id: session.user.id,
        roles: session.user.roles,
        permissions: session.user.permissions,
      },
    };
  },
});
export const GET = handler;

SvelteKit

// src/hooks.server.ts
import type { Handle } from "@sveltejs/kit";
import { sequence } from "@sveltejs/kit/hooks";
import { createSvelteKitHandle } from "@tablecraft/adapter-sveltekit";
import { db } from "./db";
import * as schema from "./db/schema";
import { ordersConfig } from "./tables/orders";
const authHandle: Handle = async ({ event, resolve }) => {
  event.locals.user = {
    id: "user_1",
    orgId: "org_1",
    roles: ["admin"],
    permissions: ["read:orders"],
  };
  return resolve(event);
};
const tablecraftHandle = createSvelteKitHandle({
  db,
  schema,
  configs: { orders: ordersConfig },
  prefix: "/api/engine",
  getContext: async (event) => ({
    tenantId: event.locals.user.orgId,
    user: {
      id: event.locals.user.id,
      roles: event.locals.user.roles,
      permissions: event.locals.user.permissions,
    },
  }),
});
export const handle: Handle = sequence(authHandle, tablecraftHandle);

Elysia

// src/index.ts
import { Elysia } from "elysia";
import { createElysiaPlugin } from "@tablecraft/adapter-elysia";
import { db } from "./db";
import * as schema from "./db/schema";
import { ordersConfig } from "./tables/orders";
new Elysia()
  .use(
    createElysiaPlugin({
      db,
      schema,
      configs: { orders: ordersConfig },
      prefix: "/api/engine",
      getContext: async (context) => {
        const auth = context.store.auth;
        return {
          tenantId: auth.orgId,
          user: {
            id: auth.userId,
            roles: auth.roles,
            permissions: auth.permissions,
          },
        };
      },
    }),
  )
  .listen(3000);

Add the table styles

The React package uses Tailwind CSS v4 and ships its own stylesheet for table interactions. Import the stylesheet and point Tailwind at the package source.

/* src/index.css */
@import "tailwindcss";
@import "@tablecraft/table/styles.css";
@source "../node_modules/@tablecraft/table/src";

The @source path depends on where your CSS file lives.

/* src/styles/global.css */
@import "tailwindcss";
@import "@tablecraft/table/styles.css";
@source "../../node_modules/@tablecraft/table/src";
/* apps/web/src/index.css */
@import "tailwindcss";
@import "@tablecraft/table/styles.css";
@import "tw-animate-css";
@source "../../../node_modules/@tablecraft/table/src";

Generate typed adapters and row types

Run the code generation CLI after the backend starts. The CLI reads table metadata and writes TypeScript files for each table.

npx @tablecraft/codegen --url http://localhost:3000/api/engine --out ./src/generated

The CLI can target selected tables and attach auth headers.

npx @tablecraft/codegen \
  --url https://api.example.com/engine \
  --out ./src/generated \
  --tables orders customers \
  -H "Authorization: Bearer token" \
  -H "x-tenant-id: acme"

Generated output usually includes row interfaces, filter interfaces, column unions, adapter factories, and an index file.

Render the React data table

Use the generated adapter for the shortest path. This keeps the table typed from the data row down to hidden columns and default column order.

// src/pages/orders-page.tsx
import { DataTable, defaultColumnOrder, hiddenColumns } from "@tablecraft/table";
import {
  createOrdersAdapter,
  type OrdersColumn,
  type OrdersRow,
} from "../generated";
export function OrdersPage() {
  const adapter = createOrdersAdapter({
    baseUrl: "/api/engine",
  });
  return (
    <DataTable<OrdersRow>
      adapter={adapter}
      hiddenColumns={hiddenColumns<OrdersColumn>(["id", "tenantId"])}
      defaultColumnOrder={defaultColumnOrder<OrdersColumn>([
        "status",
        "customerEmail",
        "total",
        "createdAt",
      ])}
      config={{
        enableSearch: true,
        enableExport: true,
        enableColumnResizing: true,
        enableRowSelection: true,
        enableDateFilter: true,
        enableColumnVisibility: true,
        defaultPageSize: 25,
        pageSizeOptions: [10, 25, 50, 100],
      }}
    />
  );
}

System columns stay pinned by the component. select stays first and __actions stays last, so your custom order array should only list data columns.

Render nested rows

Expanded rows work well for master detail screens. You can place another DataTable inside the expanded area and strip the outer border from the child table.

import { DataTable } from "@tablecraft/table";
function OrdersTable({ adapter }: { adapter: unknown }) {
  return (
    <DataTable
      adapter={adapter}
      renderSubRow={({ row }) => <OrderItemsTable orderId={row.id} />}
      config={{
        defaultExpanded: false,
      }}
    />
  );
}
function OrderItemsTable({ orderId }: { orderId: number }) {
  const adapter = createOrderItemsAdapter({
    baseUrl: "/api/engine",
    headers: { "x-order-id": String(orderId) },
  });
  return (
    <DataTable
      adapter={adapter}
      config={{
        removeOuterBorder: true,
      }}
    />
  );
}

Use the client SDK in React or plain JavaScript

The client package works even if you do not use the React table. It can query rows, fetch metadata, build URLs, count rows, and export data.

// src/lib/tablecraft.ts
import { createClient } from "@tablecraft/client";
export const tc = createClient({
  baseUrl: "/api/engine",
  headers: async () => {
    const token = await getAuthToken();
    return {
      Authorization: `Bearer ${token}`,
    };
  },
});
// direct query
const orders = tc.table("orders");
const result = await orders.query({
  page: 1,
  pageSize: 25,
  sort: ["-createdAt", "status"],
  search: "paid",
  filters: {
    total: { operator: "gte", value: 100 },
    status: { operator: "in", value: ["paid", "refunded"] },
  },
  select: ["id", "status", "total", "createdAt"],
  distinct: false,
  includeDeleted: false,
});
const metadata = await orders.meta();
const total = await orders.count({ filters: { status: "paid" } });
const csv = await orders.export("csv", { filters: { status: "paid" } });
const url = orders.buildUrl({ page: 2, pageSize: 50, sort: "-createdAt" });

If you want React state management around the client, use the hooks package path.

import { useTableMeta, useTableQuery } from "@tablecraft/client/react";
import { tc } from "../lib/tablecraft";
export function OrdersSummary() {
  const client = tc.table("orders");
  const { metadata, loading: metaLoading } = useTableMeta(client);
  const {
    data,
    meta,
    loading,
    setPage,
    setPageSize,
    setSort,
    setFilter,
    removeFilter,
    setSearch,
    refresh,
  } = useTableQuery(client, {
    page: 1,
    pageSize: 25,
    sort: ["-createdAt"],
  });
  if (metaLoading || loading) return <div>Loading...</div>;
  return (
    <section>
      <button onClick={() => setSearch("paid")}>Paid</button>
      <button onClick={() => setFilter("status", "refunded")}>Refunded</button>
      <button onClick={() => removeFilter("status")}>Clear status</button>
      <button onClick={() => setSort("-createdAt")}>Newest</button>
      <button onClick={() => setPageSize(50)}>50 rows</button>
      <button onClick={() => setPage(meta.page + 1)}>Next page</button>
      <button onClick={refresh}>Refresh</button>
      <div>{metadata?.columns.length} columns</div>
      <div>{data.length} rows loaded</div>
    </section>
  );
}

The core client also fits Vue, Svelte, and plain JavaScript apps. React gets built in hooks today, while Vue and Svelte still rely on the core client layer.

Publish metadata and OpenAPI output

Metadata drives dynamic table rendering. OpenAPI turns the same table config into API documentation.

import { generateOpenApiSpec } from "@tablecraft/engine";
import { ordersConfig } from "./tables/orders";
const spec = generateOpenApiSpec(ordersConfig);
console.log(JSON.stringify(spec, null, 2));
// app/api/docs/[table]/route.ts
import { generateOpenApiSpec } from "@tablecraft/engine";
import * as configs from "@/tables";
export async function GET(
  request: Request,
  { params }: { params: Promise<{ table: string }> },
) {
  const { table } = await params;
  const config = configs[table as keyof typeof configs];
  if (!config) {
    return Response.json({ error: "Table not found" }, { status: 404 });
  }
  return Response.json(generateOpenApiSpec(config));
}

For frontend driven tables, read _meta and use the column facts directly.

const meta = await fetch("/api/engine/orders/_meta").then((response) =>
  response.json(),
);

Think about large tables early

Offset pagination fits small and medium datasets where page jumping matters. Cursor pagination fits large datasets and high concurrency because the engine can continue from indexed sort values.

If you sort by several fields, the cursor stores every sort value from the last row. That keeps later pages in order even when many rows share the same primary sort value.

Cache can help too. Table queries stay deterministic because filters, sort order, and page state sit in query parameters.

import { cachePlugin, memoryCache } from "@tablecraft/plugin-cache";
import { createTableEngine } from "@tablecraft/engine";
const engine = createTableEngine({
  db,
  schema,
  config: ordersConfig,
  plugins: [
    cachePlugin({
      adapter: memoryCache({ ttl: 60_000 }),
    }),
  ],
});

API Reference

defineTable() builder methods

MethodDescription
defineTable(table, options?)Start a builder from a Drizzle table and auto read its base columns.
.name(name)Set the table name used in config and generated endpoints.
.hide(...columns)Remove fields from API selection and frontend metadata.
.show(...columns)Mark fields as visible after broader hiding rules.
.only(...columns)Keep only the listed fields visible.
.autoHide()Hide common secret style fields such as password or token columns.
.inspectSensitive()Return the fields that the auto hide logic would match.
.transform(column, fn)Change a field after the database read completes.
.search(...columns)Register searchable fields for search queries.
.searchAll()Search all detected text columns.
.filter(...columns)Register filterable fields.
.staticFilter(field, operator, value)Apply a permanent filter rule in the config.
.where(condition)Add a backend condition that can read context values.
.whereOr(...conditions)Add an OR group of backend conditions.
.sort(...columns)Set the default sort order. Prefix a field with - for descending order.
.sortable(...columns)Limit which fields can be sorted from the request.
.noSort()Disable sorting on the table.
.pageSize(size, options)Set the default page size and max page size.
.paginationMode(mode)Pick offset mode or cursor mode.
.join(table, options)Join another table into the current query.
.include(table, options)Fetch related rows in a separate query and attach them as nested data.
.computed(name, sqlExpression)Add a virtual SQL column.
.subquery(alias, table, type, filter?)Add a correlated subquery with count, exists, or first output.
.groupBy(...columns)Group rows by one or more fields.
.aggregate(name, fn, field)Add aggregate output such as sum or count.
.recursive(options)Build recursive tree queries from parent child records.
.tenant(field?)Scope reads by context.tenantId.
.softDelete(field?)Filter deleted rows by a delete marker column.
.access({ roles, permissions })Attach role and permission rules to the table.
.exportable(...formats)Register export formats such as CSV or JSON.
.format(column, format)Publish a display format such as currency or date in metadata.
.align(column, align)Publish alignment hints in metadata.
.width(column, width)Publish column width in metadata.
.options(column, values)Publish enum style filter options in metadata.
.datePresets(column, presets)Publish named date shortcuts in metadata.
.columnMeta(column, meta)Override metadata for base, joined, computed, or raw columns.
.rawSelect(name, sql, meta)Add a raw SQL field and describe it for the frontend.
.visibleTo(column, roles)Attach role based visibility to a column.
.roleVisibility(role, columns)Publish role specific visible column rules.
.toConfig()Freeze the builder into a config object for the engine or adapters.

DataTable props

PropDescription
adapterData adapter instance for row queries.
columnsManual column definitions when you do not want metadata driven columns.
renderersCustom cell renderers keyed by column name.
configTable behavior flags and page defaults.
exportConfigExport file and column settings.
idFieldRow key field. id is the default.
onRowClickRow click callback.
hiddenColumnsUI hidden fields that still stay in the fetched data.
defaultColumnOrderFirst load order for data columns.
renderSubRowExpanded row renderer for nested content.
getRowCanExpandPer row expansion rule.
startToolbarContentCustom content before built in toolbar controls.
toolbarContentCustom content after built in toolbar controls.
renderToolbarFull toolbar renderer override.
classNameClass name for the outer wrapper.
pageSizeOptionsPage size choices shown in the UI.

DataTable config fields

Config fieldDescription
enableSearchShow the search UI.
enableExportShow export actions.
enableColumnResizingTurn on drag resizing.
enableRowSelectionTurn on row selection.
enableDateFilterShow date filter controls.
enableColumnVisibilityShow column visibility controls.
defaultPageSizeSet the initial page size.
pageSizeOptionsSet available page sizes.
removeOuterBorderRemove the outer card border for nested tables.
defaultExpandedStart rows in an expanded state.

createClient() options

OptionDescription
baseUrlBase path for the TableCraft API.
fetchCustom fetch implementation.
axiosAxios instance. This path takes precedence over fetch when both exist.
headersStatic or async default headers.

Client table methods

MethodDescription
tc.table<T>(name)Return a typed table client.
table.query(params?)Fetch rows, pagination meta, and aggregate values.
table.meta()Fetch table metadata.
table.count(params?)Fetch a count for the current filter set.
table.export(format, params?)Export filtered rows as CSV or JSON text.
table.buildUrl(params?)Build the request URL from query params.

table.query() parameters

ParameterDescription
pageOne based page number for offset pagination.
pageSizeRows per page.
cursorCursor string for cursor pagination.
sortOne sort field or an array of sort fields.
filtersField filters with scalar values or operator objects.
searchGlobal search string.
selectSelected field list.
distinctReturn distinct rows only.
includeDeletedInclude soft deleted rows in the result.

Filter operators

OperatorDescription
eqEqual.
neqNot equal.
gtGreater than.
gteGreater than or equal.
ltLess than.
lteLess than or equal.
betweenRange between two values.
inMatch one value from a list.
notInExclude values from a list.
containsMatch a substring.
startsWithMatch the start of a string.
endsWithMatch the end of a string.

Metadata shape

FieldDescription
nameTable name.
columnsColumn metadata array.
capabilities.searchSearch support flag.
capabilities.searchFieldsSearchable field list.
capabilities.exportExport support flag.
capabilities.exportFormatsSupported export formats.
capabilities.pagination.enabledPagination support flag.
capabilities.pagination.defaultPageSizeDefault page size.
capabilities.pagination.maxPageSizeMax page size.
capabilities.pagination.cursorCursor pagination support flag.
capabilities.sort.enabledSort support flag.
capabilities.sort.defaultSortDefault sort rules.
capabilities.groupByGroup by support flag.
capabilities.groupByFieldsGroupable field list.
capabilities.recursiveRecursive query support flag.
filtersFilter metadata array.
aggregationsAggregate metadata array.
includesIncluded relation metadata array.
staticFiltersPermanent filter descriptions.

Column metadata fields

FieldDescription
nameColumn key.
typePrimitive type such as string, number, boolean, or date.
labelDisplay label.
hiddenHidden field flag.
sortableSort support flag.
filterableFilter support flag.
computedComputed field flag.
sourceSource kind such as base, join, computed, or subquery.
joinTableJoined table name for join fields.
formatDisplay format hint.
alignAlignment hint.
widthSuggested width.
minWidthSuggested minimum width.
maxWidthSuggested maximum width.
optionsEnum style value list for filters.
datePresetsNamed date shortcuts.
operatorsSupported filter operators for the field.

React hooks

HookReturn values
useTableQuery(client, initialParams?)data, meta, aggregations, loading, error, params, setParams, setPage, setPageSize, setSort, setFilter, removeFilter, setSearch, refresh.
useTableMeta(client)metadata, loading, error.

Limits and edge cases

TopicDetail
first subquery typeWorks only on PostgreSQL because it relies on row_to_json().
Vue and Svelte state helpersThe core client works there today. Built in hooks exist only for React.
OpenAPI coverageThe generated spec covers GET endpoints, query parameters, response schema, and bearer auth metadata.
OpenAPI gapsNested includes and write endpoints stay outside the generated spec.
Excel exportExcel output depends on exceljs. CSV and JSON stay available even when Excel is absent.
Very large tablesCursor pagination usually fits better than deep offset paging.

Related Resources

  • Drizzle ORM: Model your schema in TypeScript and keep SQL close to the application code.
  • TanStack Table: Build custom React tables when you want lower level control over columns and rendering.
  • shadcn/ui: Compose table screens from accessible UI primitives and utility first styling.

FAQs

Q: Does TableCraft work outside React
A: The table component targets React, but the client package works in plain JavaScript, Vue, and Svelte.

Q: When should you use cursor pagination in TableCraft
A: Use cursor pagination when the table gets large, page numbers go deep, or concurrency climbs. The cursor stores the last sort values and keeps later pages fast on indexed fields.

Q: Can TableCraft build a React table from metadata alone
A: Yes. The _meta endpoint can publish labels, operators, enum options, widths, alignments, date presets, and capability flags, so a metadata driven table can render from backend facts.

Q: How do hidden columns and role rules work
A: Put the field behind .hide(), .visibleTo(), or role based visibility rules in the table config. The engine applies those rules before the frontend reads metadata or row data.

Q: Does TableCraft generate OpenAPI for write endpoints
A: No. The generated document focuses on read endpoints and query parameters for TableCraft tables. Write routes still need their own API definitions.

Jackson Kasi

Jackson Kasi

Leave a Reply

Your email address will not be published. Required fields are marked *