Next.js
Anything that runs on the server in the App Router can query directly: a server component, a route handler, a server action. No adapter for any of them. Verified against Next.js 16, where Turbopack is the default for next dev and next build.
npm install uql-orm pg server-onlyWhere the pool lives
Section titled “Where the pool lives”import 'server-only';import { PgQuerierPool } from 'uql-orm/postgres';import './entities'; // importing the module registers the decorated entities
// the dev server re-evaluates this on every hot reload; a fresh pool per reload leaks connectionsdeclare global { var uqlPool: PgQuerierPool | undefined;}
export const pool = (globalThis.uqlPool ??= new PgQuerierPool({ connectionString: process.env.DATABASE_URL,}));server-only turns an accidental import from a client component into a build error rather than a bundled connection string. Server components and actions use the exported pool.
UQL’s decorators are the standard TC39 ones, so there are no decorator flags to add and nothing for Turbopack to trip over: field types are stated explicitly (@Field({ type: String })) rather than reflected. Next’s generated tsconfig.json needs no changes either: it already ships moduleResolution: bundler and esnext in lib, which is everything Requirements asks for.
Server components
Section titled “Server components”import { pool } from '@/db/uql';import { User } from '@/db/entities';
export default async function UsersPage() { const users = await pool.findMany(User, { $select: { id: true, name: true }, $populate: { posts: { $select: { title: true }, $where: { published: true }, $limit: 5, }, }, $sort: { createdAt: 'desc' }, $limit: 20, });
return ( <ul> {users.map((user) => ( <li key={user.id}> {user.name} ({user.posts.length}) </li> ))} </ul> );}users is typed User[], each with a typed posts.
Route handlers
Section titled “Route handlers”For callers outside your React tree: a mobile client, a webhook, a third party. Keep the default Node.js runtime, since pg needs TCP.
import { NextResponse } from 'next/server';import { pool } from '@/db/uql';import { User } from '@/db/entities';
export async function GET() { const users = await pool.findMany(User, { $select: { id: true, name: true }, $limit: 20, }); return NextResponse.json(users);}Server actions
Section titled “Server actions”'use server';
import { revalidatePath } from 'next/cache';import { pool } from '@/db/uql';import { Post, User } from '@/db/entities';import { z } from 'zod';
const NewUser = z.object({ email: z.email(), name: z.string().trim().min(1),});
export async function createUser(formData: FormData) { const form = NewUser.safeParse(Object.fromEntries(formData)); if (!form.success) { return { errors: z.flattenError(form.error).fieldErrors }; }
await pool.transaction(async (querier) => { const id = await querier.insertOne(User, form.data); await querier.insertOne(Post, { authorId: id, title: 'Hello' }); });
revalidatePath('/users');}An action is a public endpoint with a generated URL, so build the query from validated input and never hand a raw Query<User> to the pool. Writes that must land together go in pool.transaction.
Auto-generated CRUD
Section titled “Auto-generated CRUD”import { createFetchHandler } from 'uql-orm/http';import { pool } from '@/db/uql';import { User } from '@/db/entities';
const handler = createFetchHandler({ pool, include: [User], basePath: '/api/uql',});
export { handler as GET, handler as HEAD, handler as POST, handler as PUT, handler as PATCH, handler as DELETE,};Next.js does not strip the prefix, hence basePath. Every entity now has typed REST endpoints (/api/uql/user, …) for HttpQuerier. Route handler exports are named after the standard verbs, so the QUERY transport is not available here; keep the client on GET.
Multi-tenancy
Section titled “Multi-tenancy”import 'server-only';import { withContext } from 'uql-orm';import { getSession } from '@/auth';
export async function withTenant<T>(run: () => Promise<T>): Promise<T> { const session = await getSession(); // verified cookie or JWT, never a client-supplied id return session ? withContext({ tenantId: session.tenantId, userId: session.userId }, run) : run();}const invoices = await withTenant(() => pool.findMany(Invoice, { $limit: 50 }));withContext propagates across every await inside the callback, so a security filter scopes each query, relations and cascades included, and fails closed without a context. Middleware cannot do this: it runs before the request and returns, so the store is gone by the time anything queries. The CRUD route gets the same treatment from createFetchHandler’s getContext. See Multi-tenancy.