Skip to content
NewComposite primary keys5 min read

TanStack Start

Start is full-stack and fetch-native, so there are two ways in and they coexist: call the pool from type-safe server functions, or mount the HTTP core as a catch-all server route. Nothing to install beyond uql-orm.

A UQL query is plain JSON, so it passes through as the validated input with end-to-end types:

import { createServerFn } from '@tanstack/react-start';
import type { Query } from 'uql-orm/type';
import { pool } from './uql.config.js';
import { User } from './shared/models/index.js';
export const listUsers = createServerFn({ method: 'GET' })
.validator((query: Query<User>) => query) // type-only pass-through
.handler(({ data }) => pool.findMany(User, data));
const users = await listUsers({
data: {
$select: { id: true, name: true },
$where: { status: 'active' },
$populate: {
posts: {
$select: { title: true },
$where: { published: true },
$limit: 5,
},
},
$limit: 10,
},
});
// typed User[], each with a typed posts: Post[]

(query) => query declares the type without checking it, the same trust model as tRPC and oRPC. For untrusted callers, validate with a schema and build the query server-side; for tenant isolation run the handler inside withContext with a security filter. See Multi-tenancy.

A server function that writes more than one row wraps its statements in pool.transaction and runs each on the callback’s querier: a pool.x call inside there takes a second connection, so it lands outside the transaction.

src/routes/api/uql/$.ts
import { createFileRoute } from '@tanstack/react-router';
import { createFetchHandler } from 'uql-orm/http';
import { pool } from './uql.config.js';
import { Post, User } from './shared/models/index.js';
const handler = createFetchHandler({
pool,
include: [User, Post],
basePath: '/api/uql',
});
export const Route = createFileRoute('/api/uql/$')({
server: {
handlers: {
GET: ({ request }) => handler(request),
HEAD: ({ request }) => handler(request),
POST: ({ request }) => handler(request),
PUT: ({ request }) => handler(request),
PATCH: ({ request }) => handler(request),
DELETE: ({ request }) => handler(request),
},
},
});

Start does not strip the prefix, hence basePath. Every entity now has typed REST endpoints (/api/uql/user, …) for HttpQuerier. The handlers keys are standard verbs, so the QUERY transport is not routed here; keep GET.

createFetchHandler takes the core’s hooks, with the web Request as the hook context, and getContext for tenant scoping.

Use server functions for typed per-operation calls and the catch-all route for CRUD across many entities. The query object is identical either way: one query, every transport.