Data Tables
Server-side paginated tables with filtering, sorting, and RLS support
This guide explains how to create server-side paginated, filterable, and sortable data tables with Row-Level Security (RLS) support.
Overview
The data table system provides:
- ✅ Server-side pagination - Handle large datasets efficiently
- ✅ Server-side filtering - Apply filters with validated operators
- ✅ Server-side sorting - Sort by any exposed field
- ✅ Type-safe table names - Extracted from Drizzle schema
- ✅ RLS support - Optional row-level security
- ✅ Declarative columns - Built-in renderers for common types
- ✅ Field-level permissions - Control filterable/sortable fields
Architecture
┌─────────────────┐
│ UI Component │ (table-users.tsx)
│ - DataTable │
│ - Columns │
└────────┬────────┘
│ useListHook
│
┌────────▼────────┐
│ oRPC Hook │ (hooks.ts)
│ useListTable │
└────────┬────────┘
│ useQuery(rpc.table.queryOptions)
│
┌────────▼────────┐
│ oRPC Procedure │ (routers/*.ts)
│ table │
└────────┬────────┘
│ ctx.db.table.table
│
┌────────▼────────┐
│ Functions │ (functions.ts)
│ table() │
└────────┬────────┘
│ operations.table
│
┌────────▼────────┐
│ Drizzle Ops │ (drizzle-operations.ts)
│ + RLS Context │
└────────┬────────┘
│
┌────────▼────────┐
│ Database │ (PostgreSQL)
│ with RLS │
└─────────────────┘Quick Start
1. Define Table in Registry
Add your table to exposed-tables.ts:
// src/db/exposed-tables.ts
export const myTableConfig: ExposedTableConfig = {
tableName: "my_table", // Type-safe from Drizzle schema
fields: {
id: { sortable: true },
name: { filterable: true, sortable: true, searchable: true },
status: { filterable: true, sortable: true },
createdAt: { sortable: true },
},
defaultSort: "createdAt",
defaultDirection: "desc",
};
export const TableRegistry = {
// ... other tables
my_table: myTableConfig,
} satisfies Partial<Record<DrizzleTableName, ExposedTableConfig>>;2. Create Functions
Add table function in functions.ts:
// src/features/my-feature/functions.ts
import { createDrizzleOperations } from "@/db/drizzle-operations";
import { myTable } from "@/db/tables";
import type { TablePagination } from "@/forms/table-list/types";
const operations = createDrizzleOperations({
table: myTable,
});
export async function table(params: TablePagination) {
return operations.table(params);
}3. Create Hook
Add hook in hooks.ts:
// src/features/my-feature/hooks.ts
import type { TablePagination } from "@/forms/table-list/types";
import { useRpcUtils } from "@/rpc/react";
import { useQuery } from "@tanstack/react-query";
export function useMyTableList(input: TablePagination) {
const rpc = useRpcUtils();
return useQuery(rpc.myFeature.table.queryOptions({ input }));
}4. Create oRPC Procedure
Add procedure in routers/my-feature.ts:
// src/rpc/procedures/routers/my-feature.ts
import { tablePaginationSchema } from "@/forms/table-list/types";
import { authProcedure, createRPCRouter } from "@/rpc/procedures/rpc";
import type { ActionResponse } from "@/lib/utils/schema-utils";
export const myFeatureRouter = createRPCRouter({
table: authProcedure
.meta({ rateLimit: "QUERY" })
.input(tablePaginationSchema)
.query(async ({ ctx, input }) => {
const result = await ctx.db.myTable.table(input);
return {
success: true,
payload: {
items: result.items,
pagination: result.pagination,
},
} satisfies ActionResponse;
}),
});5. Create UI Component
Create component using declarative columns:
// src/features/my-feature/components/table-my-feature.tsx
"use client";
import DataTable from "@/forms/table-list/data-table";
import { defineTableColumns } from "@/forms/table-list/column-factory";
import { useMyTableList } from "@/features/my-feature/hooks";
import { useFormatter, useTranslations } from "next-intl";
type Row = MyFeatureType;
export function TableMyFeature() {
const t = useTranslations("pageMyFeature");
const formatter = useFormatter();
const columns = defineTableColumns<Row>(
[
{
key: "name",
i18nKey: "form.name.label",
width: "48",
cell: "text", // Built-in renderer
},
{
key: "status",
i18nKey: "form.status.label",
width: "24",
cell: "badge", // Badge renderer
badge: BadgeStatus, // Custom badge component
},
{
key: "createdAt",
i18nKey: "form.createdAt.label",
width: "32",
cell: "datetime", // DateTime renderer
},
],
{ t, formatter },
);
return (
<DataTable
useListHook={useMyTableList}
columns={columns}
defaultSorting={[{ desc: true, id: "createdAt" }]}
filterFields={[
{
label: t("form.name.label"),
field: "name",
icon: "search",
input: "default",
},
]}
/>
);
}Column Factory
The defineTableColumns factory provides declarative column definitions with built-in renderers.
Built-in Cell Types
{
key: "name",
i18nKey: "form.name.label",
cell: "text", // Plain text
}
{
key: "count",
i18nKey: "form.count.label",
cell: "number", // Formatted number
}
{
key: "price",
i18nKey: "form.price.label",
cell: "currency", // Currency formatting
}
{
key: "isActive",
i18nKey: "form.active.label",
cell: "boolean", // Checkmark/Cross
}
{
key: "publishedAt",
i18nKey: "form.published.label",
cell: "date", // Date only
}
{
key: "createdAt",
i18nKey: "form.created.label",
cell: "datetime", // Date + Time
}
{
key: "status",
i18nKey: "form.status.label",
cell: "badge", // Badge component
badge: MyBadgeComponent,
}Custom Cell Renderer
Override with custom JSX:
{
key: "name",
i18nKey: "form.name.label",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span>{row.original.name}</span>
{row.original.isPremium && (
<Badge variant="gold">Premium</Badge>
)}
</div>
),
}Filter Fields
Define which fields are filterable in the UI:
<DataTable
filterFields={[
{
label: t("form.name.label"),
field: "name", // Must be in TableRegistry.fields with filterable: true
icon: "search",
input: "default", // Text input
},
{
label: t("form.status.label"),
field: "status",
icon: "filter",
input: "select", // Dropdown
options: [
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
],
},
]}
/>Row Actions
Add action buttons to each row:
<DataTable
rowActions={(row, refreshPage) => {
const actions: TableRowActionType[] = [
{
label: t("actions.edit"),
icon: "edit",
onClick: () => router.push(`/edit/${row.id}`),
},
{
label: t("actions.delete"),
icon: "trash",
variant: "destructive",
onClick: async () => {
await deleteItem(row.id);
refreshPage?.(); // Refresh the table
},
},
];
return actions;
}}
/>Toolbar CTA
Use the cta prop to place action buttons (e.g. "Create") inside the table toolbar, next to filters. Do not render a separate button above the table.
The header prop is deprecated. Always use cta instead.
<SmartTable
cta={
<PageAdminItemsNew.Link className="gap-2">
<Icon iconKey="add" className="size-4" />
{t("actions.create")}
</PageAdminItemsNew.Link>
}
// ...columns, filterFields, etc.
/>The CTA is responsive by default:
- Mobile: stacked layout, CTA fills width
- Desktop: CTA aligned right on the filters row
To keep CTA sizing consistent, prefer className="w-full md:w-auto" on the CTA button component.
When a page uses SectionHeader + SmartTabs, each tab can own its own CTA via the SmartTable cta prop. Use SectionHeader children for page-level actions and SmartTable cta for tab-specific actions.
Table Registry
The registry defines which fields are filterable, sortable, and searchable.
Field Permissions
export type TableFieldConfig = {
filterable?: boolean; // Can be filtered in UI
sortable?: boolean; // Can be sorted by clicking header
searchable?: boolean; // Included in text search (ilike)
};Example Configuration
export const usersTableConfig: ExposedTableConfig = {
tableName: "users",
fields: {
// Only sortable
id: { sortable: true },
// Filterable, sortable, searchable
name: { filterable: true, sortable: true, searchable: true },
email: { filterable: true, sortable: true, searchable: true },
// Only filterable and sortable
role: { filterable: true, sortable: true },
// Only sortable (timestamps)
createdAt: { sortable: true },
updatedAt: { sortable: true },
},
defaultSort: "createdAt",
defaultDirection: "desc",
};Adding New Tables
When adding a new table:
- Create table in Drizzle schema
- Add to registry in exposed-tables.ts
- The table name is automatically validated against
DrizzleTableName
// Drizzle schema
export const myNewTable = createTable("my_new_table", {
// ... columns
});
// Exposed tables registry
export const myNewTableConfig: ExposedTableConfig = {
tableName: "my_new_table", // ✅ Type-checked from Drizzle
fields: { /* ... */ },
};Validation & Security
Filter Operators
Only these operators are allowed (validated by Zod):
"==", "!=", ">", "<", ">=", "<=", "like", "ilike", "in", "not-in"Field Validation
- Filters only work on fields marked
filterable: true - Sorting only works on fields marked
sortable: true - Search only includes fields marked
searchable: true
RLS (Row-Level Security)
Enable RLS by passing auth context to createDrizzleOperations:
// functions.ts
import { withSessionContext } from "@/db/rls-context";
export async function table(params: TablePagination, auth?: AuthContext) {
return operations.table(params, auth);
}
// In oRPC procedure
.query(async ({ ctx, input }) => {
const result = await ctx.db.myTable.table(input, {
userId: ctx.user.id,
orgId: ctx.user.orgId,
});
// ...
});Complete Example
API Keys Feature
See complete implementation:
- Registry: exposed-tables.ts#apiKeysTableConfig
- Functions: api-keys/functions.ts
- Hook: api-keys/hooks.ts
- Router: routers/api-keys.ts
- Component: table-api-keys.tsx
// 1. Registry
export const apiKeysTableConfig: ExposedTableConfig = {
tableName: "apikeys",
fields: {
id: { sortable: true },
name: { filterable: true, sortable: true, searchable: true },
enabled: { filterable: true, sortable: true },
createdAt: { sortable: true },
expiresAt: { sortable: true },
},
defaultSort: "createdAt",
defaultDirection: "desc",
};
// 2. Functions
const operations = createDrizzleOperations({ table: apiKeys });
export async function table(params: TablePagination) {
return operations.table(params);
}
// 3. Hook
export function useApiKeysListTable(input: TablePagination) {
return api.apiKeys.table.useQuery(input);
}
// 4. oRPC Procedure
table: authProcedure
.input(tablePaginationSchema)
.query(async ({ ctx, input }) => {
const result = await ctx.db.apiKeys.table(input);
return {
success: true,
payload: { items: result.items, pagination: result.pagination },
};
});
// 5. UI Component
export function TableApiKeys() {
const t = useTranslations("pageDashboardApiKeys");
const formatter = useFormatter();
const columns = defineTableColumns<ApiKeyClient>(
[
{
key: "name",
i18nKey: "form.fields.name.label",
width: "48",
},
{
key: "requestCount",
i18nKey: "form.fields.requestCount.label",
width: "24",
cell: "number",
},
{
key: "createdAt",
i18nKey: "form.fields.createdAt.label",
width: "32",
cell: "datetime",
},
],
{ t, formatter },
);
return (
<DataTable
useListHook={useApiKeysListTable}
columns={columns}
defaultSorting={[{ desc: true, id: "createdAt" }]}
filterFields={[
{
label: t("form.fields.name.label"),
field: "name",
icon: "key",
input: "default",
},
]}
/>
);
}Type Safety
Table Names
Table names are extracted from Drizzle schema at compile-time using TypeScript's type system. This prevents typos and ensures only valid tables can be referenced.
Basic Usage
import { type DrizzleTableName, DRIZZLE_TABLE_NAMES } from "@/db/schema-tables";
// Type: "users" | "sessions" | "accounts" | "verifications" | "apikeys" | "settings" | ...
type TableName = DrizzleTableName;
// ✅ Valid table names
const tableName: DrizzleTableName = "users";
const another: DrizzleTableName = "apikeys";
// ❌ TypeScript error: "invalid_table" is not assignable
const invalid: DrizzleTableName = "invalid_table";
// Runtime array of all table names
const allTables = DRIZZLE_TABLE_NAMES;Validation Functions
import { isValidTableName, getTableByName } from "@/db/schema-tables";
// Runtime validation
if (isValidTableName("users")) {
const table = getTableByName("users"); // Type-safe!
}Benefits
- No more typos - Autocomplete in IDE, compile-time errors for invalid tables
- Single source of truth - Table names come directly from
createTable()calls - Runtime validation -
isValidTableName()for dynamic checks
Before vs After
// ❌ Before: Prone to typos
const tableName = "apiKeys"; // Wrong! Should be "apikeys"
const config = TableRegistry[tableName]; // undefined
// ✅ After: Type-safe from schema
import { type DrizzleTableName } from "@/db/schema-tables";
const tableName: DrizzleTableName = "apikeys"; // Autocomplete + validation
const config = TableRegistry[tableName]; // Works!Adding New Tables
When you add a new table to the schema:
-
Create table in
src/db/tables/*.ts:export const myNewTable = createTable("my_new_table", { ... }); -
Export from
src/db/tables/index.ts:export * from "./my-new-table"; -
DrizzleTableNameautomatically includes"my_new_table"✨ -
Optionally expose in
exposed-tables.ts:export const myNewTableConfig: ExposedTableConfig = { tableName: "my_new_table", // Type-checked! fields: { ... }, };
Technical Details
The type extraction uses TypeScript's mapped types:
type DrizzleTableName = {
[K in keyof typeof schema]: (typeof schema)[K] extends PgTable
? (typeof schema)[K]["_"]["name"]
: never;
}[keyof typeof schema];This imports the full Drizzle schema, filters for PgTable types, and extracts the internal _["name"] property to create a union type of all valid table names.
Filter Operators
Operators are validated at compile-time and runtime:
import { tableFilterOperators } from "@/forms/table-list/types";
// Type: "==" | "!=" | ">" | "<" | ">=" | "<=" | "like" | "ilike" | "in" | "not-in"
type Operator = (typeof tableFilterOperators)[number];Best Practices
✅ DO
- Use
defineTableColumnsfor consistent styling - Define field permissions in
TableRegistry - Use type-safe table names from
DrizzleTableName - Add RLS context when querying user-specific data
- Use built-in cell renderers (
date,datetime,number,currency, etc.)
❌ DON'T
- Don't use raw Drizzle queries (use
createDrizzleOperations) - Don't expose fields without proper validation
- Don't hard-code table names (use
DrizzleTableName) - Don't forget to add table to
TableRegistry - Don't filter by fields not marked
filterable: true
Troubleshooting
Table Name TypeScript Error
// ❌ Error: Type '"apiKeys"' is not assignable to type 'DrizzleTableName'
tableName: "apiKeys"
// ✅ Fix: Check actual table name in schema
tableName: "apikeys" // Must match createTable("apikeys")Field Not Filterable
// ❌ Filter doesn't work
filterFields: [{ field: "name", ... }]
// ✅ Fix: Add to TableRegistry
fields: {
name: { filterable: true, sortable: true, searchable: true }
}RLS Not Applied
// ❌ RLS not enforced
const operations = createDrizzleOperations({ table: myTable });
// ✅ Fix: Pass auth context
export async function table(params: TablePagination, auth?: AuthContext) {
return operations.table(params, auth);
}Related Documentation
- Database Operations - Drizzle ORM and operations
- oRPC Procedures - API patterns
- schema-tables.ts - Table name type extraction implementation