Skip to content

tRPC layer

@repo/api is the shared tRPC layer — used by apps/svelte-web (server-side caller) and the native mobile apps (hand-written tRPC-over-HTTP client). Zero external SDK dependencies — only TypeScript interfaces; the host app injects concrete implementations.

packages/api/src/
├── index.ts # types + browser-safe transforms (pure type barrel)
├── server.ts # runtime exports for hosts (appRouter, createCallerFactory)
├── router.ts # merges 19 domain routers
├── trpc.ts # initTRPC + procedure factories + rate-limit + observability seam
├── routers/ # one file per domain (thin — delegate to services)
├── services/ # business logic (framework-agnostic)
├── types/
│ ├── services.ts # Services interface (host implements)
│ ├── search.ts # LocationDocument, AgentDocument
│ └── property.ts # PropertyMarker, PropertyWithImage
└── transforms/
└── property.ts # toNum, normalizePropertyDetail
ImportUse from
@repo/api (root)import type { AppRouter, Context, Services, ... } + pure transform functions. Safe to import from any code, including client bundles.
@repo/api/serverimport { appRouter, createCallerFactory }. Server-only — these value re-exports drag the router + service tree (including the @repo/database/kysely runtime + pg) into whatever bundle imports them.

addresses, admin, agents, ai, analytics, chat, dashboard, inventory, leads, listings, lists, locations, notifications, portal, properties, savedSearches, teams, tours, users.

Source of truth: packages/api/src/router.ts.

Each router has a matching service in packages/api/src/services/. Two team-specific helpers are split out: team-analytics.ts, team-member-detail.ts. Plus listings-write.ts (split from listings.ts).

From packages/api/src/trpc.ts:

ProcedureGuard
publicProcedureNo auth
protectedProcedureRequires ctx.user
agentProcedureRequires AGENT role
rateLimit(scope, limit, windowSeconds)Per-user middleware via ctx.services.rateLimit.byUser
publicRateLimit(scope, limit, windowSeconds)IP-based for public procedures

Observability span. Every procedure also runs an observe base middleware first (outermost, so the span wraps auth + rate-limit). It hands off to a host-registered middleware via setMonitoringMiddleware(fn) (re-exported from @repo/api/server) — each host passes its own Sentry.trpcMiddleware({ attachRpcInput: true }) at startup. This keeps @repo/api free of any @sentry/* import (the package’s zero-SDK rule) and is a no-op when unregistered (e.g. tests). See Analytics & monitoring.

Context is a pure object — no SvelteKit types allowed in this package. The host app builds it:

interface Context {
user: { id, email, roles, name, image } | null;
session: { id } | null;
clientIp: string;
db: Database; // Kysely handle from '@repo/database/kysely'
services: Services;
}

The Services interface (see packages/api/src/types/services.ts) is the contract the host fulfils — rateLimit, search clients, S3, notifications, etc.

Routers stay thin. Business logic in services.

packages/api/src/services/leads.ts
export async function getLead(db: Database, userId: string, id: string) {
// business logic here
}
// packages/api/src/routers/leads.ts
export const leadsRouter = router({
get: protectedProcedure
.use(rateLimit('leads.get', 60, 60))
.input(z.object({ id: z.string() }))
.query(({ ctx, input }) => getLead(ctx.db, ctx.user.id, input.id)),
});
apps/svelte-web/src/lib/server/trpc.ts
import { appRouter, createCallerFactory } from '@repo/api/server'; // runtime subpath
import type { Context, Services } from '@repo/api'; // type barrel
const createCaller = createCallerFactory(appRouter);
// build Context in hooks.server.ts, then: const api = createCaller(ctx);

The native apps speak the tRPC wire protocol with a hand-written thin client (TrpcClient.swift / TrpcClient.kt) — there is no @trpc/react-query and no compile-time AppRouter types. Queries are GET /api/trpc/{router.procedure}?input=…, mutations POST, SuperJSON envelope, no batching (one request per call; native concurrency handles parallelism), transport-failure-only retry. The response model layer is generated from @repo/contract. Full details on Conventions → Mobile (Native).

  • No SvelteKit imports in @repo/api. No RequestEvent, Cookies, Locals, $env/*. If you need request-y data, add it to Context or Services and have the host inject it.
  • Routers stay thin. They validate input, apply middleware, and delegate to a service. Business logic in src/services/.
  • superjson is the transformerDate serializes/deserializes automatically. Postgres returns numeric columns as strings (not a Decimal type); convert with Number(...) before doing math.
  • Enums — read packages/database/src/enums.ts (values) + packages/database/src/generated/db.ts (column types) before writing inputs. Tour.status = REQUESTED (not PENDING).
  • Web mutations: SvelteKit form actions in +page.server.ts. Permanent — do not migrate to tRPC. They give progressive enhancement, no-JS support, and superforms validation.
  • Web read queries / typed reads / multi-step flows: tRPC is fine.
  • Mobile: tRPC for everything (mobile has no form actions).
  • Shared business logic: put it in src/services/ and call from both the form action (via a thin wrapper) and the tRPC procedure.

See Conventions → Web for the form-action template.

Terminal window
bun --filter @repo/api check-types
bun --filter svelte-web check

The native mobile apps are plain directories (not bun-workspace members), so bun run check does not cover them — if a procedure’s response shape changes, update the @repo/contract schema and regenerate the native models (CI’s tests job enforces it). See Conventions → Mobile (Native).