Browser
The browser can be either end of a UQL query. uql-orm/browser talks to your API over HTTP, and uql-orm/pglite skips the network by running Postgres in the tab.
Browser Extension
Section titled “Browser Extension”uql-orm/browser consumes the REST API served by the HTTP core or the Express extension, using the same query syntax you write on the server. It is optional, and it is an HTTP client rather than a driver: something on the other end still holds the connection.
import { HttpQuerier } from 'uql-orm/browser';import { User } from './shared/models/index.js';
const querier = new HttpQuerier('https://api.yourdomain.com/api');
const { data: users } = await querier.findMany(User, { $select: { email: true }, $populate: { profile: { $select: { picture: true } } }, $where: { email: { $endsWith: '@domain.com' } }, $sort: { createdAt: 'desc' }, $limit: 10,});// typed User[], each with a typed profileEntity classes are shared between backend and frontend, so the query type-checks identically on both sides.
Client API
Section titled “Client API”Every wire operation has a typed method: findMany, findManyAndCount, findOne, findOneById, count, insertOne, insertMany, saveOne (upsert via PUT), saveMany, updateOneById, updateMany, deleteOneById, deleteMany. Responses are { data, count? }.
URLs derive from the shared CRUD_ROUTES contract in uql-orm/http, and a compile-time check guarantees the client covers every operation, so the mapping cannot drift from the server.
Failed requests throw a RequestError carrying the server’s message and the numeric HTTP status, so status-driven flows work without string matching:
import { RequestError } from 'uql-orm/browser';
try { await querier.findMany(User, {});} catch (err) { if (err instanceof RequestError && err.status === 401) { location.href = '/login'; }}Options
Section titled “Options”// per instance: defaults that per-call options overrideconst querier = new HttpQuerier('/api', { headers: { Authorization: `Bearer ${session.token}` },});
// per call: abort/timeout, headers, and `silent` to skip the notification busawait querier.findMany(User, q, { signal: AbortSignal.timeout(120_000), headers: { Authorization: `Bearer ${session.token}` },});Build one scoped instance per server-side request rather than reusing a module-level one, so a token never leaks across requests.
For non-CRUD endpoints (/api/payments/checkout, …), the typed helpers get, post, put, patch, remove and query are exported too, sharing the same envelope, headers, notifications and RequestError.
HTTP QUERY transport
Section titled “HTTP QUERY transport”Opt in to send read queries in the request body instead of the URL, which sidesteps URL-length limits on large $where/$populate:
const querier = new HttpQuerier('/api', { readMethod: 'QUERY' });findOne, findMany and count then use QUERY; writes and by-id reads keep their canonical methods. The default stays GET because cross-origin QUERY needs a CORS preflight and some proxies still drop the method. The server accepts both at once, so this is a per-client switch.
Request notifications
Section titled “Request notifications”A small pub/sub bus (on) emits start, success, error and complete per request, which is enough for a global spinner in a vanilla app. Libraries that already track loading state do not need it: pass { silent: true }. See the TanStack Query recipe, where the serializable query doubles as the cache key.
Postgres in the tab
Section titled “Postgres in the tab”PGlite is Postgres compiled to WASM, and idb:// is its browser dataDir, so PgliteQuerierPool runs in a page with no server behind it. Same entities, same queries, same migrations as the server, because it reports itself as the postgres dialect:
import { PgliteQuerierPool } from 'uql-orm/pglite';import { User } from './shared/models/index.js';
const pool = new PgliteQuerierPool('idb://app');const querier = await pool.getQuerier();const users = await querier.findMany(User, { $select: { id: true, email: true },});The database survives a reload: idb:// persists into IndexedDB.
Two things your bundler has to get right, each with its own failure:
- Emit PGlite’s assets. It fetches
pglite.wasm,initdb.wasmandpglite.datafrom the page’s origin at runtime. When they 404,getQuerierthrowsInvalid FS bundle size. - Set a dated
target. No browser implements decorators natively, soesnextleaves the syntax in and the page dies onInvalid or unexpected tokenbefore any of your code runs. Same requirement as on the server.
One connection means one transaction at a time, which for a single tab is rarely the limit it sounds like. PGlite has the rest.