The Future of Web Dev
The Future of Web Dev
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
Table Of Contents
- Install the packages
- Define a table
- Mount the API in your framework
- Add the table styles
- Generate typed adapters and row types
- Render the React data table
- Render nested rows
- Use the client SDK in React or plain JavaScript
- Publish metadata and OpenAPI output
- Think about large tables early
- defineTable() builder methods
- DataTable props
- DataTable config fields
- createClient() options
- Client table methods
- table.query() parameters
- Filter operators
- Metadata shape
- Column metadata fields
- React hooks
- Limits and edge cases
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/codegenExpress
pnpm add @tablecraft/engine @tablecraft/table @tablecraft/adapter-express
pnpm add -D @tablecraft/codegenNext.js
pnpm add @tablecraft/engine @tablecraft/table @tablecraft/adapter-next
pnpm add -D @tablecraft/codegenSvelteKit
pnpm add @tablecraft/engine @tablecraft/table @tablecraft/adapter-sveltekit
pnpm add -D @tablecraft/codegenElysia
pnpm add @tablecraft/engine @tablecraft/table @tablecraft/adapter-elysia
pnpm add -D @tablecraft/codegenIf 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/clientIf you want Excel export in the React table, install exceljs.
bun add exceljsDefine 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/generatedThe 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
| Method | Description |
|---|---|
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
| Prop | Description |
|---|---|
adapter | Data adapter instance for row queries. |
columns | Manual column definitions when you do not want metadata driven columns. |
renderers | Custom cell renderers keyed by column name. |
config | Table behavior flags and page defaults. |
exportConfig | Export file and column settings. |
idField | Row key field. id is the default. |
onRowClick | Row click callback. |
hiddenColumns | UI hidden fields that still stay in the fetched data. |
defaultColumnOrder | First load order for data columns. |
renderSubRow | Expanded row renderer for nested content. |
getRowCanExpand | Per row expansion rule. |
startToolbarContent | Custom content before built in toolbar controls. |
toolbarContent | Custom content after built in toolbar controls. |
renderToolbar | Full toolbar renderer override. |
className | Class name for the outer wrapper. |
pageSizeOptions | Page size choices shown in the UI. |
DataTable config fields
| Config field | Description |
|---|---|
enableSearch | Show the search UI. |
enableExport | Show export actions. |
enableColumnResizing | Turn on drag resizing. |
enableRowSelection | Turn on row selection. |
enableDateFilter | Show date filter controls. |
enableColumnVisibility | Show column visibility controls. |
defaultPageSize | Set the initial page size. |
pageSizeOptions | Set available page sizes. |
removeOuterBorder | Remove the outer card border for nested tables. |
defaultExpanded | Start rows in an expanded state. |
createClient() options
| Option | Description |
|---|---|
baseUrl | Base path for the TableCraft API. |
fetch | Custom fetch implementation. |
axios | Axios instance. This path takes precedence over fetch when both exist. |
headers | Static or async default headers. |
Client table methods
| Method | Description |
|---|---|
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
| Parameter | Description |
|---|---|
page | One based page number for offset pagination. |
pageSize | Rows per page. |
cursor | Cursor string for cursor pagination. |
sort | One sort field or an array of sort fields. |
filters | Field filters with scalar values or operator objects. |
search | Global search string. |
select | Selected field list. |
distinct | Return distinct rows only. |
includeDeleted | Include soft deleted rows in the result. |
Filter operators
| Operator | Description |
|---|---|
eq | Equal. |
neq | Not equal. |
gt | Greater than. |
gte | Greater than or equal. |
lt | Less than. |
lte | Less than or equal. |
between | Range between two values. |
in | Match one value from a list. |
notIn | Exclude values from a list. |
contains | Match a substring. |
startsWith | Match the start of a string. |
endsWith | Match the end of a string. |
Metadata shape
| Field | Description |
|---|---|
name | Table name. |
columns | Column metadata array. |
capabilities.search | Search support flag. |
capabilities.searchFields | Searchable field list. |
capabilities.export | Export support flag. |
capabilities.exportFormats | Supported export formats. |
capabilities.pagination.enabled | Pagination support flag. |
capabilities.pagination.defaultPageSize | Default page size. |
capabilities.pagination.maxPageSize | Max page size. |
capabilities.pagination.cursor | Cursor pagination support flag. |
capabilities.sort.enabled | Sort support flag. |
capabilities.sort.defaultSort | Default sort rules. |
capabilities.groupBy | Group by support flag. |
capabilities.groupByFields | Groupable field list. |
capabilities.recursive | Recursive query support flag. |
filters | Filter metadata array. |
aggregations | Aggregate metadata array. |
includes | Included relation metadata array. |
staticFilters | Permanent filter descriptions. |
Column metadata fields
| Field | Description |
|---|---|
name | Column key. |
type | Primitive type such as string, number, boolean, or date. |
label | Display label. |
hidden | Hidden field flag. |
sortable | Sort support flag. |
filterable | Filter support flag. |
computed | Computed field flag. |
source | Source kind such as base, join, computed, or subquery. |
joinTable | Joined table name for join fields. |
format | Display format hint. |
align | Alignment hint. |
width | Suggested width. |
minWidth | Suggested minimum width. |
maxWidth | Suggested maximum width. |
options | Enum style value list for filters. |
datePresets | Named date shortcuts. |
operators | Supported filter operators for the field. |
React hooks
| Hook | Return 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
| Topic | Detail |
|---|---|
first subquery type | Works only on PostgreSQL because it relies on row_to_json(). |
| Vue and Svelte state helpers | The core client works there today. Built in hooks exist only for React. |
| OpenAPI coverage | The generated spec covers GET endpoints, query parameters, response schema, and bearer auth metadata. |
| OpenAPI gaps | Nested includes and write endpoints stay outside the generated spec. |
| Excel export | Excel output depends on exceljs. CSV and JSON stay available even when Excel is absent. |
| Very large tables | Cursor 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.





