
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.

Get notified when we release new tutorials, lessons, and other expert Nuxt content.
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.
Our chat application features:
Just something simple. No over the top features for now. π
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.

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.

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.
First, let's create a new Nuxt 4 project:
npx nuxi@latest init nuxt-chat-app
cd nuxt-chat-app
npm install
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
Update your nuxt.config.ts file:
export default defineNuxtConfig({
devtools: { enabled: true },
modules: ['@nuxtjs/tailwindcss'],
css: ['~/assets/css/main.css'],
nitro: {
experimental: {
wasm: true
}
}
})
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
+ },
+ }
})
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);
}
}
}));
});
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.

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>

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.
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.
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.
