Documentation
Documentation
Introduction

Getting Started

Getting started
Getting StartedInstallationQuick StartProject Structure

Configuration

Configuration
ConfigurationEnvironment ConfigurationEdge ConfigDatabaseAuth SecretStripeFirebaseStorageGoogle Maps And Cloud Service AccountOAuth ProvidersEmail DeliverySentryFeature Flags

Architecture

Architecture
Architecture OverviewTech StackoRPC MiddlewareDesign Principles

Patterns

Patterns
Code Patterns & ConventionsFeature ModulesError HandlingType Safety

Database

Database
DatabaseSetupSchema DefinitionDatabase OperationsMigrationsCaching
Data Tables

API

oRPCProceduresRoutersoRPC Proxy Setup
APIsOpenAPIREST Endpoints

Auth & Access

AuthenticationConfigurationOAuth ProvidersRolesSession Management
AuthorizationUser RolesPermissions

Routing & i18n

RoutingDeclarative RoutingNavigation
InternationalizationTranslationsLocale Routing

Components & UI

ComponentsButtonsFormsNavigationDialogs
StylesTailwind CSSThemingTypography

Storage

Storage
StorageConfigurationUsageBuckets
Stripe Billing

Extra

Caching

Templates

Templates
Template GuidesCreate New FeatureCreate New PageCreate Database TableCreate oRPC RouterAdd Translations

Development

Development
DevelopmentCommandsAI AgentsBest Practices
Pulling Updates

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:

  1. Create table in Drizzle schema
  2. Add to registry in exposed-tables.ts
  3. 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:

  1. Create table in src/db/tables/*.ts:

    export const myNewTable = createTable("my_new_table", { ... });
  2. Export from src/db/tables/index.ts:

    export * from "./my-new-table";
  3. DrizzleTableName automatically includes "my_new_table" ✨

  4. 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 defineTableColumns for 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

On this page

Overview
Architecture
Quick Start
1. Define Table in Registry
2. Create Functions
3. Create Hook
4. Create oRPC Procedure
5. Create UI Component
Column Factory
Built-in Cell Types
Custom Cell Renderer
Filter Fields
Row Actions
Toolbar CTA
Table Registry
Field Permissions
Example Configuration
Adding New Tables
Validation & Security
Filter Operators
Field Validation
RLS (Row-Level Security)
Complete Example
API Keys Feature
Type Safety
Table Names
Basic Usage
Validation Functions
Benefits
Before vs After
Adding New Tables
Technical Details
Filter Operators
Best Practices
✅ DO
❌ DON'T
Troubleshooting
Table Name TypeScript Error
Field Not Filterable
RLS Not Applied
Related Documentation