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]/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).
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 { 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,
schema: tableSchema.definition, // Auto-derives slider/facet/date keys
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>);
// Map DB rows to your ColumnSchema shape
const data = result.data.map((row) => ({
uuid: row.uuid,
level: row.level,
status: row.status,
latency: row.latency,
host: row.host,
date: row.date,
// ... map all columns
}));
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,
}),
);
}createDrizzleHandler accepts either schema: tableSchema.definition (auto-derives filter types) or explicit sliderKeys, facetKeys, and dateKeys arrays when the table schema can't be imported on the server.
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, start, 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(mapping, filters, options?)— converts filter state to SQL WHERE clauses. Handlesilikefor strings,betweenfor slider ranges,inArrayfor checkboxes, date ranges, and PostgreSQL array overlap (&&) for array columns.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.
Powered by OpenStatus
