Building a Realtime Chat Application with Nuxt and Socket.IO

Socket.IO is a powerful tool for building applications that demand instant communication. In this article, you’ll learn how to create a real-time community chat application using Nuxt 4 and Socket.IO, exploring everything from WebSocket connections to simple user management and beyond.

Charles Allotey
Nuxt 3

The Mastering Nuxt FullStack Unleashed Course is here!

Get notified when we release new tutorials, lessons, and other expert Nuxt content.

Click here to view course

Over the weekend, I decided to challenge myself. Instead of building another simple frontend project, I wanted to create something that felt alive, a web application that updates in real time, reacts instantly to user actions, and brings people together.

The idea was a community chat app where anyone could jump in, pick a username, and start talking without refreshing the page. Real-time communication has become a core feature of modern web applications. From team collaboration to lively online communities, instant interaction is what makes apps feel alive.

In this article, I’ll walk you through how I built a chat application using Nuxt 4 and Socket.IO. You’ll see how to manage real-time messaging, user presence, and persistent usernames so that your app feels dynamic and responsive.

What We'll Build

Our chat application features:

  • Username creation and persistence using localStorage
  • Real-time message broadcasting to all connected users
  • User join/leave notifications
  • Message history display
  • Responsive design for desktop and mobile

Just something simple. No over the top features for now. 😊

Why Socket.IO?

Before we dive into the project, let’s talk about Socket.IO. It’s is a library that enables low-latency, bidirectional and event-based communication between a client and a server.

Screenshot 2025-09-14 at 18-03-24 Socket.IO.png

This makes it ideal for applications where real-time interaction is essential, such as chat platforms, support systems, collaborative workspaces, live dashboards, and even gaming environments.

bidirectional-communication2.png

What sets it apart is its robust architecture that ensures stable connections, handles reconnections automatically, and provides seamless fallbacks across different browsers, making it the preferred choice for creating fluid, real-time user experiences that feel instantaneous and reliable.

Now that we have a brief info about socket.io let’s jump into building our application.

Project Setup

1. Initialize Nuxt Project

First, let's create a new Nuxt 4 project:

npx nuxi@latest init nuxt-chat-app
cd nuxt-chat-app
npm install

2. Install Required Dependencies

Now let’s install Socket.IO for real-time communication and tailwindCSS for our styling:

npm install socket.io socket.io-client
npm install @nuxtjs/tailwindcss # For styling

3. Configure Nuxt

Update your nuxt.config.ts file:

export default defineNuxtConfig({
  devtools: { enabled: true },
  modules: ['@nuxtjs/tailwindcss'],
  css: ['~/assets/css/main.css'],
  nitro: {
    experimental: {
      wasm: true
    }
  }
})

Server Setup with Socket.IO Server

Since Socket.io runs on WebSockets which in Nitro is currently experimental, we will need to be manually enable it.

// nuxt.config.js

export default defineNuxtConfig({
  devtools: {
    enabled: true
  },
+ nitro: {
+   experimental: {
+     websocket: true
+   },
+ }
})

Creating the Socket.IO Plugin

Create a server/plugins/socket.io.ts plugin to handle our WebSocket connections:

import type{NitroApp } from "nitropack";
import{Server as Engine} from "engine.io";
import{Server} from "socket.io";

interface User{id : string username : string}

interface Message {
id:
  string username : string message : string timestamp : Date
}

const users = new Map<string, User>()
const messages: Message[] = []

export default defineNitroPlugin((nitroApp: NitroApp) => {
  const engine = new Engine();
  const io = new Server({cors : {origin : "*", methods : [ "GET", "POST" ]}});

  io.bind(engine);

  io.on(
      "connection", (socket) = > {
        console.log('πŸŽ‰ New user connected:', socket.id);

        socket.on(
            'user-join', (username
                          : string) = > {
              const user : User = {id : socket.id, username : username};
              users.set(socket.id, user);
              console.log(`πŸ‘‹ ${username} joined the party !`);
              socket.emit('message-history', messages);
              socket.broadcast.emit('user-joined', {
                username : username,
                message : `${username} joined the chat`,
                timestamp : new Date()
              });
              io.emit('users-update', Array.from(users.values()));
            });

        socket.on(
            'new-message', (data
                            : {message : string}) = > {
              const user = users.get(socket.id);
              if (user) {
                const messageObj : Message = {
                  id : Date.now().toString(),
                  username : user.username,
                  message : data.message,
                  timestamp : new Date()
                };
                messages.push(messageObj);
                if (messages.length > 100) {
                  messages.shift();
                }
                io.emit('message-received', messageObj);
                console.log(`πŸ“’ ${user.username} : $ { data.message }`);
              }
            });

        socket.on(
            'disconnect', () = > {
              const user = users.get(socket.id);
              if (user) {
                users.delete(socket.id);
                console.log(`πŸ‘‹ ${user.username} left the chat`);
                socket.broadcast.emit('user-left', {
                  username : user.username,
                  message : `${user.username} left the chat`,
                  timestamp : new Date()
                });
                io.emit('users-update', Array.from(users.values()));
              }
            });
      });

  nitroApp.router.use("/socket.io/", defineEventHandler({
    handler(event) {
      engine.handleRequest(event.node.req, event.node.res);
      event._handled = true;
    },
    websocket: {
  open(peer) {
    engine.prepare(peer._internal.nodeReq);
    engine.onWebSocket(peer._internal.nodeReq, peer._internal.nodeReq.socket,
                       peer.websocket);
  }
    }
}));
});

Frontend Implementation

1. Username Setup Component

Create components/UsernameSetup.vue to handle users setting up up their username

<template>
  <div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
    <div class="max-w-md w-full space-y-8">
      <div>
        <h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
          Join the Community Chat
        </h2>
        <p class="mt-2 text-center text-sm text-gray-600">
          Choose a unique username to start chatting
        </p>
      </div>
      
      <form class="mt-8 space-y-6" @submit.prevent="joinChat">
        <div>
          <label for="username" class="sr-only">Username</label>
          <input
            id="username"
            v-model="username"
            type="text"
            required
            maxlength="20"
            class="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
            placeholder="Enter your username"
          >
        </div>
        
        <div v-if="error" class="text-red-600 text-sm">
          {{ error }}
        </div>
        
        <div>
          <button
            type="submit"
            :disabled="!username.trim() || isJoining"
            class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            {{ isJoining ? 'Joining...' : 'Join Chat' }}
          </button>
        </div>
      </form>
    </div>
  </div>
</template>

<script setup lang="ts">
const emit = defineEmits<{
  'username-set': [username: string]
}>()

const username = ref('')
const error = ref('')
const isJoining = ref(false)

const joinChat = async () => {
  if (!username.value.trim()) {
    error.value = 'Username cannot be empty'
    return
  }
  
  if (username.value.length > 20) {
    error.value = 'Username must be 20 characters or less'
    return
  }
  
  isJoining.value = true
  error.value = ''
  
  try {
    localStorage.setItem('chat-username', username.value.trim())
    console.log('Username saved to localStorage!')
    
    emit('username-set', username.value.trim())
  } catch (err) {
    error.value = 'Failed to join chat. Please try again.'
    console.error('Failed to save username:', err)
  } finally {
    isJoining.value = false
  }
}

onMounted(() => {
  const existingUsername = localStorage.getItem('chat-username')
  if (existingUsername) {
    username.value = existingUsername
    console.log('πŸŽ‰ Welcome back,', existingUsername)
  }
})
</script>

Our component creates a clean interface for users to login into the community chat by setting a unique username. Usernames are saved using localStorage integration to remember returning users.

Screenshot 2025-09-14 at 19-12-47 .png

2. Chat Interface Component

Create components/ChatRoom.vue to handle our chatroom conversations

<template>
  <div class="flex h-screen bg-gray-100">
    <!-- The community sidebar - I love seeing who's online! -->
    <div class="w-64 bg-white shadow-lg">
      <div class="p-4 bg-indigo-600 text-white">
        <h3 class="font-semibold">Online Now ({{ users.length }})</h3>
      </div>
      <div class="p-4">
        <div
          v-for="user in users"
          :key="user.id"
          class="flex items-center space-x-2 mb-2 animate-pulse"
        >
          <div class="w-2 h-2 bg-green-400 rounded-full"></div>
          <span class="text-sm font-medium">{{ user.username }}</span>
        </div>
      </div>
    </div>
    <div class="flex-1 flex flex-col">
      <!-- Header with a personal touch -->
      <div class="bg-white shadow-sm p-4 flex justify-between items-center">
        <h1 class="text-xl font-semibold">πŸš€ Community Chat</h1>
        <div class="flex items-center space-x-2">
          <span class="text-sm text-gray-600">Hey there, {{ currentUser }}! πŸ‘‹</span>
          <button
            @click="leaveChat"
            class="px-3 py-1 bg-red-500 text-white rounded text-sm hover:bg-red-600 transition-colors"
          >
            Leave Chat
          </button>
        </div>
      </div>
      <div
        ref="messagesContainer"
        class="flex-1 overflow-y-auto p-4 space-y-4 messages-container"
      >
        <div
          v-for="message in messages"
          :key="message.id"
          class="flex flex-col message-bubble"
        >
          <div
            v-if="message.type === 'system'"
            class="text-center text-gray-500 text-sm italic bg-gray-100 rounded-full py-2 px-4 mx-auto"
          >
            {{ message.message }}
          </div>
          <!-- Regular chat messages -->
          <UChatMessage v-else :content="message.message"
           :side="message.username === currentUser ? 'right' : 'left'"
            :variant="message.username === currentUser ? 'outline' : 'solid'"
            :user="{
      side: 'left',
      variant: 'solid',
      avatar: {
        src: 'https://github.com/benjamincanac.png'
      }
    }"
    :createdAt="formatTime(message.timestamp)"/>
        </div>
      </div>
      <div class="bg-white p-4 border-t shadow-lg">
        <form @submit.prevent="sendMessage" class="flex space-x-2">
          <input
            v-model="newMessage"
            type="text"
            placeholder="What's on your mind? Type here..."
            class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all"
            maxlength="500"
            @input="handleTyping"
          >
          <button
            type="submit"
            :disabled="!newMessage.trim()"
            class="px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform hover:scale-105 active:scale-95"
          >
            Send πŸš€
          </button>
        </form>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { io, Socket } from 'socket.io-client'

const props = defineProps<{
  username: string
}>()

const emit = defineEmits<{
  'leave-chat': []
}>()

interface User {
  id: string
  username: string
}

interface Message {
  id: string
  username?: string
  message: string
  timestamp: Date
  type?: 'system' | 'user'
}

const socket = ref<Socket | null>(null)
const messages = ref<Message[]>([])
const users = ref<User[]>([])
const newMessage = ref('')
const messagesContainer = ref<HTMLElement>()
const currentUser = computed(() => props.username)

const initializeSocket = () => {
  console.log('πŸ”Œ Connecting to the chat universe...')
  socket.value = io()
  
  socket.value.emit('user-join', props.username)
  
  socket.value.on('message-history', (history: Message[]) => {
    console.log('πŸ“š Loading message history...')
    messages.value = history.map(msg => ({
      ...msg,
      timestamp: new Date(msg.timestamp),
      type: 'user'
    }))
    nextTick(() => scrollToBottom())
  })
  
  socket.value.on('message-received', (message: Message) => {
    console.log('πŸ’¬ New message from', message.username)
    messages.value.push({
      ...message,
      timestamp: new Date(message.timestamp),
      type: 'user'
    })
    nextTick(() => scrollToBottom())
  })
  
  socket.value.on('user-joined', (data: any) => {
    console.log('πŸŽ‰ Someone new joined!')
    messages.value.push({
      id: Date.now().toString(),
      message: data.message,
      timestamp: new Date(data.timestamp),
      type: 'system'
    })
    nextTick(() => scrollToBottom())
  })
  

  socket.value.on('user-left', (data: any) => {
    console.log('πŸ‘‹ Someone left the chat')
    messages.value.push({
      id: Date.now().toString(),
      message: data.message,
      timestamp: new Date(data.timestamp),
      type: 'system'
    })
    nextTick(() => scrollToBottom())
  })

  socket.value.on('users-update', (usersList: User[]) => {
    console.log('User list updated:', usersList.length, 'users online')
    users.value = usersList
  })
  
  socket.value.on('connect', () => {
    console.log('Connected to chat server!')
  })
  
  socket.value.on('disconnect', () => {
    console.log('Disconnected from chat server')
  })
}

const sendMessage = () => {
  if (!newMessage.value.trim() || !socket.value) return
  
  console.log('πŸ“€ Sending message:', newMessage.value)
  socket.value.emit('new-message', {
    message: newMessage.value.trim()
  })
  
  newMessage.value = ''
}

const leaveChat = () => {
  console.log('πŸ‘‹ Leaving the chat...')
  if (socket.value) {
    socket.value.disconnect()
  }
  localStorage.removeItem('chat-username')
  emit('leave-chat')
}

const scrollToBottom = () => {
  if (messagesContainer.value) {
    messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
  }
}

const formatTime = (date: Date) => {
  return new Intl.DateTimeFormat('en-US', {
    hour: '2-digit',
    minute: '2-digit'
  }).format(date)
}

onMounted(() => {
  console.log('πŸš€ Chat room mounting for', props.username)
  initializeSocket()
})

onBeforeUnmount(() => {
  console.log('🧹 Cleaning up socket connection...')
  if (socket.value) {
    socket.value.disconnect()
  }
})
</script>

Screenshot 2025-09-14 at 21-36-40 .png

Our Chatroom component creates a real-time chat interface. The structure includes a sidebar showing online users a main chat area with system and user messages, and an input form for sending new messages.

We also include Nuxt UI's UChatMessage component to display messages with different styling based on whether they're from the current user or others, and includes a clean header with user greeting and leave chat functionality.

With Socket.IO connections and events, we are able automatically log who is joining the chat and various events such as message history, new messages, user joins/leaves, and user list updates.

3. Main Application Page

Update your pages/index.vue:

<template>
  <div>
    <UsernameSetup
      v-if="!currentUsername"
      @username-set="handleUsernameSet"
    />
    <ChatRoom
      v-else
      :username="currentUsername"
      @leave-chat="handleLeaveChat"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const currentUsername = ref('')

const handleUsernameSet = (username: string) => {
  console.log('πŸŽ‰ Username set:', username)
  currentUsername.value = username
}

const handleLeaveChat = () => {
  console.log('πŸ‘‹ User left the chat')
  currentUsername.value = ''
}

onMounted(() => {
  const existingUsername = localStorage.getItem('chat-username')
  if (existingUsername) {
    console.log('πŸ”„ Returning user detected:', existingUsername)
    currentUsername.value = existingUsername
  }
})
</script>

With all the core parts of our chat application in place, it’s time to test the feature and ensure everything is working as expected.

Username creation

Broadcast Messaging

Conclusion

In just a weekend, I built a real-time chat application with Nuxt 4 and Socket.IO that feels polished and functional. Users can join, chat, and see each other online instantly.

This project is just the beginning. The modular structure makes it easy to expand features, such as private messages, reactions, file sharing, or advanced room management. With a solid foundation in place, you can iterate and add complexity without losing maintainability.

If you want to dive deeper into full-stack development with Nuxt, check out the Mastering Nuxt Fullstack Unleashed course for a hands-on guide to building production-ready applications.

Additional Resources

Charles Allotey
Charles is a Frontend Developer at Vueschool. Has a passion for building great experiences and products using Vue.js and Nuxt.

Follow MasteringNuxt on