Navigation

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.fetch is Nitro's fetchWithEvent over its in-process fetch: the incoming request's headers are forwarded, no socket is opened, and the answer is a real Response with 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 fetch against 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 applyWSSHandler over crossws. tRPC drives a ws server, so the handler presents one and a ws client 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 the trpc-nuxt-module:webSocket:open and :close Nitro runtime hooks with the connection's context, which the app's server/plugins/webSocketConnection.ts answers with the user's connect and disconnect, and on Nitro's close it broadcasts tRPC's reconnect notice so clients resubscribe rather than fail.
  • A query's key follows its input. The useAsyncData key 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 fixed queryKey watches 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.

UpstreamVerdictProof
PR #262, PR #261, #260, #117, PR #80, #43In scope — defecth3 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, #5In 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, #1In scope — defectThe 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 #239In scope — defect"#239 fetches a changed input under its own key" — the useAsyncData key is a getter over the input
#224In scope — defect"#224 rejects a mutate whose mutation failed"
#221In scope — defect"#221 answers under an app base url" — the request is rebuilt from the path Nitro routed on, below app.baseURL
PR #219, #218In scope — defectThe request body is the raw one h3 caches, never a re-read stream; "#215 answers a mutation whose body a middleware read first"
#215In scope — defectSplit. 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, #210In scope — defect"#210 types a subscription's data"
PR #208, PR #194, #190In scope — defect"#190 takes a mutation's input and resolves with its output"
#191In scope — defect"#191 aborts a procedure's signal when the client goes away"
#175, #72In 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, #2In scope — defectServer 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, #78In scope — defect"#92 #162 types a query's data by its transform and its default"
PR #160, PR #159, PR #152, #144, #143, PR #53In scope — defectNo 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, #87In scope — defectTyped on tRPC's public types only — the links take HTTPLinkOptions and HTTPBatchLinkOptions as they are
PR #76In scope — defectThe proxy hands a call's input and its options on together; "#234 subscribes, and unsubscribes once disabled"
#35In scope — defectBoth engines, Nuxt, h3 and crossws are peers
#253In scope — feature"#253 runs no query while disabled" — enabled is useAsyncData's own
#237, PR #236, #225, #60, #50, #23In scope — featureA Nuxt 4 module (meta.compatibility), registered like any other
#234In scope — feature"#234 subscribes, and unsubscribes once disabled"
PR #228, PR #227In scope — feature"#227 leaves the response a procedure sent through the event"
#212, #145, #64, #41, PR #36, PR #33, #19, #13In scope — feature"answers a batch through the transformer in both directions"
PR #205, PR #131, PR #12, PR #7In scope — featuregetQueryKey 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, #29In scope — featuretRPC 11 on its fetch adapter; the peer range is tRPC 11's
#182, PR #132, #119, #57, #39, PR #32, #9In scope — featureuseMutation and the vanilla query, mutate and subscribe; "#224 rejects a mutate whose mutation failed"
#173, #89In scope — feature"#89 types a lazy query as a query" — useLazyQuery is useQuery with lazy: true
PR #167, #166In scope — featurewatch: false in useQuery's own options, which useAsyncData honours at runtime and leaves out of its type
#164, #156, #82, #45, #17In scope — featureThe 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, #31In scope — featureA ref or a getter as input is watched through the key; "#239 fetches a changed input under its own key"
#106In scope — feature"#106 caches under a given query key"
#93In scope — featurecreateTRPCEventHandler takes any h3 event, so it serves a Nitro app without Nuxt
PR #245, #189Out of scopeThroughput under load is the deployment's and tRPC's, not the adapter's
#232Out of scopeCORS is the app's own middleware, as the thread shows
#217, PR #65, #63, #27Out of scopeTanStack Query is its own integration; the owner declined it for Nuxt's built-in data layer
#192Out of scopeNitro's async context, as the answering comment shows
#154, #153, #118Out of scopeCaching 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 #26False positiveRelease, 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 #30False positiveUpstream's documentation site, playground and agent files; this package documents itself in its README and here
#223, PR #204, PR #203False positiveAnswered in the thread: tRPC's own httpBatchStreamLink is the streaming link; the package ships no copy of it
#193, #128, #59False positiveAnswered: an error link, the vue:error hook, or a try around the vanilla call
#183False positiveNo 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, #55False positiveUsage 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, #3False positiveSetup and usage questions, or self-resolved, each closed in its thread
#74False positiveFixed 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.md beside the handler is the number.
  • Instantiation budgets for the client's types are not asserted yet. @ark/attest passed the admission gate but cannot count instantiations through this repository's tsgo-backed typescript; the type tests carry the @TODO that says what waits.

Key files

FileRole
packages/trpc-nuxt-module/src/module.tsThe module — options, generated handlers, the hooks' type template, imports
packages/trpc-nuxt-module/src/runtime/server/createTRPCEventHandler.tsThe HTTP handler over tRPC's fetch adapter
packages/trpc-nuxt-module/src/runtime/server/services/toRequest.tsThe request rebuilt from the cached raw body, the routed path and a signal
packages/trpc-nuxt-module/src/runtime/server/createTRPCWebSocketHandler.tsThe WebSocket handler over tRPC's applyWSSHandler and crossws
packages/trpc-nuxt-module/src/runtime/server/models/PeerWebSocketAdapter.tsA crossws peer presented as the socket tRPC's handler drives
packages/trpc-nuxt-module/src/runtime/client/createTRPCNuxtClient.tsThe client proxy and the composables it decorates each procedure with
packages/trpc-nuxt-module/src/runtime/client/services/getLinkFetch.tsevent.fetch during server rendering, the global fetch in the browser
apps/web/configuration/modules.tsThe app's registration — router, context, endpoints and keep-alive
apps/web/app/plugins/trpc.tsThe app's link chain around the module's client
apps/web/server/plugins/webSocketConnection.tsThe connect and disconnect callers on the WebSocket hooks

Sources

Details

Command palette

Keyboard shortcuts