Skip to content

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.

Terminal window
npm install uql-orm pg server-only
src/db/uql.ts
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 connections
declare 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.

app/users/page.tsx
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.

For callers outside your React tree: a mobile client, a webhook, a third party. Keep the default Node.js runtime, since pg needs TCP.

app/api/users/route.ts
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);
}
app/users/actions.ts
'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.

app/api/uql/[[...uql]]/route.ts
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.

src/db/withTenant.ts
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.