React Router
Framework mode (what Remix became) is fetch-native throughout: loaders and actions receive a web Request and may return a Response, and middleware wraps the request. No adapter anywhere. Written for v8; v7 differs only in that middleware was still behind a future flag there.
Where the pool lives
Section titled “Where the pool lives”The .server.ts suffix is a hard boundary: the Vite plugin strips those modules from the client bundle, so importing one from a component is a build error rather than a leaked connection string.
import { PgQuerierPool } from 'uql-orm/postgres';import './models';
export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL,});Loaders and actions use the exported pool.
Loaders and actions
Section titled “Loaders and actions”import { pool } from '~/db.server';import { Post } from '~/models';import type { Route } from './+types/posts';
export async function loader({ request }: Route.LoaderArgs) { const skip = Number(new URL(request.url).searchParams.get('skip')) || 0; return { posts: await pool.findMany(Post, { $select: { id: true, title: true }, $populate: { author: { $select: { name: true } } }, $where: { published: true }, $sort: { createdAt: 'desc' }, $limit: 20, $skip: skip, }), };}
export default function Posts({ loaderData }: Route.ComponentProps) { return ( <ul> {loaderData.posts.map((post) => ( <li key={post.id}> {post.title} - {post.author.name} </li> ))} </ul> );}loaderData is typed from the loader’s return type, each post with a typed author. Rows are plain objects, so there is nothing to map for the single-fetch serializer.
import { redirect } from 'react-router';import { pool } from '~/db.server';import { Post } from '~/models';import type { Route } from './+types/posts.new';import { z } from 'zod';
const NewPost = z.object({ title: z.string().trim().min(1) });
export async function action({ request }: Route.ActionArgs) { const form = NewPost.safeParse(Object.fromEntries(await request.formData())); if (!form.success) { return { errors: z.flattenError(form.error).fieldErrors }; } return redirect(`/posts/${await pool.insertOne(Post, form.data)}`);}An action is a public endpoint: build the query from validated input, never from a raw Query<Post>. Several writes go in pool.transaction.
Auto-generated CRUD
Section titled “Auto-generated CRUD”A route module with no default export is a resource route. Point a splat at one:
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [ index('routes/home.tsx'), route('api/uql/*', 'routes/uql.ts'),] satisfies RouteConfig;import { createFetchHandler } from 'uql-orm/http';import { pool } from '~/db.server';import { Post, User } from '~/models';import type { Route } from './+types/uql';
const handler = createFetchHandler({ pool, include: [Post, User], basePath: '/api/uql',});
export const loader = ({ request }: Route.LoaderArgs) => handler(request);export const action = ({ request }: Route.ActionArgs) => handler(request);React Router does not strip the prefix, hence basePath. loader serves GET and HEAD, action serves the write verbs, which is the whole wire protocol minus the QUERY transport: that split is keyed to named verbs, so keep the browser client on GET.
Multi-tenancy
Section titled “Multi-tenancy”Middleware wraps next(), so withContext drops straight in. On the root route it covers every loader, action and resource route below:
import { withContext } from 'uql-orm';import type { Route } from './+types/root';
export const middleware: Route.MiddlewareFunction[] = [ async ({ request }, next) => { const session = await getSession(request.headers.get('cookie')); return session ? withContext( { tenantId: session.tenantId, userId: session.userId }, () => next(), ) : next(); },];A security filter then scopes every query, relations and cascades included, and fails closed without a context. See Multi-tenancy.
Data mode and SPA mode have no server in the router: mount the HTTP core on whatever hosts your API and talk to it with HttpQuerier or the TanStack Query recipe.