Framework Integration
Drop-in patterns for wiring Turbine into a real app. Each one is small on purpose — Turbine is just a client you create once and call from your handlers.
Next.js — singleton client + Route Handler
Create the client once and reuse it. In development, Next.js hot-reload re-evaluates modules, which would otherwise leak a new connection pool on every save — stash the instance on globalThis to survive reloads.
// 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;Then import that db from any Route Handler. Don't call db.disconnect() per request — the pool is long-lived and 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 });
}Running on a serverless or edge runtime instead of the Node server? Bind an external HTTP pool with
turbineHttp— see Serverless & Edge.
Express — request handler
Create the client at startup, pass errors to next(err) so your error middleware can map typed Turbine errors to status codes, and disconnect on shutdown.
// server.ts
import express from 'express';
import { turbine } from './generated/turbine';
import { NotFoundError } 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.findUnique({
where: { id: Number(req.params.id) },
with: { posts: { limit: 10 } },
});
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
} catch (err) {
next(err);
}
});
app.post('/users', async (req, res, next) => {
try {
const user = await db.users.create({ data: req.body });
res.status(201).json(user);
} catch (err) {
next(err);
}
});
// Map typed Turbine errors to HTTP status codes.
app.use((err, _req, res, _next) => {
if (err instanceof NotFoundError) return res.status(404).json({ error: err.message });
console.error(err);
res.status(500).json({ error: 'Internal error' });
});
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
Push the tenant boundary into Postgres so a missing WHERE tenant_id = … can never leak data. You write a policy once; the database enforces it on every query.
1. Define the policy
Enable RLS on the table and add a policy that reads the tenant from a transaction-local setting (a GUC). Use the two-argument current_setting(name, true) so the policy doesn't 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);Make sure your application connects as a role that is subject to RLS — the table owner bypasses policies unless you also ALTER TABLE documents FORCE ROW LEVEL SECURITY, and superusers always bypass them.
2. Set the tenant per request
$withSession(context, fn) opens a transaction, applies each entry as SELECT set_config(name, value, true) (transaction-local) right after BEGIN, then runs your callback. Every query inside sees only the matching rows — and the setting resets automatically on 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
);
}Use the longer $transaction(fn, { sessionContext }) form when you also need writes or multiple statements in the same isolated session:
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 (coerced to text, since GUCs are text). An invalid setting name throws ValidationError and rolls back before any query runs.
3. Wire it into a handler
Pull the tenant from your auth layer and scope every query 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);
}The query layer never has to remember to filter by tenant — the policy does it, every time.
See also
- Transactions & Pipelines —
$transaction,$withSession, isolation levels, andsessionContextin depth. - Serverless & Edge — the singleton pattern adapted for edge runtimes via
turbineHttp. - Typed Errors — mapping Turbine errors to HTTP responses.
- Quick Start — generating the typed client these recipes import.