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<SDKType>()<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 server-side fetcher
Section titled “The server-side fetcher”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
jwtcookie onto the outbound request to the Go API (API_URL). - Injects the W3C
traceparentheader (propagation.inject) so the app → api → rag trace stays connected, and tags the active span with the requester’s org. - Flushes any
Set-Cookiethe 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.
Error mapping
Section titled “Error mapping”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.
Client transport (a splitLink)
Section titled “Client transport (a splitLink)”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).
UI system: Base UI + Tailwind v4
Section titled “UI system: Base UI + Tailwind v4”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: TanStack Form
Section titled “Forms: TanStack Form”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, …).
Data & state: TanStack Query
Section titled “Data & state: TanStack Query”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 & SSR
Section titled “Cache Components & SSR”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/>behindSuspense+connection(), sodehydrate()’s internalDate.now()is deferred to request time and children still prerender statically. - Relative timestamps —
<TimeAgo>uses the shareduseMountedhook (useSyncExternalStorewith afalseserver 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
DataTableusesuseReactTable, butDataTableSkeleton(rendered inloading.tsx) derives columns directly to avoiduseReactTable’s internalDate.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
useSuspenseQueryand suspend into their ownloading.tsx. Parallel routes (@slotfolders) each own their fetch and boundary. ReactQueryStreamedHydration(inproviders.tsx) streams queries resolved during the SSR pass into the client cache, using the query client’s superjson dehydrate/hydrate options. This covers the gapHydrateClientcan’t:HydrateClienttransfers RSC-layer prefetches, while client components’useSuspenseQueryfetches 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.
Workflow status over SSE
Section titled “Workflow status over SSE”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, yieldingdata:payloads and skipping heartbeat/comment lines.watchStatus(path, signal)— consumes one watch route. It requestsaccept: 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 onewatchStatusconnection per id, merges them into aRecord<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).
Auth & role gating
Section titled “Auth & role gating”- JWT cookie (
jwt), verified locally withjose(HS256,JWT_SECRET) insrc/lib/session.ts.verifySessionreturnsSessionClaims(userID,orgID,orgType) orundefined— it no longer inspects the JWTpurposeclaim (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 insrc/pages.ts. It readsorgTypefrom 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.