Implementing Real-Time tRPC Subscriptions with WebSocket in Next.js 14+
Set up a type-safe WebSocket subscription in Next.js 14+ with tRPC, enabling instant data sync between server and React client.
10 Oct 2025, 14:26 UTC

Desired outcome
Establish a type-safe WebSocket connection between a Next.js 14+ application (using the App Router) and a React client so that data changes are pushed instantly via tRPC subscriptions.
Prerequisites
- Next.js 14+ with the App Router enabled.
- tRPC v11+ packages:
@trpc/server,@trpc/client,@trpc/next,@trpc/react-query. - WebSocket library such as
wsfor the server side. - Node.js runtime (v18+ recommended) to run a custom WebSocket server.
- Basic familiarity with observable patterns and TypeScript type annotations.
Server setup – a custom WebSocket endpoint
Next.js’ default serverless deployment (Vercel Edge Functions, Vercel Serverless) does not support long-living WebSocket connections. To keep a persistent WS connection you must run a custom Node.js server that upgrades HTTP requests to WebSocket and integrates with tRPC.
- Create a new file
server.tsat the project root. - Install the required packages:
- Initialize the WebSocket server and attach a tRPC handler:
- The
subscriptionmethod returns anObservable(fromrxjsor a compatible library). - Explicitly annotate the output type so TypeScript can infer it downstream.
- Clean up the interval (or any resource) in the teardown function to avoid memory leaks.
- {new Date(value).toLocaleTimeString()} – {value} ))}
- Open the browser DevTools → Network tab. A
websocketentry should appear with frames containing JSON like{"type":"subscribe","id":"...","payload":{}}. - Compile TypeScript:
npx tsc --noEmit. No type errors should appear in the router or theuseSubscriptionhook. - Open two browser tabs pointing to the same page. Changing the server-side interval should instantly reflect in both tabs, confirming real-time propagation.
- Simulate a network drop (Chrome DevTools → Offline) and verify that the client attempts reconnection (you’ll see reconnection attempts in the console).
- Reconnection strategy:
wsLinkships with exponential backoff. If you need a custom policy, pass areconnectIntervaloption. - Error handling: Use the
onErrorcallback ofuseSubscriptionto log or display user-friendly messages. - Fallback to polling: If the WebSocket remains disconnected, invalidate the related query (e.g., via
invalidateQueries) so React Query can refetch data periodically. - Heartbeat monitoring: Implement a server-side ping/pong interval to detect dead connections and cleanly close them, preventing leaks.
- The custom WebSocket server must stay alive; serverless platforms that do not support persistent connections (e.g., Vercel Edge) cannot host this setup.
- Memory leaks can occur if subscription observables are not unsubscribed. Always return a cleanup function from the observable factory.
- Batching links (httpBatchLink) do not combine subscription messages; each subscription runs over its own WebSocket connection unless you implement multiplexing.
npm i ws @trpc/server @trpc/client @trpc/server/adapters/ws
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { applyWSSHandler } from '@trpc/server/adapters/ws';
import { appRouter } from './server/routers/_app';
import { createContext } from './server/trpc';
const httpServer = createServer();
const wss = new WebSocketServer({ server: httpServer });
applyWSSHandler({
wss,
router: appRouter,
createContext,
});
httpServer.listen(4000, () => {
console.log('WebSocket server listening on port 4000');
});
Run this file with node server.ts. The server must have permission to open a network port; on Linux you may need sudo or appropriate firewall rules.
tRPC router with a subscription procedure
Define a router that includes a subscription. The procedure must return an observable that yields typed payloads.
import { router, procedure } from '@trpc/server';
import { z } from 'zod';
import { Observable } from 'rxjs';
export const appRouter = router({
subscribeToCounter: procedure
.input(z.void())
.output(z.array(z.number()))
.subscription(() => {
return new Observable((observer) => {
const interval = setInterval(() => {
observer.next([Date.now()]);
}, 1000);
return () => clearInterval(interval);
});
}),
});
Key points:
Client side – wiring the WebSocket transport
On the React side, create a wsLink that uses the ws transport and combine it with the standard httpBatchLink for queries and mutations.
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import { wsLink } from '@trpc/client/ws';
const wsLink = wsLink({
url: 'ws://localhost:4000/',
connectionParams: () => ({ token: 'secret' }),
});
const trpc = createTRPCReact({
links: [httpBatchLink, wsLink],
});
Wrap the trpc provider in a root component (App Router):
export default function Root() {
return (
);
}
Using the subscription in a component
The useSubscription hook from @trpc/react-query consumes the observable and re-renders when new data arrives.
import { useSubscription } from '@trpc/react-query';
import { appRouter } from '@/server/routers/_app';
function Counter() {
const { data, isLoading, error } = useSubscription(
appRouter.subscribeToCounter,
{
select: (input, original) => original,
}
);
if (isLoading) return Loading…
;
if (error) return Error: {error.message}
;
return (
{data?.map((value) => (
);
}
Place the Counter component anywhere in the client hierarchy; it will receive updates as soon as the server pushes a new timestamp.
Expected checks
Recovery options
Limitations and verification
To verify the final state, run the Next.js dev server (npm run dev), start the custom WS server, and open multiple tabs. Observe that updates appear instantly across tabs, and confirm that TypeScript compilation succeeds without errors.