Realtime Planning Poker with Nuxt 4 + TypeScript + Nitro WebSockets + Pinia

Every realtime tutorial reaches for a service. Firebase, Supabase, Pusher, Socket.IO with its own server. But Nuxt 4 already ships a server that speaks WebSockets — Nitro, via crossws — and you almost certainly aren't using it.
This guide wires four pieces together into one app with no external dependency for realtime at all:
- Nitro WebSockets for the transport, using
defineWebSocketHandlerand topic-based pub/sub - Nuxt 4's
shared/directory so the client and the server compile against literally the same message types - Pinia for the derived client state that a raw socket message can't give you
- Nitro's
useStorageso room state lives behind a driver you can swap for Redis without touching a line of logic
Tags: Nuxt 4, Nitro, WebSockets, Pinia, TypeScript, crossws
Time to read: 14 min
What you'll build: a planning poker room. Everyone joins a URL, picks a story point card, and nobody can see anyone else's vote until someone hits Reveal. That last requirement is the interesting one — it means the server can never just broadcast its state object, because the state object contains the secret.
Why this combination
Each piece earns its place:
Nitro WebSockets are runtime-agnostic. The same hooks run on Node, Bun and Deno because crossws normalises the adapter underneath. (Cloudflare is the asterisk: the plain cloudflare adapter implements publish() as a no-op, so anything using pub/sub needs cloudflare-durable.)
The shared/ directory is a Nuxt 4 feature that quietly solves the worst part of realtime work. Message contracts drift: the server starts sending playerId, the client still reads userId, and nothing fails until runtime. Put the union types in shared/ and both ends compile against the same definition.
Pinia is doing real work here rather than being ceremony. A socket hands you a message; a component needs "has everyone voted", "what's the average", "is this consensus". Those are derived values used by several components, recomputed on every message. That's a store.
useStorage is Nitro's built-in KV layer (unstorage). It defaults to in-memory, and you mount Redis or the filesystem by changing config, not code. That indirection is the point: a module-level Map works identically today and has to be torn out the day you need persistence or a second instance.
Prerequisites
- Node.js 20+
- Basic Nuxt 4 / Vue 3 Composition API knowledge
- A terminal and two browser windows for testing
1) Scaffold the project
npx nuxi@latest init nuxt4-poker
cd nuxt4-poker
npm i pinia @pinia/nuxt @vueuse/core @vueuse/nuxt
npm i -D typescript vue-tsc
Then enable WebSockets. This is the one step people miss — Nitro's WebSocket support is behind an experimental flag and does nothing until you turn it on:
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
modules: ['@pinia/nuxt', '@vueuse/nuxt'],
nitro: {
experimental: { websocket: true },
},
})
Nitro 2 vs Nitro 3. Nuxt 4 currently ships Nitro 2, where the flag lives at
nitro.experimental.websocketanddefineWebSocketHandleris auto-imported inserver/. Standalone Nitro 3 moved it tofeatures.websocketand you import the helper fromnitro. If you're following the Nitro docs and the flag does nothing, that's why.
2) Define the contract once in shared/
Nuxt 4's shared/ directory is reachable from both the client and the server via the ~~/shared/* alias (and functions in shared/utils/ are auto-imported on both sides). One file, one source of truth:
// shared/types/poker.ts
export const DECK = ['1', '2', '3', '5', '8', '13', '?'] as const
export type Card = (typeof DECK)[number]
/** What the server keeps in storage — includes the secret votes. */
export interface ServerPlayer {
id: string
name: string
vote: Card | null
}
export interface ServerRoom {
id: string
topic: string
revealed: boolean
players: ServerPlayer[]
}
/** What every client is allowed to see. Votes are redacted until reveal. */
export interface PublicPlayer {
id: string
name: string
hasVoted: boolean
vote: Card | null
}
export interface PublicRoom {
id: string
topic: string
revealed: boolean
players: PublicPlayer[]
}
export type ClientMessage =
| { type: 'join', name: string }
| { type: 'vote', card: Card }
| { type: 'reveal' }
| { type: 'reset', topic?: string }
export type ServerMessage =
| { type: 'welcome', selfId: string }
| { type: 'room', room: PublicRoom }
| { type: 'error', message: string }
Note the deliberate split between ServerRoom and PublicRoom. This is the whole security model of the app expressed as two types, and ServerRoom never leaves the server.
Be honest about how much the compiler gives you here, though. crossws types peer.send(data: unknown) — nothing stops you handing it a ServerRoom. What actually keeps the secret in is the satisfies ServerMessage annotation on every outgoing payload, which is a discipline you have to apply, not a wall. Apply it consistently and the wall is real; skip it once and TypeScript won't save you.
Discriminated unions on both message types mean the switch statements below narrow automatically. Pair that with a default branch asserting never — as the handler does — and adding a variant becomes a compile error at every unhandled site.
3) Room state with useStorage
useStorage is auto-imported in server/. Passing a name gives you a namespaced view over Nitro's default (in-memory) storage, so keys can't collide with anything else:
// server/utils/rooms.ts
import type { PublicRoom, ServerRoom } from '~~/shared/types/poker'
const storage = () => useStorage<ServerRoom>('poker')
function emptyRoom(id: string): ServerRoom {
return { id, topic: 'Untitled story', revealed: false, players: [] }
}
export async function getRoom(id: string): Promise<ServerRoom> {
return (await storage().getItem(id)) ?? emptyRoom(id)
}
export async function setRoom(room: ServerRoom): Promise<void> {
await storage().setItem(room.id, room)
}
/** Strip votes unless the room has been revealed. */
export function toPublicRoom(room: ServerRoom): PublicRoom {
return {
id: room.id,
topic: room.topic,
revealed: room.revealed,
players: room.players.map(p => ({
id: p.id,
name: p.name,
hasVoted: p.vote !== null,
vote: room.revealed ? p.vote : null,
})),
}
}
export async function mutateRoom(
id: string,
fn: (room: ServerRoom) => void,
): Promise<ServerRoom> {
const room = await getRoom(id)
fn(room)
await setRoom(room)
return room
}
toPublicRoom is the only function in the codebase that decides what a client can see. Keeping that logic in one small, obvious place is worth more than any amount of validation scattered through the handler.
hasVoted is what makes the UI feel alive while votes are still hidden — you see a checkmark appear next to each name as people lock in, without learning anything about the number.
The default memory driver lives inside the server process, so rooms reset on restart — and in dev, on every Nitro rebuild. To persist them, install ioredis and mount a real driver. No changes to rooms.ts are required:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt', '@vueuse/nuxt'],
nitro: {
experimental: { websocket: true },
storage: {
poker: { driver: 'redis', url: process.env.REDIS_URL },
},
},
})
4) The WebSocket handler
WebSocket handlers use the same file-based routing as HTTP routes, dynamic segments included:
// server/routes/api/ws/[room].ts
import type { ClientMessage, ServerMessage } from '~~/shared/types/poker'
import { DECK } from '~~/shared/types/poker'
function roomIdFrom(url: string): string {
const { pathname } = new URL(url, 'http://localhost')
return decodeURIComponent(pathname.split('/').pop() || 'lobby')
}
const topicFor = (roomId: string) => `poker:${roomId}`
export default defineWebSocketHandler({
upgrade(request) {
request.context.roomId = roomIdFrom(request.url)
},
async open(peer) {
const roomId = peer.context.roomId as string
peer.subscribe(topicFor(roomId))
peer.send({ type: 'welcome', selfId: peer.id } satisfies ServerMessage)
const room = await mutateRoom(roomId, (r) => {
if (!r.players.some(p => p.id === peer.id))
r.players.push({ id: peer.id, name: 'Guest', vote: null })
})
broadcast(peer, room)
},
async message(peer, message) {
const roomId = peer.context.roomId as string
// Answer the client heartbeat before doing any work.
if (message.text() === 'ping') {
peer.send('pong')
return
}
let payload: ClientMessage
try {
payload = message.json<ClientMessage>()
}
catch {
peer.send({ type: 'error', message: 'Malformed message' } satisfies ServerMessage)
return
}
const room = await mutateRoom(roomId, (r) => {
const self = r.players.find(p => p.id === peer.id)
if (!self) return
switch (payload.type) {
case 'join':
if (typeof payload.name === 'string')
self.name = payload.name.trim().slice(0, 24) || 'Guest'
break
case 'vote':
if (!r.revealed && DECK.includes(payload.card)) self.vote = payload.card
break
case 'reveal':
r.revealed = true
break
case 'reset':
r.revealed = false
if (typeof payload.topic === 'string' && payload.topic.trim())
r.topic = payload.topic.trim().slice(0, 80)
for (const p of r.players) p.vote = null
break
default:
// Adding a ClientMessage variant without handling it fails here.
payload satisfies never
}
})
broadcast(peer, room)
},
async close(peer) {
const roomId = peer.context.roomId as string
const room = await mutateRoom(roomId, (r) => {
r.players = r.players.filter(p => p.id !== peer.id)
})
broadcast(peer, room)
},
})
Several things here are worth calling out.
upgrade is where you get the URL, and the only place you get it cheaply. The hooks receive a peer, not an H3 event, so there are no route params inside open or message. Parse the path once during the upgrade and stash it on request.context — it becomes peer.context for the lifetime of the connection. This is also where you'd authenticate: throw or return a Response from upgrade and the connection is rejected before it opens.
Topics need the room id baked in. In crossws 0.3 — the version Nuxt 4 currently ships — pub/sub topics are global to the server; there are no namespaces isolating them. Subscribe everyone to a bare "poker" topic and every room broadcasts into every other room. poker:${roomId} keeps them separate. (Newer crossws docs describe a namespace option and a sync backplane. Neither exists in 0.3, so don't reach for them yet.)
message.json() casts blindly. Every field it hands back is a compile-time fiction. DECK.includes(payload.card) isn't defensive-programming theatre, and neither are the typeof === 'string' checks: without them, a client sending {"type":"join","name":123} throws a TypeError inside the mutateRoom callback and rejects the whole message hook. If you'd rather not hand-roll the guards, this is the natural place to parse with Zod instead.
All mutation goes through mutateRoom. Read, mutate, write, return — the handler never touches storage directly, so there's exactly one place to add a lock or a transaction later.
5) Broadcasting — and a crossws gotcha worth knowing
Here's the helper the handler keeps calling:
// server/utils/broadcast.ts
import type { Peer } from 'crossws'
import type { ServerMessage, ServerRoom } from '~~/shared/types/poker'
export function broadcast(peer: Peer, room: ServerRoom) {
const payload = JSON.stringify({
type: 'room',
room: toPublicRoom(room),
} satisfies ServerMessage)
peer.publish(`poker:${room.id}`, payload)
peer.send(payload)
}
Two details, and the second one will cost you an hour if you hit it cold.
publish() skips the sender. It reaches every other subscriber of the topic, never the peer that called it. So the pattern is always publish-then-send: publish for everyone else, send for yourself. Forget the send and the person who just voted is the only one who doesn't see it register.
Stringify before you publish. On the Node adapter, send() and publish() disagree about what counts as binary. send() inspects the serialised value — an object becomes a JSON string, so it goes out as a text frame. publish() inspects the original argument — an object isn't a string, so it goes out as a binary frame.
Pass the same object to both and your clients receive two different things:
// Don't do this.
peer.publish(topic, message) // other clients get a Blob
peer.send(message) // the sender gets a string
In the browser, event.data is a string for the sender and a Blob for everyone else, so JSON.parse(event.data) throws for every client except the one who acted. Calling JSON.stringify yourself makes both paths emit a text frame, and the asymmetry disappears. Bun and Deno serialise both paths the same way, which is exactly what makes this one nasty: it only shows up on the runtime you probably deploy to.
Note that peer.send() on its own is safe with a raw object — that's why the welcome and error messages in the handler above pass one directly. The rule is narrower than it looks: stringify anything that also goes through publish().
6) The Pinia store
The socket gives you a room snapshot. The UI needs conclusions drawn from it:
// app/stores/poker.ts
import { defineStore } from 'pinia'
import type { Card, PublicRoom } from '~~/shared/types/poker'
export const usePokerStore = defineStore('poker', () => {
const selfId = ref<string | null>(null)
const room = ref<PublicRoom | null>(null)
const myVote = ref<Card | null>(null)
const players = computed(() => room.value?.players ?? [])
const revealed = computed(() => room.value?.revealed ?? false)
const everyoneVoted = computed(
() => players.value.length > 0 && players.value.every(p => p.hasVoted),
)
const average = computed(() => {
if (!revealed.value) return null
const numbers = players.value
.map(p => Number(p.vote))
.filter(n => Number.isFinite(n))
if (!numbers.length) return null
return Math.round((numbers.reduce((a, b) => a + b, 0) / numbers.length) * 10) / 10
})
const consensus = computed(() => {
if (!revealed.value) return false
const votes = players.value.map(p => p.vote).filter(Boolean)
return votes.length > 1 && new Set(votes).size === 1
})
function applyRoom(next: PublicRoom) {
room.value = next
// The server is the source of truth for everything except our own pending
// vote, which it deliberately never echoes back before reveal.
if (!next.revealed && !next.players.find(p => p.id === selfId.value)?.hasVoted)
myVote.value = null
}
return {
selfId,
room,
myVote,
players,
revealed,
everyoneVoted,
average,
consensus,
applyRoom,
}
})
myVote is the piece that justifies a store rather than a plain ref in the page. It's the one bit of state the server refuses to send back — redaction applies to you too — so the client has to remember its own choice locally while staying subscribed to server truth for everything else. applyRoom reconciles the two: server state wins, except for the local echo, which is cleared whenever the server says you have no pending vote.
Number('?') is NaN, which is why average filters on Number.isFinite. The ? card means "I don't know" and shouldn't drag the estimate anywhere.
7) Wiring the socket to the store
VueUse's useWebSocket handles reconnection and heartbeats; the composable's job is to translate between it and the store:
// app/composables/usePokerRoom.ts
import type { Card, ClientMessage, ServerMessage } from '~~/shared/types/poker'
export function usePokerRoom(roomId: MaybeRefOrGetter<string>) {
const store = usePokerStore()
// Build an absolute ws:// or wss:// URL from the current origin.
const url = computed(() => {
if (import.meta.server) return undefined
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${location.host}/api/ws/${encodeURIComponent(toValue(roomId))}`
})
const { status, send, open, close } = useWebSocket(url, {
immediate: false,
autoReconnect: { retries: 5, delay: 1500 },
heartbeat: { interval: 25_000, message: 'ping', responseMessage: 'pong', pongTimeout: 10_000 },
onMessage(_ws, event) {
let payload: ServerMessage
try {
payload = JSON.parse(event.data)
}
catch {
return
}
if (payload.type === 'welcome') store.selfId = payload.selfId
else if (payload.type === 'room') store.applyRoom(payload.room)
else if (payload.type === 'error') console.warn('[poker]', payload.message)
},
})
// useWebSocket is browser-only, so only connect after hydration.
onMounted(open)
onBeforeUnmount(close)
function post(message: ClientMessage) {
send(JSON.stringify(message))
}
return {
status,
join: (name: string) => post({ type: 'join', name }),
vote: (card: Card) => {
store.myVote = card
post({ type: 'vote', card })
},
reveal: () => post({ type: 'reveal' }),
reset: (topic?: string) => post({ type: 'reset', topic }),
}
}
The SSR-safety pattern is immediate: false plus onMounted(open). useWebSocket does guard itself — open() bails on the server and the url computed returns undefined there anyway — but making the connection point explicit beats relying on two layers of defensive checks lining up. The socket opens exactly once, after hydration, at a line you can point at.
The heartbeat matters more than it looks. Proxies and load balancers kill idle connections aggressively — nginx defaults to 60 seconds — and a planning poker room sits idle while people argue. A 25-second ping keeps the connection warm, and pongTimeout makes a dead connection detectable rather than silently broken, which is what triggers autoReconnect. VueUse swallows the matching pong before onMessage ever runs, so the handler never sees it. On the server side, answering ping before anything else keeps a liveness probe from costing a storage read.
One caveat on the heartbeat: the
interval/pongTimeoutshape is deprecated in current VueUse in favour of ascheduleroption. It works today, but expect to migrate.
Returning named actions instead of the raw send keeps ClientMessage construction in one file. Components call vote('8').
8) The page
<!-- app/pages/room/[id].vue -->
<script setup lang="ts">
import { DECK } from '~~/shared/types/poker'
const route = useRoute()
const roomId = computed(() => String(route.params.id))
const store = usePokerStore()
const { status, join, vote, reveal, reset } = usePokerRoom(roomId)
const name = ref('')
const nextTopic = ref('')
watch(status, (s) => {
if (s === 'OPEN' && name.value) join(name.value)
})
</script>
<template>
<main class="room">
<header>
<h1>{{ store.room?.topic ?? 'Loading…' }}</h1>
<p>Room <code>{{ roomId }}</code> — socket {{ status }}</p>
</header>
<form @submit.prevent="join(name)">
<input v-model="name" placeholder="Your name" maxlength="24">
<button type="submit">Join</button>
</form>
<section class="deck">
<button
v-for="card in DECK"
:key="card"
:disabled="store.revealed"
:aria-pressed="store.myVote === card"
@click="vote(card)"
>
{{ card }}
</button>
</section>
<ul class="players">
<li v-for="player in store.players" :key="player.id">
<span>{{ player.name }}{{ player.id === store.selfId ? ' (you)' : '' }}</span>
<strong>{{ store.revealed ? (player.vote ?? '–') : (player.hasVoted ? '✓' : '…') }}</strong>
</li>
</ul>
<footer>
<p v-if="store.revealed">
Average {{ store.average ?? 'n/a' }}
<em v-if="store.consensus">— consensus!</em>
</p>
<p v-else-if="store.everyoneVoted">Everyone has voted.</p>
<button :disabled="store.revealed" @click="reveal()">Reveal</button>
<input v-model="nextTopic" placeholder="Next story">
<button @click="reset(nextTopic); nextTopic = ''">Next round</button>
</footer>
</main>
</template>
Markup is left unstyled so the mechanics stay visible — drop Tailwind classes straight onto these elements and nothing about the data flow changes.
The watch on status is the reconnection story in three lines. autoReconnect restores the socket, but the server assigned a brand-new peer.id and knows nothing about you, so the client re-announces its name every time the connection reaches OPEN.
Notice the component never touches the socket. It reads the store and calls named actions. You could swap the whole transport for SSE and this file wouldn't change.
9) Try it
npm run dev
Open http://localhost:3000/room/sprint-42 in two windows, join with different names, and vote. You'll see checkmarks appear as people vote and the numbers stay hidden until someone hits Reveal. Open /room/other in a third window to confirm the topics really are isolated.
To be sure the redaction holds, watch the frames in DevTools → Network → filter WS → the sprint-42 request → Messages. Before reveal, every payload carries "vote": null for every player, whatever their hasVoted says. The numbers aren't hidden by CSS or by a v-if — they never reach the browser.
Then check it survives the build:
npx nuxt typecheck
npx nuxt build
node .output/server/index.mjs
The Node preset serves WebSockets on the same port as HTTP, so there's no second process and no separate socket server to deploy.
Deploying behind nginx
Standard WebSocket proxying — the upgrade headers are the part people forget:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 300s;
}
Without proxy_http_version 1.1 and the two upgrade headers, nginx downgrades the handshake and every connection fails with a non-101 status. Bumping proxy_read_timeout past your heartbeat interval stops nginx from closing quiet rooms.
One architectural caveat: crossws pub/sub is per-process. Run two instances behind a load balancer and peers on instance A never hear peers on instance B. For a single container this is a non-issue; to scale horizontally you need a backplane (publish through Redis and fan out per instance) or sticky sessions that pin a room to one instance.
Where to take it
- Auth in
upgrade. Validate a session cookie or token fromrequest.headersand reject with aResponse. Put the user id onrequest.contextand identify players by account instead of connection. - Persistence. Mount the Redis or filesystem driver shown above so rooms outlive deploys.
- Optimistic UI.
myVoteis already local, so rendering your own selection before the server confirms is a one-line change. - Spectators. A
?role=spectatorquery param read inupgrade— subscribe them to the topic but skip adding them toplayers.
Wrapping up
The realtime layer here is about 145 lines of server code and no third-party service. The combination is what makes it work: Nitro handles the transport, shared/ keeps one definition of every message, useStorage puts a swappable driver where a module global would go, and Pinia turns snapshots into the values your components actually render.
Three things to carry into your own build: publish() never reaches the sender, stringify anything that goes through it, and treat everything message.json() returns as untyped until you've checked it. Everything else is ordinary Nuxt.
Sources
- Nitro — WebSocket
- crossws — Hooks
- crossws — Pub / Sub (documents features beyond the 0.3 release Nuxt 4 ships)
- unstorage
- VueUse — useWebSocket
- Pinia





