trpc-nuxt-module
packages/trpc-nuxt-module (npm trpc-nuxt-module) joins tRPC to Nuxt. Registered in nuxt.config with the file that exports the router and the file that exports the context factory, it generates the HTTP handler at the configured endpoint and, when asked, the WebSocket handler Nitro serves; auto-imports createTRPCNuxtClient, the two HTTP links and the two key helpers; and decorates every procedure on the client with useQuery, useLazyQuery, useMutation and useSubscription, each a composable over Nuxt's own useAsyncData. Nothing about tRPC's protocol is written in the package: the handlers are tRPC's fetch and WebSocket adapters, and the links are tRPC's own with a fetch chosen for where they run.
It replaced trpc-nuxt, which made every app hand-write its tRPC route, its socket bridge, a client plugin and a transpile entry, and is the second worked example of the absorption flow in dependency admission, after trpc-msw.
How a call travels
flowchart TD
page["page or component<br/>$trpc.x.useQuery()"]
asyncData["useAsyncData<br/>key follows the input"]
links["the app's link chain<br/>logger, offline, error"]
where{"Where is it running?"}
eventFetch["httpLink / httpBatchLink<br/>fetch = event.fetch"]
globalFetch["httpLink / httpBatchLink<br/>global fetch, absolute url"]
wsLink["wsLink"]
kind{"Subscription?"}
handler["createTRPCEventHandler<br/>Request from the cached raw body"]
bridge["createTRPCWebSocketHandler<br/>each peer presented as a ws client"]
hooks["Nitro runtime hooks<br/>webSocket:open / webSocket:close"]
router["the app's router<br/>tRPC's own adapters"]
page --> asyncData --> links --> where
where -->|"server render"| eventFetch
where -->|browser| kind
kind -->|no| globalFetch
kind -->|yes| wsLink
eventFetch -->|"in-process, headers forwarded"| handler
globalFetch -->|network| handler
wsLink -->|"crossws peer"| bridge
bridge --> hooks
handler --> router
bridge --> router
- Server rendering sends through the request event.
event.fetchis Nitro'sfetchWithEventover its in-process fetch: the incoming request's headers are forwarded, no socket is opened, and the answer is a realResponsewith its headers intact. The link reads the event when it is built, inside the app's context, because a batch sends a tick later when that context is gone. - The browser sends through the global
fetchagainst an absolute url, which is also what a network interceptor sees, so a test answers the real links, batching included, at the network (trpc-msw). - The HTTP handler rebuilds the request from what h3 caches: the raw body rather than the stream, so a middleware that read the body first leaves it to be read again; the path Nitro routed on, below any
app.baseURL; and a signal aborted when the response closes early, which is what ends a streamed subscription when the client leaves. - The WebSocket handler is tRPC's
applyWSSHandlerover crossws. tRPC drives awsserver, so the handler presents one and awsclient per peer — the shape trpc-msw presents an msw connection in — and replays each crossws hook onto the client it belongs to. As a connection opens and closes it calls thetrpc-nuxt-module:webSocket:openand:closeNitro runtime hooks with the connection's context, which the app'sserver/plugins/webSocketConnection.tsanswers with the user's connect and disconnect, and on Nitro'scloseit broadcasts tRPC's reconnect notice so clients resubscribe rather than fail. - A query's key follows its input. The
useAsyncDatakey is a getter over the procedure path and a hash of the input, so a changed input is fetched and cached under its own key; a fixedqueryKeywatches the input instead.
Registering it
The module is registered by its options — the router and context as the file and export name each lives under, resolved through Nuxt's aliases, the HTTP endpoint, and optionally the WebSocket endpoint with tRPC's keep-alive. The generated handlers import both from there, and the type template reads the router's context type for the hooks. A value the module owns itself — the hook names — is imported by the generated handler from the module's runtime rather than written into it, so the only literals spliced into generated code are the app's own options; CodeQL's js/bad-code-sanitization fails main on a splice of the module's constants. Inside this repository it is registered by the path to its source module rather than its package name: nuxt prepare loads every module from the app's postinstall, before any workspace package is built, and a package name resolves to a dist a fresh clone does not have yet. That rule, and the package shape it belongs to, is Nuxt module packages.
The app keeps only what is its own: app/plugins/trpc.ts composes its logger, offline and error links around the module's transport links and provides the client createTRPCNuxtClient returns.
Upstream
All of trpc-nuxt's tracker is below, grouped where several items share one answer, and graded by the triage that page defines; each defect is proven by the named test in packages/trpc-nuxt-module/src — the event handler, WebSocket handler, client and link suites, or the type tests beside the models.
| Upstream | Verdict | Proof |
|---|---|---|
| PR #262, PR #261, #260, #117, PR #80, #43 | In scope — defect | h3 is a peer and the context factory takes its own H3Event; "#260 hands the context factory h3's own H3Event, which Nitro augments" |
| PR #257, PR #256, #255, PR #235, #233, PR #115, #113, #22, #20, #5 | In scope — defect | "#255 #233 types a query's data, undefined until it arrives" |
| #250, PR #248, #247, #242, PR #241, #240, #226, #181, #137, #133, #56, #48, #11, #1 | In scope — defect | The runtime imports only published packages (nuxt/app, h3, vue), never #imports, and Nuxt transpiles a module's own root, so no build.transpile entry exists; publint and attw gate every build |
| PR #239 | In scope — defect | "#239 fetches a changed input under its own key" — the useAsyncData key is a getter over the input |
| #224 | In scope — defect | "#224 rejects a mutate whose mutation failed" |
| #221 | In scope — defect | "#221 answers under an app base url" — the request is rebuilt from the path Nitro routed on, below app.baseURL |
| PR #219, #218 | In scope — defect | The request body is the raw one h3 caches, never a re-read stream; "#215 answers a mutation whose body a middleware read first" |
| #215 | In scope — defect | Split. The hang is the handler's and h3 1.15 already reuses a cached body for the stream upstream read; the handler reads that cached raw body itself, pinned by "#215 answers a mutation whose body a middleware read first". The filter is nuxt-security's — out of scope, measured in configuration/security.test.ts and recorded on security posture |
| PR #213, #210 | In scope — defect | "#210 types a subscription's data" |
| PR #208, PR #194, #190 | In scope — defect | "#190 takes a mutation's input and resolves with its output" |
| #191 | In scope — defect | "#191 aborts a procedure's signal when the client goes away" |
| #175, #72 | In scope — defect | "#175 sends through the request event during server rendering" — event.fetch returns a real Response, headers included, with no wrapper between it and tRPC |
| #170, #70, #46, PR #28, PR #25, #24, #21, #15, #14, #4, #2 | In scope — defect | Server rendering fetches in-process through event.fetch, which forwards the incoming request's headers; "#175 sends through the request event during server rendering" |
| PR #163, #162, #92, #78 | In scope — defect | "#92 #162 types a query's data by its transform and its default" |
| PR #160, PR #159, PR #152, #144, #143, PR #53 | In scope — defect | No ofetch in the transport, so there is no FetchError to detect: tRPC reads the error body from the response itself |
| PR #149, PR #140, PR #138, PR #124, #123, PR #88, #87 | In scope — defect | Typed on tRPC's public types only — the links take HTTPLinkOptions and HTTPBatchLinkOptions as they are |
| PR #76 | In scope — defect | The proxy hands a call's input and its options on together; "#234 subscribes, and unsubscribes once disabled" |
| #35 | In scope — defect | Both engines, Nuxt, h3 and crossws are peers |
| #253 | In scope — feature | "#253 runs no query while disabled" — enabled is useAsyncData's own |
| #237, PR #236, #225, #60, #50, #23 | In scope — feature | A Nuxt 4 module (meta.compatibility), registered like any other |
| #234 | In scope — feature | "#234 subscribes, and unsubscribes once disabled" |
| PR #228, PR #227 | In scope — feature | "#227 leaves the response a procedure sent through the event" |
| #212, #145, #64, #41, PR #36, PR #33, #19, #13 | In scope — feature | "answers a batch through the transformer in both directions" |
| PR #205, PR #131, PR #12, PR #7 | In scope — feature | getQueryKey and getMutationKey; "#239 fetches a changed input under its own key" reads the cache through getQueryKey |
| PR #199, PR #197, PR #196, PR #188, PR #180, #169, PR #155, #148, PR #139, PR #136, #135, #67, #40, #38, #29 | In scope — feature | tRPC 11 on its fetch adapter; the peer range is tRPC 11's |
| #182, PR #132, #119, #57, #39, PR #32, #9 | In scope — feature | useMutation and the vanilla query, mutate and subscribe; "#224 rejects a mutate whose mutation failed" |
| #173, #89 | In scope — feature | "#89 types a lazy query as a query" — useLazyQuery is useQuery with lazy: true |
| PR #167, #166 | In scope — feature | watch: false in useQuery's own options, which useAsyncData honours at runtime and leaves out of its type |
| #164, #156, #82, #45, #17 | In scope — feature | The WebSocket handler the package never shipped; "#156 streams a subscription over a WebSocket and hands each end of the connection its context" |
| PR #151, #147, #146, #85, #31 | In scope — feature | A ref or a getter as input is watched through the key; "#239 fetches a changed input under its own key" |
| #106 | In scope — feature | "#106 caches under a given query key" |
| #93 | In scope — feature | createTRPCEventHandler takes any h3 event, so it serves a Nitro app without Nuxt |
| PR #245, #189 | Out of scope | Throughput under load is the deployment's and tRPC's, not the adapter's |
| #232 | Out of scope | CORS is the app's own middleware, as the thread shows |
| #217, PR #65, #63, #27 | Out of scope | TanStack Query is its own integration; the owner declined it for Nuxt's built-in data layer |
| #192 | Out of scope | Nitro's async context, as the answering comment shows |
| #154, #153, #118 | Out of scope | Caching and global useAsyncData defaults are Nuxt's (route rules, clearNuxtData); answered in the threads |
| PR #263, PR #259, PR #258, PR #254, PR #249, PR #244, PR #243, PR #238, PR #230, PR #229, PR #222, PR #220, PR #214, PR #211, PR #209, PR #207, PR #206, PR #202, PR #201, PR #200, PR #198, PR #195, PR #187, PR #186, PR #185, PR #184, PR #157, PR #120, PR #83, PR #34, PR #26 | False positive | Release, dependency-bump and build-tooling pull requests carrying no behaviour of their own |
| #252, #251, PR #246, PR #231, #178, PR #177, PR #142, PR #141, PR #134, #130, PR #127, PR #126, #125, #114, PR #108, PR #86, PR #84, #81, PR #75, PR #58, PR #54, PR #52, PR #51, PR #47, PR #44, PR #42, PR #37, PR #30 | False positive | Upstream's documentation site, playground and agent files; this package documents itself in its README and here |
| #223, PR #204, PR #203 | False positive | Answered in the thread: tRPC's own httpBatchStreamLink is the streaming link; the package ships no copy of it |
| #193, #128, #59 | False positive | Answered: an error link, the vue:error hook, or a try around the vanilla call |
| #183 | False positive | No reproduction; the answering comment traces it to shared cache keys, which queryKey and mutationKey set |
| #179, #171, #122, #111, #91, PR #68, PR #62, PR #61, #55 | False positive | Usage and editor questions answered in their threads; the client's type comes from the plugin that provides it |
| #176, #161, #129, #112, #110, #105, #104, #90, #79, #77, #73, #71, #69, #66, #49, #18, #16, PR #10, #8, #6, #3 | False positive | Setup and usage questions, or self-resolved, each closed in its thread |
| #74 | False positive | Fixed by a later release: tRPC's own recursive proxy answers toJSON, so serialising the client no longer calls a procedure |
Never ported: ofetch options on the links (its fetch options and header picking), which configured a transport the links no longer use — tRPC's own fetch and headers options are the seam, and event.fetch forwards the request's headers itself; a default link url, since the endpoint is the module's option and a default the links did not share with it would disagree the day it changed; and the upstream abort-on-unmount flag, since useAsyncData aborts a superseded or unmounted fetch through the signal it hands the handler.
Notes
- The handler costs more than tRPC's bare fetch adapter, and more than the upstream handler in-process: reading the cached raw body and arming the abort signal are what fix the middleware-read body and the leaked subscription, and each is microseconds against a procedure's own work. The committed
createTRPCEventHandler.bench.mdbeside the handler is the number. - Instantiation budgets for the client's types are not asserted yet.
@ark/attestpassed the admission gate but cannot count instantiations through this repository's tsgo-backedtypescript; the type tests carry the@TODOthat says what waits.
Key files
| File | Role |
|---|---|
packages/trpc-nuxt-module/src/module.ts | The module — options, generated handlers, the hooks' type template, imports |
packages/trpc-nuxt-module/src/runtime/server/createTRPCEventHandler.ts | The HTTP handler over tRPC's fetch adapter |
packages/trpc-nuxt-module/src/runtime/server/services/toRequest.ts | The request rebuilt from the cached raw body, the routed path and a signal |
packages/trpc-nuxt-module/src/runtime/server/createTRPCWebSocketHandler.ts | The WebSocket handler over tRPC's applyWSSHandler and crossws |
packages/trpc-nuxt-module/src/runtime/server/models/PeerWebSocketAdapter.ts | A crossws peer presented as the socket tRPC's handler drives |
packages/trpc-nuxt-module/src/runtime/client/createTRPCNuxtClient.ts | The client proxy and the composables it decorates each procedure with |
packages/trpc-nuxt-module/src/runtime/client/services/getLinkFetch.ts | event.fetch during server rendering, the global fetch in the browser |
apps/web/configuration/modules.ts | The app's registration — router, context, endpoints and keep-alive |
apps/web/app/plugins/trpc.ts | The app's link chain around the module's client |
apps/web/server/plugins/webSocketConnection.ts | The connect and disconnect callers on the WebSocket hooks |
Sources
- wobsoriano/trpc-nuxt — the package replaced, its issues and pull requests.
- Module author guide, module anatomy, recipes and best practices (Nuxt) —
defineNuxtModule, the runtime directory, server and type templates, prefixed runtime hooks. - useAsyncData (Nuxt) — the reactive key,
enabled, and the abort signal the handler receives. - useRequestEvent (Nuxt) — the event whose
fetchis the server-rendering transport. - WebSocket (Nitro) —
defineWebSocketHandlerand theexperimental.websocketflag the module sets. - Fetch adapter and WebSockets (tRPC) — the two handlers the module wraps.
- nuxt-security XSS validator — the filter whose semantics keep it off.