Drizzle ORM
A step-by-step guide to wiring up the data table with Drizzle ORM and PostgreSQL. See the live example and the Database page for the concepts behind the implementation.
File Checklist
Every file you need to create:
| File | Purpose |
|---|---|
db/drizzle/schema.ts | Drizzle table definition (enums, columns) |
db/drizzle/index.ts | Database client |
app/[route]/column-mapping.ts | Maps schema keys → Drizzle columns |
app/[route]/table-schema.tsx | UI column config (display, filters, sorting) |
app/[route]/schema.ts | Zod validation + ColumnSchema type |
app/[route]/search-params.ts | URL search param parsers (derived from schema) |
app/[route]/query-options.ts | React Query infinite query config |
app/[route]/api/route.ts | API handler with createDrizzleHandler |
app/[route]/api/ai/route.ts | AI filter handler (optional, see AI Filters) |
app/[route]/client.tsx | Client component |
app/[route]/page.tsx | Page wrapper |
Step 1: Define Your Drizzle Table
Define your PostgreSQL table with Drizzle's schema builder:
// db/drizzle/schema.ts
import {
integer,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
export const levelEnum = pgEnum("level", ["success", "warning", "error"]);
export const methodEnum = pgEnum("method", ["GET", "POST", "PUT", "DELETE"]);
export const logs = pgTable("logs", {
uuid: uuid("uuid").defaultRandom().primaryKey(),
level: levelEnum("level").notNull(),
method: methodEnum("method").notNull(),
host: text("host").notNull(),
pathname: text("pathname").notNull(),
status: integer("status").notNull(),
latency: integer("latency").notNull(),
regions: text("regions").array().notNull(),
date: timestamp("date", { withTimezone: true }).notNull(),
timingDns: integer("timing_dns").notNull(),
timingConnection: integer("timing_connection").notNull(),
timingTls: integer("timing_tls").notNull(),
timingTtfb: integer("timing_ttfb").notNull(),
timingTransfer: integer("timing_transfer").notNull(),
headers: jsonb("headers").$type<Record<string, string>>().notNull(),
message: text("message"),
});Set up the database client using the node-postgres driver:
// db/drizzle/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL environment variable is not set");
}
export const db = drizzle(connectionString, { schema });Note: If you're using a connection pooler (Supabase transaction mode, PgBouncer, Neon), make sure to use the transaction pooler URL (port
6543for Supabase). Installpgas a dependency:pnpm add pg @types/pg.
Step 2: Column Mapping
The column mapping bridges your table schema keys to Drizzle columns. This is the only file that couples UI to DB — one file per table:
// app/drizzle/column-mapping.ts
import { logs } from "@/db/drizzle/schema";
import type { ColumnMapping } from "@/lib/drizzle";
export const columnMapping = {
level: logs.level,
date: logs.date,
status: logs.status,
latency: logs.latency,
method: logs.method,
host: logs.host,
pathname: logs.pathname,
regions: logs.regions,
"timing.dns": logs.timingDns,
"timing.connection": logs.timingConnection,
"timing.tls": logs.timingTls,
"timing.ttfb": logs.timingTtfb,
"timing.transfer": logs.timingTransfer,
} satisfies ColumnMapping;Keys can use dot notation ("timing.dns") to map nested UI names to flat database columns (timingDns).
This mapping is also the projection: the handler selects with it, so rows come back keyed by your schema keys. No caller ever sees timingDns. A key that is missing from the mapping throws at handler construction with the exact snippet to paste — it used to make the filter silently stop filtering.
Step 3: Table Schema
Define how each column appears in the UI — labels, display format, filter type, sizing:
// app/drizzle/table-schema.tsx
import { col, createTableSchema } from "@/lib/table-schema";
export const tableSchema = createTableSchema({
// Checkbox filter with enum options
level: col
.enum(["success", "warning", "error"])
.label("Level")
.filterable("checkbox", {
options: [
{ label: "success", value: "success" },
{ label: "warning", value: "warning" },
{ label: "error", value: "error" },
],
})
.size(80),
// Timerange filter for date column
date: col
.timestamp()
.label("Date")
.display("timestamp")
.defaultOpen()
.size(200)
.sortable(),
// Checkbox filter with number options
status: col
.number()
.label("Status")
.filterable("checkbox", {
options: [
{ label: "200", value: 200 },
{ label: "400", value: 400 },
{ label: "404", value: 404 },
{ label: "500", value: 500 },
],
})
.size(60),
// Slider filter with range
latency: col
.number()
.label("Latency")
.filterable("slider", { min: 0, max: 5000, unit: "ms" })
.size(110)
.sortable(),
// Text input filter
host: col.string().label("Host").filterable("input").size(125),
// ... add more columns as needed
});The filter type you choose here (checkbox, slider, input, timerange) determines how the three-pass filtering strategy handles each column.
Additional methods: hidden() to hide by default, sortable() for sort support, sheet() for the row detail drawer, resizable() for resizable columns.
Step 4: API Route
The API route uses createDrizzleHandler which implements the three-pass filtering strategy, faceted search, counts, and cursor pagination:
// app/drizzle/api/route.ts
import { db } from "@/db/drizzle";
import { logs } from "@/db/drizzle/schema";
import { createDrizzleHandler } from "@/lib/drizzle";
import { defineFilters } from "@/lib/filters";
import { NextRequest } from "next/server";
import SuperJSON from "superjson";
import { columnMapping } from "../column-mapping";
import { searchParamsCache } from "../search-params";
import { tableSchema } from "../table-schema";
export const dynamic = "force-dynamic";
const handler = createDrizzleHandler({
db,
table: logs,
filters: defineFilters(tableSchema.definition), // declared filter semantics
columnMapping,
cursorColumn: "date",
defaultSize: 40,
});
export async function GET(req: NextRequest): Promise<Response> {
const _search: Map<string, string> = new Map();
req.nextUrl.searchParams.forEach((value, key) => _search.set(key, value));
const search = searchParamsCache.parse(Object.fromEntries(_search));
const result = await handler.execute(search as Record<string, unknown>);
// No remap needed — rows are already keyed by schema keys.
const data = result.data as unknown as ColumnSchema[];
return Response.json(
SuperJSON.stringify({
data,
meta: {
totalRowCount: result.totalRowCount,
filterRowCount: result.filterRowCount,
chartData: [], // Add chart data query if needed
facets: result.facets,
},
prevCursor: result.prevCursor,
nextCursor: result.nextCursor,
}),
);
}filters carries the declared filter semantics — the slider, facet, and date key groupings are derived from it. When tableSchema lives in a "use client" file and can't be imported on the server, pass the serialized schema instead: defineFilters(schemaJson). The declaration crosses the boundary as data, so the server still gets real semantics rather than three untyped string[]s.
To add AI-powered filtering, create a second route at
app/[route]/api/ai/route.tsusingcreateAIFilterHandlerfrom@/lib/ai. It reuses the sametableSchemato generate the AI prompt and output schema. See AI Filters — API Route Setup.
Step 5: Client Component
Wire up useInfiniteQuery to DataTableInfinite. The key pattern is populating filter fields dynamically from the facet data returned by the API:
// app/drizzle/client.tsx
"use client";
import { DataTableInfinite } from "@/components/data-table/data-table-infinite";
import { DataTableStoreProvider, useFilterState } from "@/lib/store";
import { useNuqsAdapter } from "@/lib/store/adapters/nuqs";
import {
generateColumns,
generateFilterFields,
getDefaultColumnVisibility,
} from "@/lib/table-schema";
import { useInfiniteQuery } from "@tanstack/react-query";
import * as React from "react";
import { dataOptions } from "./query-options";
import type { ColumnSchema, FilterState } from "./schema";
import { filterSchema } from "./schema";
import { tableSchema } from "./table-schema";
const columns = generateColumns<ColumnSchema>(tableSchema.definition);
const filterFields = generateFilterFields<ColumnSchema>(tableSchema.definition);
const defaultColumnVisibility = getDefaultColumnVisibility(
tableSchema.definition,
);
export function Client() {
const adapter = useNuqsAdapter(filterSchema.definition, { id: "drizzle" });
return (
<DataTableStoreProvider adapter={adapter}>
<ClientInner />
</DataTableStoreProvider>
);
}
function ClientInner() {
const search = useFilterState<FilterState>();
const { data, isFetching, isLoading, fetchNextPage, hasNextPage, refetch } =
useInfiniteQuery(dataOptions(search));
const flatData = React.useMemo(
() => data?.pages?.flatMap((page) => page.data ?? []) ?? [],
[data?.pages],
);
const lastPage = data?.pages?.[data?.pages.length - 1];
const facets = lastPage?.meta?.facets;
// Populate filter options from server-side facet data
const dynamicFilterFields = React.useMemo(() => {
return filterFields.map((field) => {
const facetsField = facets?.[field.value as string];
if (!facetsField) return field;
if (field.options && field.options.length > 0) return field;
const options = facetsField.rows.map(({ value }) => ({
label: `${value}`,
value,
}));
if (field.type === "slider") {
return {
...field,
min: facetsField.min ?? field.min,
max: facetsField.max ?? field.max,
options,
};
}
return { ...field, options };
});
}, [facets]);
const { sort, size, uuid, cursor, direction, live, ...filter } = search;
const defaultColumnFilters = React.useMemo(() => {
return Object.entries(filter)
.map(([key, value]) => ({ id: key, value }))
.filter(({ value }) => {
if (value === null || value === undefined) return false;
if (Array.isArray(value) && value.length === 0) return false;
return true;
});
}, [filter]);
return (
<DataTableInfinite
columns={columns}
data={flatData}
totalRows={lastPage?.meta?.totalRowCount}
filterRows={lastPage?.meta?.filterRowCount}
totalRowsFetched={flatData?.length}
defaultColumnFilters={defaultColumnFilters}
defaultColumnSorting={sort ? [sort] : undefined}
defaultColumnVisibility={defaultColumnVisibility}
filterFields={dynamicFilterFields}
isFetching={isFetching}
isLoading={isLoading}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
refetch={refetch}
schema={filterSchema.definition}
adapterType="nuqs"
meta={{}}
/>
);
}Step 6: Page Wrapper
A minimal server component with HydrationBoundary for React Query:
// app/drizzle/page.tsx
import { getQueryClient } from "@/providers/get-query-client";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { Client } from "./client";
export const dynamic = "force-dynamic";
export default function Page() {
return (
<HydrationBoundary state={dehydrate(getQueryClient())}>
<Client />
</HydrationBoundary>
);
}Helper Functions Reference
The @/lib/drizzle module exports four functions that createDrizzleHandler uses internally. You can also use them directly for custom query logic:
import {
buildWhereConditions,
buildOrderBy,
buildCursorPagination,
computeFacets,
} from "@/lib/drizzle";buildWhereConditions(filters, values, mapping, selection?)— converts filter state to SQL WHERE clauses. It never inspects the shape of a filter value:filters.plan()turns declared semantics into a closed set of six operations, and each compiles to exactly one SQL form (ilike,=,IN,&&,BETWEEN, or a date range).buildOrderBy(mapping, sort)— returns anascordescSQL clause from a sort descriptor.buildCursorPagination({ cursor, direction, size, cursorColumn })— returns cursor condition, order clause, and aneedsReverseflag for bidirectional infinite scroll.computeFacets(db, table, mapping, conditions, facetKeys, options?)— computes grouped counts (or min/max for slider keys) via parallel SQL queries.evaluateIntervalMs(durationMs)— the bucket-size ladder used for time-series aggregation.
Writing your own aggregate SQL
result.scope is one typed handle onto the query the handler just ran, so a chart or rollup query does not have to re-derive anything:
const { db, table, where, range, bucketMs } = result.scope;| field | what it is |
|---|---|
where | the fully filtered set |
whereWithoutSliders | the set slider bounds are computed over |
range | resolved from an explicit date filter, else MIN/MAX of the set |
bucketMs | bucket size for range, interval ladder already applied |
columns | the column mapping |
This replaces the older result.allConditions, which exposed just enough to make every caller rebuild the range, the bucket size, and the WHERE composition by hand.
