Framework integration
One client per process, a scoped client per request, and consistent HTTP error mapping in Next.js, Hono, Express, and Fastify.
The same three pieces apply to every server framework:
- build the Drizzle and Better clients once per process
- derive a scoped client per request with
$withContext(...) - map errors to HTTP responses in one place
One client per process
better(db, options) builds table metadata and runs every plugin's setup() once. Do it at module scope and import the result. Calling better() inside a handler repeats that work and, if you also create the pool there, opens new connections on every request.
import { better } from 'better-drizzle';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { schema } from './schema';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const client = better(drizzle(pool, { schema }), { schema });
export type Db = typeof client;A scoped client per request
client.$withContext(meta) returns a clone whose meta is merged into every operation, raw query, and transaction started from it. Hooks and plugins read it as ctx.meta, so request IDs and tenant IDs reach logs and guardrails without being passed through every function.
const db = client.$withContext({
requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(),
tenantId: session.tenantId,
userId: session.userId,
});
await db.posts.findMany({ where: { published: true } });
// hooks see meta: { requestId, tenantId, userId }- The merge is shallow: scoped keys first, then per-call
meta, so a per-call key wins. - Nested
$withContext()calls stack. - The clone does not re-run plugin
setup(), but it does rebuild the table delegates. Create it once per request and pass it down, rather than once per query. - Do not call
extends()on a per-request clone. Extensions are registered on state shared by every client derived from the root, so the second request fails with a key conflict. Register them once at startup (see client extensions).
Read the metadata in a hook, for example to tag failures:
export const client = better(drizzle(pool, { schema }), {
schema,
hooks: {
onError(ctx) {
logger.error('db operation failed', {
requestId: ctx.meta?.requestId,
table: ctx.table,
action: ctx.action,
error: ctx.error,
});
},
},
});.throw() also reports to onError, with code RESULT_NOT_FOUND. Skip that code in the hook if expected 404s would add noise.
See multi-tenancy for enforcing tenant scope from this metadata.
Map errors in one place
Library errors are BetterDrizzleError instances with a code and an HTTP-like status. Examples: .throw() on a missing row gives RESULT_NOT_FOUND (404), an aborted or timed-out transaction gives 408, tx.rollback() gives TRANSACTION_ROLLBACK (409). The full list is in the error codes reference.
Database failures reach you in one of two shapes:
- the raw driver error, for example from a plain
create()on a client without hooks - a wrapped
BetterDrizzleErrorwith codeOPERATION_ERROR, status 500, and the driver error incause, when the failure happens insidetransaction(), inside a raw query, or while anonErrorhook or a hook for that operation is configured
Check both the error and its cause so a unique violation maps to 409 whichever shape arrives:
import {
BetterDrizzleError,
isForeignKeyViolation,
isUniqueViolation,
} from 'better-drizzle';
export const toHttpError = (error: unknown) => {
const cause = BetterDrizzleError.is(error) ? error.cause : undefined;
if (isUniqueViolation(error) || isUniqueViolation(cause))
return { status: 409, body: { code: 'CONFLICT' } };
if (isForeignKeyViolation(error) || isForeignKeyViolation(cause))
return { status: 422, body: { code: 'INVALID_REFERENCE' } };
if (BetterDrizzleError.is(error))
return {
status: error.status,
body: {
code: error.code,
// never expose internal messages on 5xx
message: error.status < 500 ? error.message : undefined,
},
};
return { status: 500, body: { code: 'INTERNAL' } };
};400 usually means a bug, not bad input
Codes with a 400 default, such as RAW_INVALID_QUERY or LOCK_NOT_SUPPORTED,
describe an invalid call from your code. Validate user input before it
reaches the client, so these stay rare and visible in logs.
One transaction per unit of work
Wrap the writes of a single request in one transaction(). Scoped metadata from $withContext() carries into the transaction client, and options bound the work to the request:
signal: rolls back when the signal aborts (for example, the client disconnected). The error has status 408.timeoutMs: rolls back after the given duration, also 408.tx.afterCommit(callback): runs side effects such as emails or queue messages only after the commit succeeds.
const post = await db.transaction(
async (tx) => {
const created = await tx.posts.create({
data: { authorId: userId, title, published: false },
});
tx.afterCommit(() => queue.publish('post.created', { id: created.id }));
return created;
},
{ signal: request.signal, timeoutMs: 5_000 },
);Framework snippets
Each snippet imports client from lib/db.ts and toHttpError from lib/http-error.ts above.
Keep the client in a server-only module. In development, hot reloads re-evaluate modules, so cache the pool on globalThis to avoid opening a new pool on every edit.
import 'server-only';
import { better } from 'better-drizzle';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { schema } from './schema';
const globalForDb = globalThis as unknown as { pool?: Pool };
const pool =
globalForDb.pool ?? new Pool({ connectionString: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== 'production') globalForDb.pool = pool;
export const client = better(drizzle(pool, { schema }), { schema });A route handler scopes the client from the request and maps errors:
import { client } from '@/lib/db';
import { toHttpError } from '@/lib/http-error';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const db = client.$withContext({
requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(),
});
try {
const user = await db.users
.findUnique({ where: { id: Number(id) } })
.throw();
return Response.json(user);
} catch (error) {
const { status, body } = toHttpError(error);
return Response.json(body, { status });
}
}A server action has no Request, so read headers from next/headers:
'use server';
import { headers } from 'next/headers';
import { client } from '@/lib/db';
export async function createPost(input: { authorId: number; title: string }) {
const requestHeaders = await headers();
const db = client.$withContext({
requestId: requestHeaders.get('x-request-id') ?? crypto.randomUUID(),
userId: input.authorId,
});
return db.transaction(
(tx) =>
tx.posts.create({
data: { authorId: input.authorId, title: input.title, published: false },
}),
{ timeoutMs: 5_000 },
);
}Store the scoped client in a typed context variable from a middleware, and map errors in app.onError:
import { Hono } from 'hono';
import { client, type Db } from './lib/db';
import { toHttpError } from './lib/http-error';
const app = new Hono<{ Variables: { db: Db } }>();
app.use(async (c, next) => {
c.set(
'db',
client.$withContext({
requestId: c.req.header('x-request-id') ?? crypto.randomUUID(),
tenantId: c.req.header('x-tenant-id'),
}),
);
await next();
});
app.get('/users/:id', async (c) => {
const user = await c
.get('db')
.users.findUnique({ where: { id: Number(c.req.param('id')) } })
.throw();
return c.json(user);
});
app.onError((error) => {
const { status, body } = toHttpError(error);
return Response.json(body, { status });
});
export default app;Attach the scoped client to res.locals and map errors in an error middleware. Express 5 forwards rejected promises from async handlers to it; on Express 4, catch and call next(error) yourself.
import express from 'express';
import { client, type Db } from './lib/db';
import { toHttpError } from './lib/http-error';
declare global {
namespace Express {
interface Locals {
db: Db;
}
}
}
const app = express();
app.use((req, res, next) => {
res.locals.db = client.$withContext({
requestId: req.header('x-request-id') ?? crypto.randomUUID(),
});
next();
});
app.get('/users/:id', async (req, res) => {
const user = await res.locals.db.users
.findUnique({ where: { id: Number(req.params.id) } })
.throw();
res.json(user);
});
app.use(
(
error: unknown,
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
const { status, body } = toHttpError(error);
res.status(status).json(body);
},
);Decorate the request, scope it in an onRequest hook (Fastify already assigns request.id), and map errors in setErrorHandler:
import Fastify from 'fastify';
import { client, type Db } from './lib/db';
import { toHttpError } from './lib/http-error';
declare module 'fastify' {
interface FastifyRequest {
db: Db;
}
}
const app = Fastify();
app.decorateRequest('db', null as unknown as Db);
app.addHook('onRequest', async (request) => {
request.db = client.$withContext({ requestId: request.id });
});
app.get<{ Params: { id: string } }>('/users/:id', async (request) =>
request.db.users
.findUnique({ where: { id: Number(request.params.id) } })
.throw(),
);
app.setErrorHandler((error, request, reply) => {
const { status, body } = toHttpError(error);
reply.status(status).send(body);
});Serverless and edge
better-drizzle adds no connection handling of its own: it runs every query through the Drizzle instance you pass in. What matters is that instance and where you create it.
- Create the client at module scope. Warm invocations reuse the module, so metadata and plugin setup are built once per instance, and the driver's connection or pool is reused.
- The driver decides what runs. Any Drizzle driver works for repository calls. On PostgreSQL and MySQL,
transaction()delegates to the Drizzle client's owntransaction; if the client has none, the call throwsTRANSACTIONS_UNSUPPORTED. Check your driver's transaction support before relying ontransaction()at the edge. - SQLite is handled differently. Transactions run through explicit
BEGIN/SAVEPOINTstatements, and raw SQL usesdb.all()/db.run()instead ofdb.execute().