Next.js
Next.js Recipe
Section titled “Next.js Recipe”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 { setQuerierPool } from 'uql-orm';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 }));
setQuerierPool(pool);server-only turns an accidental import from a client component into a build error rather than a bundled connection string. setQuerierPool makes this the pool that createFetchHandler, getQuerier() and @Transactional resolve.
UQL’s decorators are the standard TC39 ones, so there are no tsconfig.json flags to add and nothing for Turbopack to trip over: field types are stated explicitly (@Field({ type: String })) rather than reflected.
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 * as v from 'valibot';import { pool } from '@/db/uql';import { Post, User } from '@/db/entities';
const NewUser = v.object({ email: v.pipe(v.string(), v.email()), name: v.pipe(v.string(), v.trim(), v.nonEmpty()) });
export async function createUser(formData: FormData) { const form = v.safeParse(NewUser, Object.fromEntries(formData)); if (!form.success) { return { errors: v.flatten(form.issues).nested }; }
await pool.transaction(async (querier) => { const id = await querier.insertOne(User, form.output); 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 '@/db/uql';import { User } from '@/db/entities';
const handler = createFetchHandler({ 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.