tRPC recipes
Subscribe to a typed procedure over WebSocket or SSE.
tRPC over WebSocket · Nuxt
Before you start
Section titled “Before you start”Start with an existing Nuxt app. Keep its renderer, routes and plugins. These files add one live view; Spinetab does not create your API.
Use a tRPC 11 server with a queue subscription returning a complete { open: number } value. The server router below defines that contract; mount it at /trpc-ws with tRPC's WebSocket adapter. The browser imports only its type. This standalone client recipe uses untracked full-state results; tracked events and transformers require their corresponding result handling.
The examples use same-origin URLs and declare
anonymous: true: no token is supplied, although cookies
still flow. For a separate API origin, use its URL and configure CORS or
your application's proxy. For private data, add
credentials and user scopes;
never put a secret in these files.
1. Install
Section titled “1. Install”pnpm add spinetab @trpc/client@^11.19.0 @trpc/server@^11.19.0Keep your framework's existing dependencies. See compatible versions if upgrading an older app.
2. Configure your app
Section titled “2. Configure your app”Merge this addition into your configuration; preserve existing plugins and options. Restart the dev server afterwards. The plugin bundles the worker and discovers the adapters imported below.
import { defineNuxtConfig } from "nuxt/config";
export default defineNuxtConfig({ modules: ["spinetab/nuxt"] });3. Connect and render
Section titled “3. Connect and render”Save these files together in app/components/recipe/.
reconcile: "latest" fits this full-state contract: the next complete event restores the displayed value after a gap. Use a refresh/merge policy for deltas or partial GraphQL results. Reconnecting alone does not reconstruct missed state.
import { createSpinetab } from "spinetab";
export const spinetab = createSpinetab({ anonymous: true });export type Queue = { open: number };export type QueueView = { open?: number; problem?: string };import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
// Server-side contract. Mount this router on your existing tRPC server.export const appRouter = t.router({ queue: t.procedure.subscription(async function* ({ signal }) { let open = 0; while (!signal?.aborted) { yield { open: open++ }; await new Promise<void>((resolve) => { const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", finish); resolve(); }; const timer = setTimeout(finish, 1_000); signal?.addEventListener("abort", finish, { once: true }); }); } }),});export type AppRouter = typeof appRouter;import { createTRPCClient, httpBatchLink, splitLink } from "@trpc/client";import { spinetabWsLink } from "spinetab/trpc";import { spinetab } from "./live";import type { QueueView } from "./queue-types";import type { AppRouter } from "./router";
const trpc = createTRPCClient<AppRouter>({ links: [ splitLink({ condition: (operation) => operation.type === "subscription", true: spinetabWsLink<AppRouter>({ client: spinetab, url: "/trpc-ws", reconcile: "latest", }), false: httpBatchLink({ url: "/trpc" }), }), ],});
export function watchQueue( next: (value: QueueView) => void, fail: (message: string) => void,) { const subscription = trpc.queue.subscribe(undefined, { onData: (value) => { fail(""); next(value); }, onConnectionStateChange: (state) => { if (state.error) fail(state.error.message); }, onError: (error) => fail(error.message), }); return () => subscription.unsubscribe();}<script setup lang="ts">import { onMounted, onUnmounted, ref } from "vue";import type { QueueView } from "./queue-types";import { watchQueue } from "./watch";
const data = ref<QueueView>();const error = ref("");let stop: (() => void) | undefined;onMounted(() => { stop = watchQueue( (value) => { data.value = value; error.value = ""; }, (message) => { error.value = message; }, );});onUnmounted(() => stop?.());</script>
<template> <p v-if="error" role="alert">{{ error }}</p> <p v-else-if="data?.open === undefined" role="status">Loading queue…</p> <p v-else><output>{{ data.open }}</output> open</p></template>4. Mount the view
Section titled “4. Mount the view”<script setup lang="ts">import Recipe from "../components/recipe/Recipe.vue";</script><template ><main><Recipe /></main></template>Your existing app/app.vue must render <NuxtPage />. Work starts on browser mount; no ClientOnly wrapper is needed. KeepAlive retains subscriptions until the component is disposed.
Check the result
Section titled “Check the result”Open /queue in two tabs of the same browser profile. Both should show advancing queue values. Matching subscriptions share upstream work when SharedWorker is available; fallback runs independently in each tab. Remove one view and the other should keep updating. Removing the last view releases its subscription; connection closure can follow the adapter's idle delay.