Skip to content

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.

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.

app/db.server.ts
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);
app/routes/posts.tsx
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.

app/routes/posts.new.tsx
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.

A route module with no default export is a resource route. Point a splat at one:

app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [index('routes/home.tsx'), route('api/uql/*', 'routes/uql.ts')] satisfies RouteConfig;
app/routes/uql.ts
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.

Middleware wraps next(), so withContext drops straight in. On the root route it covers every loader, action and resource route below:

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