Framework recipes
Create one client per process and call it from your handlers. These recipes show the wiring.
Next.js, singleton client + Route Handler#
Next.js hot reload re-evaluates modules in development, which would open a new pool on every save. Keep the client on globalThis:
// lib/db.ts
import { turbine } from '@/generated/turbine';
const globalForDb = globalThis as unknown as {
db?: ReturnType<typeof turbine>;
};
export const db =
globalForDb.db ?? turbine({ connectionString: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== 'production') globalForDb.db = db;Import that db in any Route Handler. Do not call db.disconnect() per request; the pool is shared.
// app/api/users/route.ts
import { db } from '@/lib/db';
export async function GET(req: Request) {
const limit = Number(new URL(req.url).searchParams.get('limit') ?? 20);
const users = await db.users.findMany({
orderBy: { createdAt: 'desc' },
limit,
with: { posts: { limit: 5 } },
});
return Response.json(users);
}
export async function POST(req: Request) {
const body = await req.json();
const user = await db.users.create({
data: { email: body.email, name: body.name },
});
return Response.json(user, { status: 201 });
}On an edge runtime, bind an HTTP pool with turbineHttp instead. See Serverless & Edge.
Express, request handler#
Create the client at startup and disconnect on shutdown. Pass errors to next(err) so one error handler maps Turbine's typed errors to status codes.
// server.ts
import express from 'express';
import { turbine } from './generated/turbine/index.js';
import { NotFoundError, UniqueConstraintError } from 'turbine-orm';
const db = turbine({ connectionString: process.env.DATABASE_URL });
const app = express();
app.use(express.json());
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.users.findUniqueOrThrow({
where: { id: Number(req.params.id) },
with: { posts: { limit: 10 } },
});
res.json(user);
} catch (err) {
next(err); // a NotFoundError becomes a 404 below
}
});
app.post('/users', async (req, res, next) => {
try {
const user = await db.users.create({ data: { email: req.body.email, name: req.body.name } });
res.status(201).json(user);
} catch (err) {
next(err);
}
});
// Map typed Turbine errors to HTTP status codes.
const onError: express.ErrorRequestHandler = (err, _req, res, _next) => {
if (err instanceof NotFoundError) {
res.status(404).json({ error: err.message });
} else if (err instanceof UniqueConstraintError) {
res.status(409).json({ error: err.message });
} else {
console.error(err);
res.status(500).json({ error: 'Internal error' });
}
};
app.use(onError);
const server = app.listen(3000);
// Graceful shutdown: drain the pool.
process.on('SIGTERM', async () => {
server.close();
await db.disconnect();
});Multi-tenant isolation with Row-Level Security#
Put the tenant boundary in PostgreSQL, so a forgotten WHERE tenant_id = ... cannot leak data. You write the policy once and the database applies it to every query.
1. Define the policy#
Enable RLS and add a policy that reads the tenant from a transaction-local setting. current_setting(name, true) returns NULL, rather than an error, when the setting is absent.
-- migration: isolate documents by tenant
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.current_tenant', true)::int);Connect as a role that RLS applies to. Superusers always bypass policies, and the table owner does too unless you run ALTER TABLE documents FORCE ROW LEVEL SECURITY.
A policy with only USING also checks rows you insert or update, so a new row needs the current tenant's tenant_id. Give the column a default such as current_setting('app.current_tenant')::int, or set it in data.
2. Set the tenant per request#
$withSession(context, fn) opens a transaction, sets each entry with set_config(name, value, true), then runs your callback. Every query inside sees only the tenant's rows, and the settings reset at commit or rollback.
import { db } from '@/lib/db';
async function listDocuments(tenantId: number) {
return db.$withSession(
{ 'app.current_tenant': tenantId },
(tx) => tx.documents.findMany(), // policy filters to this tenant
);
}$withSession is shorthand for $transaction(fn, { sessionContext }). Use the long form to add other transaction options:
await db.$transaction(
async (tx) => {
await tx.documents.create({ data: { title: 'Q3 report' } }); // tenant_id enforced by policy
return tx.documents.findMany();
},
{ sessionContext: { 'app.current_tenant': tenantId } },
);Values may be strings, numbers or booleans. Names need a dot, like app.current_tenant. An invalid name, or undefined, throws ValidationError (E003) before a connection is taken.
3. Wire it into a handler#
Read the tenant from your auth layer and run the queries through the session:
// app/api/documents/route.ts
import { db } from '@/lib/db';
import { getTenantId } from '@/lib/auth';
export async function GET(req: Request) {
const tenantId = await getTenantId(req); // from your session / JWT
const docs = await db.$withSession(
{ 'app.current_tenant': tenantId },
(tx) => tx.documents.findMany(),
);
return Response.json(docs);
}See also#
- Transactions:
$transaction,$withSessionandsessionContext. - Global filters: tenant scoping in the application instead of the database.
- Serverless & Edge: the client on edge runtimes.
- Typed errors: every error class to map to a status code.
- Quick Start: generating the typed client these recipes import.