Skip to content

Architecture

tRPC: a typed passthrough, not a second backend

Section titled “tRPC: a typed passthrough, not a second backend”

The frontend’s most important architectural fact: tRPC procedures are defined in this app, not in the Go API, and they contain no business logic. The tRPC layer (src/trpc/) is a thin, type-safe passthrough over the generated @huddlesurety/api SDK.

Each procedure declares its input as the SDK’s generated type and its handler just calls the matching SDK method:

export const bondRequestRouter = createTRPCRouter({
get: proc
.input(z.custom<BondGetRequestRequest>()) // type = SDK operation type
.query(({ input }) => api.bond.bondGetRequest(input)),
create: proc
.input(z.custom<BondCreateRequestBody>())
.mutation(({ input }) => api.bond.bondCreateRequest(input)),
});

z.custom<T>() is a type-only assertion (no runtime schema) — the SDK types are the contract. Typesafety flows end to end:

graph LR
    A[Go handlers<br/>+ swag annotations] --> B[OpenAPI spec]
    B --> C["@huddlesurety/api<br/>generated types"]
    C --> D["z.custom&lt;SDKType&gt;()<br/>procedure input"]
    D --> E[AppRouter type]
    E --> F[createTRPCReact<br/>client hooks]

So tRPC here is a transport bridge, not a schema owner. Why have it at all? It gives the client React Query hooks, a single place to inject cookies and tracing, consistent error mapping, and SSE subscriptions — over an OpenAPI backend that otherwise wouldn’t offer them.

The SDK is configured with a custom fetcher (src/trpc/api.ts) that runs on the server and acts as a cookie + trace bridge:

  • Copies the incoming browser jwt cookie onto the outbound request to the Go API (API_URL).
  • Injects the W3C traceparent header (propagation.inject) so the app → api → rag trace stays connected, and tags the active span with the requester’s org.
  • Flushes any Set-Cookie the API returns (login/refresh JWT) back to the browser — in Route Handler / Server Action contexts; during SSR render, where cookies are read-only, it’s caught and ignored.

Two middlewares wrap every procedure (src/trpc/init.ts). errorMappingMiddleware catches an SDK HuddleAPIError and maps its HTTP statusCode to the equivalent TRPCError code (401→UNAUTHORIZED, 404→NOT_FOUND, …). On the client, isNotFoundError(err) checks error.data?.code === "NOT_FOUND". A separate isPrerenderAbort walks the error’s .cause chain for the Cache Components prerender-abort signal so those aren’t logged as real errors.

The client (src/components/providers.tsx) routes each operation over a splitLink: subscriptions use httpSubscriptionLink (SSE); FormData / binary uploads use a plain httpLink; everything else uses httpBatchLink with methodOverride: "POST" (paired with allowMethodOverride: true on the route handler). Batched query inputs are otherwise encoded into the GET URL, which on a multi-query refocus refetch can blow past Node’s 16 KB header limit and return HTTP 431 — so ordinary queries are sent as POST bodies.

The transport URL is origin-relative, decoupled from any configured host: in the browser it’s "/api/trpc" (same-origin, so it works behind the h2 dev proxy or any serving host), and during SSR it self-calls http://localhost:${PORT ?? 3000}/api/trpc. It no longer reads NEXT_PUBLIC_URL (which now only builds invite links).

The component library is Base UI (@base-ui/react). The design-system primitives live in src/components/ui/ and are composed with Tailwind v4 (via the PostCSS plugin — no tailwind.config), cva for variants, and cn() (twMerge + clsx).

The look is flat/sharp with a surface-elevation depth model: sharp or minimally-rounded borders, bg-popover/bg-primary/bg-secondary surface variables, and OKLCH color-mix for hover elevation. The primary color is injected per-organization in AppFrame via a --primary CSS variable.

Forms use TanStack Form, not react-hook-form (which isn’t a dependency). The pattern is centralized in src/components/ui/form.tsx, which wires the design-system fields to a useAppForm hook:

export const { useAppForm, withForm } = createFormHook({
fieldContext, formContext,
fieldComponents: { TextField, TextareaField, SwitchField },
formComponents: { SubmitButton },
});

Consumers call useAppForm(...) and render form.TextField, form.SwitchField, form.SubmitButton (which auto-disables until the form is dirty and valid, and shows a spinner while submitting). Field validity is read reactively via useSelector(field.store, …).

Server state is TanStack Query, accessed through the tRPC React hooks — suspense-first: data-owning components use useSuspenseQuery and self-wrap in Suspense with a co-located skeleton (see Style Guide › Data fetching). The main exception is client-only panels that never prerender — e.g. the bond editor tools — which use plain useQuery. Mutations live in src/hooks/use-*-mutation.ts (one per domain), wrap trpc.*.useMutation(), usually surface a toast.promise(...), and invalidate related queries in onSuccess. Local UI state uses useState/context; cross-cutting UI state (breadcrumbs) uses a TanStack Store, read through useCrumbStore — its empty server snapshot keeps late-hydrating Suspense boundaries consistent with the SSR HTML, since crumbs are only set by client effects.

Cache Components (CC) is enabled (cacheComponents: true). CC forbids unstable values (like Date.now()) during prerender, so the app has a few deliberate, documented workarounds:

  • tRPC hydration — a custom HydrateClient (src/trpc/server.tsx) hydrates via a childless sibling <Hydrate/> behind Suspense + connection(), so dehydrate()’s internal Date.now() is deferred to request time and children still prerender statically.
  • Relative timestamps<TimeAgo> uses the shared useMounted hook (useSyncExternalStore with a false server snapshot) to render nothing on the server and only compute the relative time after mount; the absolute ISO date stays in the prerendered HTML.
  • Tables — the live DataTable uses useReactTable, but DataTableSkeleton (rendered in loading.tsx) derives columns directly to avoid useReactTable’s internal Date.now() during prerender.

Prefetch/hydration model:

  • getQueryClient() returns one client per request on the server (so RSC prefetch matches what client components read) and a singleton on the client.
  • Layouts prefetch shared queries fire-and-forget and never throw on auth — trpc.auth.session().catch(() => null) — so an unauthenticated shell still renders.
  • Pages read their data with useSuspenseQuery and suspend into their own loading.tsx. Parallel routes (@slot folders) each own their fetch and boundary.
  • ReactQueryStreamedHydration (in providers.tsx) streams queries resolved during the SSR pass into the client cache, using the query client’s superjson dehydrate/hydrate options. This covers the gap HydrateClient can’t: HydrateClient transfers RSC-layer prefetches, while client components’ useSuspenseQuery fetches happen in the separate SSR pass — the two module graphs don’t share a query client. Without the streamed transfer those fetches were discarded (double fetch on the client) and data seeded by a sibling route could render into SSR HTML the client cache didn’t have — a hydration mismatch.

Long-running backend workflows (parse document, generate field, fill form, find bond, process request) report progress to the UI via tRPC subscriptions over SSE. The app no longer polls the Go API once per second; instead it consumes the API’s …/watch SSE routes, which push a bare WorkflowStatus string whenever it changes.

That consumption is server-only. src/trpc/watch.ts (marked import "server-only") holds the generators — split out of src/trpc/utils.ts so client bundles never pull them in:

  • parseSSE(body) — parses the SSE byte stream, yielding data: payloads and skipping heartbeat/comment lines.
  • watchStatus(path, signal) — consumes one watch route. It requests accept: text/event-stream, surfaces a failed connect (401/403), reconnects after 1s if the stream drops, and dedups identical consecutive statuses.
  • watchStatusMap(pathFor, ids, signal) — opens one watchStatus connection per id, merges them into a Record<id, WorkflowStatus>, and emits the full map once every id has reported and again on any change.

The watch route paths are the single place they’re spelled — typed helpers at the bottom of watch.ts (watchParseStatuses, watchGenerateFieldStatuses, watchFillFormStatuses / watchFillFormStatus, watchFindBondStatus, watchProcessRequestStatus) — because Speakeasy gates SSE (serverEvents) generation behind its business tier, so the SDK ships no client for these routes (the API side also marks them x-speakeasy-ignore).

  • JWT cookie (jwt), verified locally with jose (HS256, JWT_SECRET) in src/lib/session.ts. verifySession returns SessionClaims (userID, orgID, orgType) or undefined — it no longer inspects the JWT purpose claim (the API now enforces purpose at the signature layer via per-purpose signing keys, so a session cookie simply verifies against the raw secret). This is treated as identity hints for UX only — the Go API is the security boundary.
  • Role gating lives in the Next middleware (src/proxy.ts), driven by the nav map in src/pages.ts. It reads orgType from the JWT, redirects / to the role’s home, and rewrites wrong-role paths to a 404 (preserving the URL). A path listed under multiple roles (e.g. /requests/new) admits all of them.