Skip to content
NewFrom Mongoose to SQL: migrating MongoDB to PostgreSQL7 min read

Next.js

Server components, route handlers and server actions run on the server, so they query the pool directly. examples/nextjs is a runnable Next.js 16 app, tested in CI.

Terminal window
npm install uql-orm pg server-only
npm install -D @babel/plugin-proposal-decorators@7 tsx
src/db/uql.ts
import 'server-only';
import { PgQuerierPool } from 'uql-orm/postgres';
import './entities'; // registers the decorated entities
// hot reload re-runs this module; reuse the pool instead of leaking a new one each time
declare global {
var uqlPool: PgQuerierPool | undefined;
}
export const pool = (globalThis.uqlPool ??= new PgQuerierPool({
connectionString: process.env.DATABASE_URL,
}));

server-only turns an import from a client component into a build error, so the connection string never ships to the browser. tsx is for uql-migrate.

Next’s SWC compiles only legacy decorators, and UQL uses the standard ones. Either let Babel compile them, leaving experimentalDecorators off; Next picks up the config on its own, Turbopack included. Or declare entities with defineEntity, which needs no decorators and so no Babel config:

babel.config.json
{
"presets": ["next/babel"],
"plugins": [["@babel/plugin-proposal-decorators", { "version": "2023-11" }]]
}

The production build minifies class names, and a table is named after its class by default, so name every table: @Entity({ name: 'users' }) (details).

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 }, $limit: 5 } },
$limit: 20,
});
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} ({user.posts.length})
</li>
))}
</ul>
);
}

A route handler queries the same way and returns NextResponse.json(rows). Keep it on the default Node.js runtime, since pg needs TCP.

An action is a public endpoint, so build the query from validated input, never from a client’s Query<User>. Writes that must land together go in pool.transaction:

app/users/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { pool } from '@/db/uql';
import { Post, User } from '@/db/entities';
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');
}

Mount the HTTP handler in an optional catch-all route, with the prefix as basePath:

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

Route handlers export the standard verbs only, so keep the browser client on GET.

Wrap the work in withContext with the tenant from a verified session. A security filter then scopes each query inside, across every await:

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();
return session ? withContext({ tenantId: session.tenantId }, run) : run();
}

Then query inside it: withTenant(() => pool.findMany(Invoice, { $limit: 50 })). Next’s proxy.ts cannot set the context, since it returns before your page queries. The CRUD route takes getContext instead. See Multi-tenancy.