React Router
React Router Recipe
Section titled “React Router Recipe”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 { setQuerierPool } from 'uql-orm';import { PgQuerierPool } from 'uql-orm/postgres';import './models';
export const pool = new PgQuerierPool({ connectionString: process.env.DATABASE_URL });
setQuerierPool(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 * as v from 'valibot';import { pool } from '~/db.server';import { Post } from '~/models';import type { Route } from './+types/posts.new';
const NewPost = v.object({ title: v.pipe(v.string(), v.trim(), v.nonEmpty()) });
export async function action({ request }: Route.ActionArgs) { const form = v.safeParse(NewPost, Object.fromEntries(await request.formData())); if (!form.success) { return { errors: v.flatten(form.issues).nested }; } return redirect(`/posts/${await pool.insertOne(Post, form.output)}`);}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 '~/db.server';import { Post, User } from '~/models';import type { Route } from './+types/uql';
const handler = createFetchHandler({ 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.