WebSockets provide a persistent, bidirectional communication channel between a client (e.g., browser) and server, allowing real-time data exchange. Unlike traditional HTTP requests, WebSockets maintain a connection, so data can flow continuously without repeated requests. This makes them ideal for use-cases like live chat applications, multiplayer online games, collaborative editing tools (e.g., Google Docs), and real-time stock trading platforms, where instant, responsive data sharing is critical. WebSockets offer efficient, seamless data updates by sending only what changes, reducing bandwidth and latency compared to polling methods.
This guide introduces WebSocket basics and shows you how to build a real-time communication layer for a two-player game in Nuxt 3. You’ll learn to set up WebSocket connections so players can find, connect, and communicate with each other seamlessly. While this article focuses on the WebSocket integration aspect, it doesn’t cover game logic, databases, or deployment processes. Those topics are reserved for future exploration. For now, the aim is to give you a solid understanding of adding WebSockets to a Nuxt 3 project.
Here's the Github Repo
As of writing this article, WebSocket is an experiemental feature in Nuxt, so you need to enable it through Nuxt configuration to use it in your Nuxt project.
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: {
websocket: true
}
}
})
With experimental.websocket
enabled, now we can dive into the Nitro layer of Nuxt to set up a WebSocket handler. Here, we can choose between creating an API endpoint or a Nitro route to manage WebSocket connections. I’ve chosen the Nitro route approach, that means I'll create a server/routes/_ws.ts
file. This file will serve as the main entry point for our WebSocket connections, allowing for smooth, real-time communication within the Nuxt 3 application.
Instead of defineEventHandler
that we normally use for creating the Nitro API endpoint, here, we will use defineWebSocketHandler
.
The reason why we use defineWebSocketHandler
over defineEventHandler
in Nuxt, is that it specifically tailors the server route to WebSocket connections, ensuring the handler automatically supports WebSocket-specific events like open
, message
, and close
. Unlike defineEventHandler
, which is designed for standard HTTP events, defineWebSocketHandler
provides a structured WebSocket API, allowing smoother handling of real-time interactions and connection lifecycle events within our Nuxt project.
You can see how the code below makes WebSocket setup pretty straight forward by providing built-in methods for managing connections and messages directly.
Nitro relies on H3 WebSockets to offer a runtime-agnostic WebSocket API, powered by CrossWS, which also works outside of Nuxt. You can see an example here. Plus, you can bring type definitions from crossws
to fully type your custom WebSocket logic.
// server/routes/_ws.ts
import type { Peer, Message } from 'crossws';
export default defineWebSocketHandler({
open(peer: Peer) {},
message(peer: Peer, message: Message) {},
close(peer: Peer) {}
});
When working with WebSockets, there are four main hooks you'll frequently use: open
, message
, close
and error
. These hooks help manage the connection lifecycle and handle incoming data.
In addition to these, crossws
provides one more hook called upgrade
which triggers when a WebSocket connection upgrade is requested, allowing pre-connection checks or authentication.
All of these hooks provide us with a peer
object that helps us connect and interact with other connected clients. If we hit the route in the browser, nothing happens just yet. However, peer
object helps us change that. peer
object provides three key methods for communicating with other connected clients, 1) peer.send()
, 2) peer.publish()
and peer.subscribe()
.
peer.send()
and peer.publish()
seem similar but the difference between these two methods lies in their scope:
peer.send()
: Sends a direct message to a specific peer, typically used for one-on-one communication within a WebSocket connection. For example, when someone connects to a server, we can send that particular client a welcome message via open()
hook.// server/routes/_ws.ts
export default defineWebSocketHandler({
open(peer: Peer) {
peer.send(JSON.stringify({ type: 'welcome', message: 'Welcome to the Matrix!' }));
}
})
peer.publish()
: Broadcasts a message to all peers subscribed to a particular channel or topic, making it ideal for sending events or updates to multiple clients at once within a group or room context. When someone joins a particular channel, or room as we know it, we can broadcast that event to everyone who is on that channel using peer.publish()
hook.// server/routes/_ws.ts
export default defineWebSocketHandler({
open(peer: Peer) {
// Send welcome to the new client
peer.send(JSON.stringify({ type: 'welcome', message: 'Welcome to the server!' }));
// Join new client to the "matrix" channel
peer.subscribe("matrix");
// Notify every other connected client
peer.publish("matrix", `[system] ${peer.toString()} has joined the Matrix!`);
}
})
In short, send
is used for direct, individual messages, while publish
is ideal for group-wide communication. Both functions require string inputs: send
takes one string argument, and publish
requires the second argument as a string. This means you’ll often use JSON.stringify
to format your data correctly.
The reverse of JSON.stringify
is JSON.parse
, which converts incoming string data back into JSON so it’s readable and usable in the backend. Whenever a client sends data from a real-time app, we typically receive it as a string that needs parsing to access and work with specific user details.
This data arrives through the message
argument in WebSocket handlers. The second argument of the message
event is also called message
and holds information sent from the frontend.
For instance, if a user sends a join_game
message type, we know they're signaling to join the game. The message(peer, message)
hook on the backend processes this, allowing us to respond appropriately.
export default defineWebSocketHandler({
// open() {}
// ....
message(peer: Peer, message: Message) {
// data sent by the client
const data = JSON.parse(message.toString());
// waitingQueue is where players waits if there's no one to pair with
if (data.type === 'join_game') {
if (waitingQueue.length > 0) {
// take opponent from the queue
const opponent = waitingQueue.shift();
// create a private room/channel id for two players to connect
const gameId = `game_${Date.now()}`;
// Subscribe player to the private game room
peer.subscribe(gameId); // Player 1 joins the private room
opponent?.peer.subscribe(gameId); // Player 2 joins the private room
// Notify each player with their role and game ID
peer.publish(gameId, JSON.stringify({
type: 'start_game',
message: `You are player 1. Game has started! You are subscribed to the game room: ${gameId}.`
}));
opponent?.peer.publish(gameId, JSON.stringify({
type: 'start_game',
message: `You are player 2. Game has started! You are subscribed to the game room: ${gameId}.`
}));
} else {
// if no one is available to pair with
// add client in a waiting queue
waitingQueue.push({ id: playerId, peer: peer });
}
}
})
})
In our example, we use overall three hooks, open
, message
and close
.
open(peer)
handles the initial connection when a player joins. We can add any welcome or initial setup messages here.message(peer, message)
processes each message received from the client. In our case, when a player sends a join_game
message, it either matches them with an opponent from waitingQueue
or places them in the waitingQueue
.close(peer)
then cleans up resources when a player disconnects. It removes the player from the waitingQueue
to ensure the system doesn’t try to match a disconnected player.To access the WebSocket hooks we set up in Nitro on the client side, we’ll use a Vue component. This lets us call the backend WebSocket events and interact with them directly from the frontend.
While you could manually manage WebSocket connections using the Native WebSocket API, the Vue community offers a more streamlined option with Ant Fu's composable, useWebSocket. This reactive WebSocket client automates connections (open()
), closures (close()
), and even automatic reconnections when needed, making real-time data handling far simpler.
<!-- components/Chat.client.vue -->
<script setup>
import { useWebSocket } from '@vueuse/core'
const { status, data, send, open, close } = useWebSocket('ws://localhost:3000/_ws')
</script>
To set up the frontend WebSocket connection, simply pass the WebSocket route (/_ws
) created in Nitro to the useWebSocket
composable. This composable will handle all necessary WebSocket methods and data, making them accessible within your Vue component.
Since WebSocket interactions occur on the client, make sure your Vue component is client-rendered (e.g., Chat.client.vue
). Naming it this way is essential, as Nuxt uses suffixes like .client
to control where components are rendered. For more, see suffixes and prefixes in Nuxt.
<!-- components/Chat.client.vue -->
<script setup>
// ...
function sendMessage() {
send(JSON.stringify({ type: 'game_chat', gameId: store.gameId, message: message.value }));
}
function joinGame() {
send(JSON.stringify({ type: 'join_game' }));
}
</script>
In the example code above, send()
transmits a message alongside the gameId
and the type
of the message, enabling the backend to route the data appropriately for each game session. This setup keeps messages organized and game-specific, ensuring each player gets the correct updates. For a full view of how the component operates and how messages are structured, check out the full Vue component here.
In this guide, we covered how to add WebSocket functionality to a Nuxt 3 project, focusing on creating a real-time communication layer for a two-player game. From setting up WebSocket support in Nuxt’s Nitro layer to handling events with peer.send()
and peer.publish()
, you now have the foundation for real-time interactions. While this article didn’t touch on game logic or deployment, you’re now equipped to explore the possibilities of WebSocket-driven features in Nuxt 3.
If you’re interested in following my journey as I write my Nuxt book and want to see how the final book layout turns out, you can follow me on X and NuxtDojo. I try to share an update or two every month with chapter ideas, tips, and insights about the book’s progress. Don’t miss out — subscribe and stay in the loop!