31 October 2024

Real-Time with Nuxt 3: A Guide to WebSocket Integration

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

Enable WebSocket support in Nuxt:

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.

Author Nitro route to handle WebSockets

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.

  • open: Fires when a new WebSocket connection is successfully established.
  • message: Activates each time a message is received, making it ideal for handling incoming data.
  • close: Occurs when a WebSocket connection closes, useful for cleanup or logging.
  • error: Fires upon encountering connection errors, allowing error handling or diagnostics.

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.

Peer Object

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.

Message Object

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.

WebSockets on Client-side

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!

Related Content