/Development/20 Min Read

Building a Real-Time Chat with WebSocket and Next.js - Step by Step

Neon sign with a chat icon
Photo by Jason Leung on Unsplash

Understanding the Architecture

Before we start coding, let's understand how our real-time chat works:

Components

  • Client (React/Next.js): The user interface where users type and see messages
  • Socket.IO Server: Handles WebSocket connections and message broadcasting
  • Custom Node.js Server: Runs our Next.js app and Socket.IO together

Communication Flow

1User Types Message → Client Socket → Server Socket → All Connected Clients → Display Message

Key Concepts

  1. WebSocket Connection: A persistent, bidirectional connection between client and server
  2. Events: Named messages that flow through the WebSocket (e.g., "send-message", "receive-message")
  3. Broadcasting: Server sends messages to all connected clients simultaneously

Step 1: Project Setup

First, create your Next.js application:

1npx create-next-app@latest <your-project-name>
2cd <your-project-name>

Choose the following options when prompted:

  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Optional
  • App Router: Yes
  • src/ directory: Yes

What this does: Sets up a modern Next.js project with TypeScript support, which gives us type safety and better development experience.

Step 2: Installing Dependencies

Install Socket.IO for both server and client:

1npm install socket.io socket.io-client

Why these packages?

  • socket.io: The server-side library that manages WebSocket connections
  • socket.io-client: The client-side library that connects to the Socket.IO server

Step 3: Creating the Custom Server

Create a file called `server.js` in your project root:

server.jsjavascript
1const { createServer } = require('http')
2const { parse } = require('url')
3const next = require('next')
4const { Server } = require('socket.io')
5
6const dev = process.env.NODE_ENV !== 'production'
7const hostname = process.env.HOSTNAME || 'localhost'
8const port = parseInt(process.env.PORT || '3000', 10)
9
10const app = next({ dev, hostname, port })
11const handler = app.getRequestHandler()
12
13app.prepare().then(() => {
14  const httpServer = createServer(async (req, res) => {
15    try {
16      const parsedUrl = parse(req.url, true)
17      await handler(req, res, parsedUrl)
18    } catch (err) {
19      console.error('Error occurred handling', req.url, err)
20      res.statusCode = 500
21      res.end('internal server error')
22    }
23  })
24
25  const io = new Server(httpServer, {
26    path: '/api/socket',
27    addTrailingSlash: false,
28  })
29
30  io.on('connection', (socket) => {
31    console.log('Client connected:', socket.id)
32
33    socket.on('send-message', (data) => {
34      console.log('Message received:', data)
35      io.emit('receive-message', data)
36    })
37
38    socket.on('disconnect', () => {
39      console.log('Client disconnected:', socket.id)
40    })
41  })
42
43  httpServer.listen(port, hostname, (err) => {
44    if (err) throw err
45    console.log(`> Ready on http://${hostname}:${port}`)
46  })
47})

Understanding this code:

  • HTTP Server Creation: We create a custom HTTP server instead of using Next.js's default server
1const httpServer = createServer(async (req, res) => { ... })
  • Socket.IO Initialization: We attach Socket.IO to our HTTP server
1const io = new Server(httpServer, {
2   path: '/api/socket',  // Custom endpoint for WebSocket connections
3})
  • Connection Event: When a client connects, we get a socket object
1io.on('connection', (socket) => {
2     // socket represents this specific client
3   })
  • Message Handling: We listen for "send-message" events from clients
1socket.on('send-message', (data) => {
2   // Broadcast to ALL clients including sender
3   io.emit('receive-message', data)
4})

Key Point: The `io.emit()` broadcasts to ALL connected clients, creating the real-time effect.

Step 4: Creating the Socket.IO API Route

Create `src/app/api/socket.ts`:

src/app/api/socket.tstypescript
1import { Server as NetServer } from 'http'
2import { NextApiResponse } from 'next'
3import { Server as ServerIO } from 'socket.io'
4
5export const config = {
6  api: {
7    bodyParser: false,
8  },
9}
10
11type NextApiResponseServerIO = NextApiResponse & {
12  socket: {
13    server: NetServer & {
14      io?: ServerIO
15    }
16  }
17}
18
19interface Message {
20  user: string
21  message: string
22  timestamp: number
23}
24
25export default function SocketHandler(req: any, res: NextApiResponseServerIO) {
26  if (!res.socket.server.io) {
27    console.log('Initializing Socket.io...')
28
29    const io = new ServerIO(res.socket.server as any, {
30      path: '/api/socket',
31      addTrailingSlash: false,
32    })
33
34    io.on('connection', (socket) => {
35      console.log('Client connected:', socket.id)
36
37      socket.on('send-message', (data: Message) => {
38        io.emit('receive-message', data)
39      })
40
41      socket.on('disconnect', () => {
42        console.log('Client disconnected:', socket.id)
43      })
44    })
45
46    res.socket.server.io = io
47  } else {
48    console.log('Socket.io already running')
49  }
50
51  res.end()
52}

Why this file?

This API route serves as an initialization endpoint. When the client makes a fetch request to `/api/socket`, it ensures the Socket.IO server is initialized and attached to the Next.js server.

The Message Interface: Defines the structure of messages flowing through our app:

1interface Message {
2  user: string      // Who sent it
3  message: string   // The content
4  timestamp: number // When it was sent
5}

Step 5: Building the Chat Component - Part 1: Structure

Create `src/components/Chat/index.tsx` and start with the basic structure:

src/components/Chat/index.tsxtypescript
1'use client'
2
3import { useEffect, useState, useRef } from 'react'
4import { io, Socket } from 'socket.io-client'
5
6interface Message {
7  user: string
8  message: string
9  timestamp: number
10}
11
12const Chat = () => {
13  // State management
14  const [messages, setMessages] = useState<Message[]>([])
15  const [inputMessage, setInputMessage] = useState('')
16  const [username, setUsername] = useState('')
17  const [isConnected, setIsConnected] = useState(false)
18
19  // Refs
20  const socketRef = useRef<Socket | null>(null)
21  const messagesEndRef = useRef<HTMLDivElement>(null)
22
23  // We'll add more code here in next steps
24
25  return <div>Chat Component</div>
26}
27
28export default Chat

Understanding the State:

  • messages: Array of all chat messages
  • inputMessage: Current text in the input field
  • username: Current user's name
  • isConnected: Whether WebSocket is connected
  • socketRef: Reference to Socket.IO client instance (persists across re-renders)
  • messagesEndRef: Reference to scroll to latest message

Step 6: Building the Chat Component - Part 2: WebSocket Connection

Add the WebSocket initialization inside your Chat component:

src/components/Chat/index.tsxtypescript
1useEffect(() => {
2  const initSocket = async () => {
3    // Initialize the Socket.IO server via API route
4    await fetch('/api/socket')
5
6    // Create Socket.IO client connection
7    socketRef.current = io({
8      path: '/api/socket',
9    })
10
11    // Handle successful connection
12    socketRef.current.on('connect', () => {
13      console.log('Connected to server')
14      setIsConnected(true)
15    })
16
17    // Handle incoming messages
18    socketRef.current.on('receive-message', (data: Message) => {
19      setMessages((prev) => [...prev, data])
20    })
21
22    // Handle disconnection
23    socketRef.current.on('disconnect', () => {
24      console.log('Disconnected from server')
25      setIsConnected(false)
26    })
27  }
28
29  initSocket()
30
31  // Cleanup on unmount
32  return () => {
33    if (socketRef.current) {
34      socketRef.current.disconnect()
35    }
36  }
37}, [])

What's happening here:

  1. Fetch `/api/socket`: Ensures the Socket.IO server is initialized
  2. Create Socket Client: `io()` creates a connection to our Socket.IO server
  3. Event Listeners:
    1. connect: Fires when connection is established
    2. receive-message: Fires when server broadcasts a new message
    3. disconnect: Fires when connection is lost

Important: We use `useRef` for the socket so it doesn't get recreated on every render.

Step 7: Building the Chat Component - Part 3: Sending Messages

Add the message sending logic:

src/components/Chat/index.tsxtypescript
1// Auto-scroll to latest message
2useEffect(() => {
3  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
4}, [messages])
5
6// Send message function
7const sendMessage = (e: React.FormEvent) => {
8  e.preventDefault()
9
10  if (!inputMessage.trim() || !username.trim() || !socketRef.current) return
11
12  const messageData: Message = {
13    user: username,
14    message: inputMessage,
15    timestamp: Date.now(),
16  }
17
18  // Emit event to server
19  socketRef.current.emit('send-message', messageData)
20
21  // Clear input
22  setInputMessage('')
23}

The Message Flow:

11. User types message and hits send
22. sendMessage() creates Message object
33. socket.emit('send-message', messageData) sends to server
44. Server receives event and broadcasts with io.emit('receive-message', data)
55. ALL clients (including sender) receive 'receive-message' event
66. Message is added to messages array
77. UI re-renders showing new message

Step 8: Building the Chat Component - Part 4: UI and Styling

First, create a CSS file for styling. Create `src/components/Chat/Chat.module.css`:

src/components/Chat/Chat.module.csscss
1.container {
2  display: flex;
3  justify-content: center;
4  align-items: center;
5  min-height: 100vh;
6  background-color: #0f172a;
7  padding: 20px;
8}
9
10.chatBox {
11  width: 100%;
12  max-width: 800px;
13  height: 600px;
14  background-color: #1e293b;
15  border-radius: 12px;
16  display: flex;
17  flex-direction: column;
18  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
19}
20
21.header {
22  padding: 20px;
23  border-bottom: 1px solid #334155;
24  display: flex;
25  justify-content: space-between;
26  align-items: center;
27}
28
29.title {
30  margin: 0;
31  color: #f1f5f9;
32  font-size: 24px;
33}
34
35.status {
36  font-size: 14px;
37  font-weight: bold;
38}
39
40.statusConnected {
41  color: #4ade80;
42}
43
44.statusDisconnected {
45  color: #ef4444;
46}
47
48.usernameForm {
49  display: flex;
50  flex-direction: column;
51  justify-content: center;
52  align-items: center;
53  flex: 1;
54  gap: 20px;
55  padding: 40px;
56}
57
58.welcomeText {
59  color: #f1f5f9;
60  font-size: 28px;
61  margin: 0;
62}
63
64.usernameInput {
65  width: 100%;
66  max-width: 300px;
67  padding: 12px;
68  border-radius: 8px;
69  border: 2px solid #334155;
70  background-color: #0f172a;
71  color: #f1f5f9;
72  font-size: 16px;
73  outline: none;
74}
75
76.messagesContainer {
77  flex: 1;
78  overflow-y: auto;
79  padding: 20px;
80  display: flex;
81  flex-direction: column;
82  gap: 12px;
83}
84
85.noMessages {
86  color: #64748b;
87  text-align: center;
88  margin-top: 50px;
89}
90
91.message {
92  padding: 12px;
93  border-radius: 8px;
94  max-width: 70%;
95  word-wrap: break-word;
96}
97
98.messageOwn {
99  background-color: #3b82f6;
100  align-self: flex-end;
101}
102
103.messageOther {
104  background-color: #6b7280;
105  align-self: flex-start;
106}
107
108.messageUser {
109  font-size: 12px;
110  font-weight: bold;
111  margin-bottom: 4px;
112  color: #e2e8f0;
113}
114
115.messageText {
116  font-size: 14px;
117  color: #ffffff;
118  margin-bottom: 4px;
119}
120
121.messageTime {
122  font-size: 10px;
123  color: #cbd5e1;
124  text-align: right;
125}
126
127.form {
128  padding: 20px;
129  border-top: 1px solid #334155;
130  display: flex;
131  gap: 10px;
132}
133
134.input {
135  flex: 1;
136  padding: 12px;
137  border-radius: 8px;
138  border: 2px solid #334155;
139  background-color: #0f172a;
140  color: #f1f5f9;
141  font-size: 14px;
142  outline: none;
143}
144
145.button {
146  padding: 12px 24px;
147  border-radius: 8px;
148  border: none;
149  background-color: #3b82f6;
150  color: white;
151  font-size: 16px;
152  font-weight: bold;
153  cursor: pointer;
154}
155
156.sendButton {
157  padding: 12px 24px;
158  border-radius: 8px;
159  border: none;
160  background-color: #3b82f6;
161  color: white;
162  font-size: 14px;
163  font-weight: bold;
164  cursor: pointer;
165  transition: background-color 0.2s;
166}
167
168.sendButton:hover:not(:disabled) {
169  background-color: #2563eb;
170}
171
172.button:hover {
173  background-color: #2563eb;
174}
175
176.input:disabled,
177.sendButton:disabled {
178  opacity: 0.5;
179  cursor: not-allowed;
180}

Now update your Chat component to import and use the CSS module. At the top of `src/components/Chat/index.tsx`, add the import:

src/components/Chat/index.tsxtypescript
1import styles from './Chat.module.css'

Then update the JSX return statement to use CSS classes:

src/components/Chat/index.tsxtypescript
1return (
2  <div className={styles.container}>
3    <div className={styles.chatBox}>
4      <div className={styles.header}>
5        <h2 className={styles.title}>Real-Time Chat</h2>
6        <span className={`${styles.status} ${isConnected ? styles.statusConnected : styles.statusDisconnected}`}>
7          {isConnected ? '🟢 Connected' : '🔴 Disconnected'}
8        </span>
9      </div>
10
11      {!username ? (
12        // Username input screen
13        <div className={styles.usernameForm}>
14          <h3 className={styles.welcomeText}>Welcome!</h3>
15          <input
16            type="text"
17            placeholder="Enter your name..."
18            className={styles.usernameInput}
19            onKeyPress={(e) => {
20              if (e.key === 'Enter' && e.currentTarget.value.trim()) {
21                setUsername(e.currentTarget.value)
22              }
23            }}
24          />
25          <button
26            className={styles.button}
27            onClick={(e) => {
28              const input = e.currentTarget.previousElementSibling as HTMLInputElement
29              if (input.value.trim()) {
30                setUsername(input.value)
31              }
32            }}
33          >
34            Join
35          </button>
36        </div>
37      ) : (
38        // Chat interface
39        <>
40          <div className={styles.messagesContainer}>
41            {messages.length === 0 ? (
42              <p className={styles.noMessages}>No messages yet...</p>
43            ) : (
44              messages.map((msg, index) => (
45                <div
46                  key={index}
47                  className={`${styles.message} ${msg.user === username ? styles.messageOwn : styles.messageOther}`}
48                >
49                  <div className={styles.messageUser}>{msg.user}</div>
50                  <div className={styles.messageText}>{msg.message}</div>
51                  <div className={styles.messageTime}>
52                    {new Date(msg.timestamp).toLocaleTimeString([], {
53                      hour: '2-digit',
54                      minute: '2-digit',
55                    })}
56                  </div>
57                </div>
58              ))
59            )}
60            <div ref={messagesEndRef} />
61          </div>
62
63          <form onSubmit={sendMessage} className={styles.form}>
64            <input
65              type="text"
66              value={inputMessage}
67              onChange={(e) => setInputMessage(e.target.value)}
68              placeholder="Type your message..."
69              className={styles.input}
70              disabled={!isConnected}
71            />
72            <button
73              type="submit"
74              className={styles.sendButton}
75              disabled={!isConnected || !inputMessage.trim()}
76            >
77              Send
78            </button>
79          </form>
80        </>
81      )}
82    </div>
83  </div>
84)

Why use CSS Modules?

Scoped Styles: Class names are automatically scoped to the component, preventing conflicts
Better Organization: Separates styling logic from component logic
Better Performance: CSS is loaded separately and can be cached
Easier Maintenance: Easier to update styles without touching component code
Type Safety: TypeScript can autocomplete class names

UI Features:

Connection Status: Shows if WebSocket is connected
Username Screen: Prompts user to enter name before chatting
Message Alignment: Your messages on right (blue), others on left (gray)
Timestamps: Shows when each message was sent
Auto-scroll: Automatically scrolls to newest message

Step 9: Creating the Main Page

Update `src/app/page.tsx`:

src/app/page.tsxtypescript
1'use client'
2import React from "react"
3import Chat from "../components/Chat"
4
5export default function Home() {
6  return <Chat />
7}

Why 'use client'?: The Chat component uses hooks and WebSocket, which only work in client components, not server components.

Step 10: Configuring Package Scripts

Update your `package.json` scripts:

package.jsonjson
1"scripts": {
2  "dev": "node server.js",
3  "build": "next build",
4  "start": "NODE_ENV=production node server.js",
5  "lint": "next lint"
6}

Why change the scripts?

  • We use our custom `server.js` instead of the default Next.js server
  • This allows Socket.IO to run alongside Next.js

Run your app:

1npm run dev

Visit `http://localhost:3000` (or your configured hostname and port) and open multiple browser tabs to test real-time messaging!

Understanding the Data Flow

Let's trace a complete message journey:

1. User Sends Message

src/components/Chat/index.tsxtypescript
1// Chat Component
2const sendMessage = (e: React.FormEvent) => {
3  const messageData = {
4    user: 'User A',
5    message: 'Hello!',
6    timestamp: Date.now()
7  }
8
9  socketRef.current.emit('send-message', messageData)
10}

What happens: Client emits event through WebSocket connection

2. Server Receives Message

server.jsjavascript
1socket.on('send-message', (data) => {
2  console.log('Message received:', data)
3  // data = { user: 'User A', message: 'Hello!', timestamp: 1234567890000 }
4
5  // Broadcast to ALL connected clients
6  io.emit('receive-message', data)
7})

What happens: Server gets the message and broadcasts it to everyone

3. All Clients Receive Message

src/components/Chat/index.tsxtypescript
1// Chat Component (on all connected clients)
2socketRef.current.on('receive-message', (data: Message) => {
3  setMessages((prev) => [...prev, data])
4})

What happens: Every client (including the sender) adds message to their messages array

4. React Re-renders UI

src/components/Chat/index.tsxtypescript
1messages.map((msg, index) => (
2  <div key={index}>
3    <div>{msg.user}</div>
4    <div>{msg.message}</div>
5  </div>
6))

What happens: New message appears in everyone's chat simultaneously

Complete Connection Lifecycle

Initial Connection

11. User opens app
22. React component mounts
33. fetch('/api/socket') initializes server
44. io() creates WebSocket connection
55. Server accepts connection and assigns socket.id
66. 'connect' event fires on client
77. isConnected state set to true

Sending Messages

11. User types and clicks send
22. socket.emit('send-message', messageData)
33. Data travels through WebSocket to server
44. Server receives 'send-message' event
55. Server broadcasts with io.emit('receive-message', messageData)
66. All connected clients receive 'receive-message' event
77. Messages state updated on all clients
88. React re-renders showing new message

Disconnection

11. User closes tab or loses connection
22. WebSocket connection closes
33. Server detects disconnect
44. 'disconnect' event fires
55. Server logs: "Client disconnected: [socket.id]"

Key WebSocket Concepts

Events

Events are named messages that flow through WebSocket connections:

1// Emitting an event (sending)
2socket.emit('event-name', data)
3
4// Listening for an event (receiving)
5socket.on('event-name', (data) => {
6  // Handle received data
7})

Broadcasting Patterns

To everyone:

1io.emit('event-name', data)  // ALL clients including sender

To everyone except sender:

1socket.broadcast.emit('event-name', data)

To specific client:

1io.to(socketId).emit('event-name', data)

Rooms (Advanced)

You could extend this app to support multiple chat rooms:

1// Join a room
2socket.join('room-name')
3
4// Send to everyone in a room
5io.to('room-name').emit('event-name', data)

Debugging Tips

Check Connection Status

1console.log('Socket connected:', socketRef.current?.connected)
2console.log('Socket ID:', socketRef.current?.id)

Monitor Server Events

In `server.js`, add logging:

server.jsjavascript
1io.on('connection', (socket) => {
2  console.log('Total connections:', io.engine.clientsCount)
3
4  socket.onAny((eventName, ...args) => {
5    console.log(`Event: ${eventName}`, args)
6  })
7})

Test with Multiple Tabs

Open multiple browser tabs to your application URL (default: `http://localhost:3000`) and send messages between them to verify real-time functionality.

Common Issues and Solutions

Messages not appearing

  • Check browser console for connection errors
  • Verify Socket.IO server is running (check terminal logs)
  • Ensure path matches: `/api/socket` in both client and server

Connection keeps disconnecting

  • Check firewall settings
  • Verify your configured port is not blocked (default: 3000)
  • Look for errors in server console
  • Check if another process is using the same port

TypeScript errors

  • Ensure Socket.IO types are installed
  • Verify all interfaces match between client and server

Next Steps and Improvements

Once you have the basic chat working, consider adding:

  1. User List: Show who's currently online
  2. Typing Indicators: Show when someone is typing
  3. Message History: Store messages in a database
  4. Private Messages: Direct messages between users
  5. Rooms/Channels: Multiple chat rooms
  6. Authentication: User login system
  7. Message Editing: Edit or delete sent messages
  8. File Sharing: Send images or files
  9. Emojis and Formatting: Rich text support
  10. Read Receipts: Show who's read your messages

Conclusion

You now have a fully functional real-time chat application! The key concepts to remember:

  • WebSockets provide persistent, bidirectional communication
  • Socket.IO simplifies WebSocket implementation with events
  • Broadcasting allows one message to reach all connected clients instantly
  • Client and server stay synchronized through emitted events

The beauty of WebSocket is that once connected, data flows instantly without the overhead of HTTP requests, making real-time features feel natural and responsive.

Happy coding! 🚀