Roll Your Own Real-time Chat Server with Next.js and WebSockets

Designly
Designly
13 min readMar 10, 2024

--

Roll Your Own Real-time Chat Server with Next.js and WebSockets

Today, we’ll guide you through the process of building your own WebSocket chat server using Node.js and the ws library, paired with a Next.js client. This project is straightforward and requires only a single project directory, simplifying our work. By using the same directory for both the Next.js client app and the server, we can easily share Typescript types between them.

Before diving in, it’s important to note that this setup cannot be hosted on serverless platforms like Vercel or AWS Amplify. Instead, you’ll need a Virtual Private Server (VPS) or a dedicated server to deploy this chat server.

In this guide, we’ll walk you through setting up the project, deploying it on your server, and configuring services and NGINX to manage SSL connections. At the end of the article, you’ll find a link to the complete code repository for this project.

Chat app demo
Chat app demo

Creating Our Project

You can either clone the repo below or use the command: npx create-next-app@latest to bootstrap a new Next.js project. If you want to start from scratch, leave all the default options (Typescript, Tailwind, etc.)

Next, we’ll install our production dependencies. There are only a few:

Production dependencies

We’ll also need some dev dependencies:

Dev dependencies

Now we need to create our directory structure for the WebSocket server in the root directory of our project. The tree will be as follows:

ws
├── data
├── dist
└── src
├── server.ts
├── types
│ └── ws.types.ts
└── WsHelper.ts

Also in the project root, let’s create a Typescript config file for our server and call it tsconfig.ws.json:

{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"outDir": "./ws/dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"./ws/src/**/*"
]
}

Next, we need to edit package.json and add some NPM scripts:

"scripts": {
"dev": "next dev",
"dev-ws": "concurrently \"tsc -p tsconfig.ws.json --watch\" \"nodemon ./ws/dist/server.js\"",
"dev-all": "concurrently npm:dev npm:dev-ws",
"build": "next build",
"build-ws": "tsc -p tsconfig.ws.json",
"start": "next start",
"start-ws": "node ./ws/dist/server.js",
"lint": "next lint"
}

With concurrently, we can simple run npm run dev-all and it will fire up both the Next.js client and the WebSocket server and show the console output in the same terminal. 😊

Now edit .gitignore in the project root. Add these lines to the bottom:

# wss
ws/dist
ws/data

Lastly, create a .env.local file and add one var:

NEXT_PUBLIC_WSS_URL="ws://localhost:1337"

The Server-side Code

First let’s create some shared types we’re going to use in both the client and server. Create a file ws/src/types/ws.types.ts:

// Types //

export enum E_WssChannel {
general = 'General Discussion',
tech = 'Tech Discussion',
}

export type T_WssChannel = keyof typeof E_WssChannel;

export type T_WssChatRequestType = 'system' | 'chat';

// Interfaces //

export interface I_WssResponse {
success: boolean;
message?: string;
}

export interface I_WssSend<T> extends I_WssResponse {
data?: T;
}

export interface I_WssChatResponse {
channel: T_WssChannel;
message: string;
}

export interface I_WssChatRequest {
type: T_WssChatRequestType;
handle: string;
message: string;
}

Next, we’ll create a little helper class ( ws/src/WsHelper.ts) that we'll use in the WebSocket server:

import WebSocket from 'ws';

import { I_WssResponse, I_WssSend, I_WssChatResponse } from './types/ws.types';

class WsHelper {
public static send<T>(ws: WebSocket, data: I_WssSend<T>, closeConnection = false) {
ws.send(JSON.stringify(data));
if (closeConnection) ws.close();
}

public static success<T>(ws: WebSocket, data: T, closeConnection = false) {
WsHelper.send(ws, { success: true, data }, closeConnection);
}

public static error(ws: WebSocket, message: string, closeConnection = false) {
WsHelper.send(ws, { success: false, message }, closeConnection);
}

public static chat(ws: WebSocket, data: I_WssChatResponse) {
WsHelper.send(ws, { success: true, data });
}
}

export default WsHelper;

Now we can create our server ( ws/src/server.ts):

import WebSocket, { WebSocketServer } from 'ws';
import WsHelper from './WsHelper';
import fs from 'fs';
import url from 'url';
import path from 'path';
import dayjs from 'dayjs';

// Types
import { T_WssChannel, E_WssChannel, I_WssChatRequest } from './types/ws.types';

// Constants
const CHANNELS = Object.keys(E_WssChannel);
const DATA_DIR = path.join(__dirname, '../data');
const REGISTERED_USERS_FILE = path.join(DATA_DIR, 'registered_users.log');
const LISTEN_PORT = 1337;
const SYSTEM_USER = 'System';

// Create a WebSocket server
const wss: WebSocketServer = new WebSocket.Server({ port: LISTEN_PORT });

const getTimestemp = () => {
return dayjs().format('YYYY-MM-DD HH:mm:ss');
};

const makeLinePrefix = (handle: string) => {
return `[${getTimestemp()}] ${handle}: `;
};

wss.on('listening', () => {
// Clean up the data directory
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR);
}

// Create the registered users file if it doesn't exist, otherwise clear it
if (!fs.existsSync(REGISTERED_USERS_FILE)) {
fs.writeFileSync(REGISTERED_USERS_FILE, '', 'utf8');
} else {
fs.truncate(REGISTERED_USERS_FILE, 0, () => {});
}

console.log(`WebSocket server listening on port ${LISTEN_PORT}`);
});

wss.on('connection', (ws: WebSocket, req: any) => {
// Parse the query parameters
const location = url.parse(req.url, true);
const channel: T_WssChannel | undefined = location.query.channel as T_WssChannel | undefined;
const handle: string = location.query.handle as string;

// Validate query parameters
if (!channel || !CHANNELS.includes(channel)) {
WsHelper.error(ws, 'No valid channel provided', true);
return;
}

if (!handle) {
WsHelper.error(ws, 'No valid handle provided', true);
return;
}

// Create the log file path
const logFile: string = path.join(DATA_DIR, `channel_${channel}.log`);

// Check if the log file exists, create it if it doesn't
if (!fs.existsSync(logFile)) {
fs.writeFileSync(logFile, '', 'utf8');
}

// Check if the user is already registered
fs.readFile(REGISTERED_USERS_FILE, 'utf8', (err, data) => {
if (err) {
console.error('Error reading registered users file:', err);
return;
}

const users = data.split('\n');
const searchTerm = `${handle}|${channel}`;
if (users.includes(searchTerm)) {
WsHelper.error(ws, 'User already registered', true);
console.error(`User ${handle} is already registered in channel ${E_WssChannel[channel]}`);
return;
}

// Register the user
fs.appendFile(REGISTERED_USERS_FILE, `${handle}|${channel}\n`, 'utf8', err => {
if (err) {
console.error('Error writing to registered users file:', err);
return;
}
});
});

// Notify chat room of new user
fs.appendFile(logFile, `${makeLinePrefix(SYSTEM_USER)} ${handle} has joined the chat\n`, 'utf8', err => {
if (err) {
console.error('Error writing to chat log:', err);
}
});

// Send chat history to the client
fs.readFile(logFile, 'utf8', (err, data) => {
if (err) {
console.error('Error reading chat log:', err);
return;
}

const messages = data.split('\n');
messages.forEach(message => {
WsHelper.chat(ws, { channel, message });
});
});

console.log(`Client connected to channel: ${E_WssChannel[channel]}`);

// Watch the log file for changes
let lastSize: number = fs.statSync(logFile).size;
fs.watch(logFile, (eventType: string, filename: string | null) => {
if (eventType === 'change') {
fs.stat(logFile, (err: NodeJS.ErrnoException | null, stats: fs.Stats) => {
if (err) {
console.error('Error getting file stats:', err);
return;
}

if (stats.size > lastSize) {
// Read the new content
const stream = fs.createReadStream(logFile, {
start: lastSize,
end: stats.size,
});

// Send the new content to the client
stream.on('data', (newData: Buffer) => {
const message = newData.toString();
WsHelper.chat(ws, { channel, message });
});

lastSize = stats.size;
}
});
}
});

ws.on('message', (payload: string) => {
try {
const data: I_WssChatRequest = JSON.parse(payload);
const { handle, message, type } = data;

// Validate the payload
if (!handle || !message || !type) {
WsHelper.error(ws, 'Invalid payload', true);
return;
}

console.log(`Received: [${type}] message from ${handle}`);

switch (type) {
case 'chat':
// Append the message to the chat log
fs.appendFile(logFile, `${makeLinePrefix(handle)} ${message}\n`, 'utf8', err => {
if (err) {
console.error('Error writing to chat log:', err);
}
});
break;
case 'system':
switch (message) {
case 'keepalive':
console.log('Received keepalive message');
break;
default:
console.error('Invalid system message:', message);
break;
}
break;
default:
console.error('Invalid message type:', type);
break;
}
} catch (err: any) {
WsHelper.error(ws, 'Error parsing message', true);
console.error('Error parsing message:', err);
}
});

ws.on('close', () => {
console.log('Client disconnected');

// Notify chat room of user leaving
fs.appendFile(logFile, `${makeLinePrefix(SYSTEM_USER)} ${handle} has left the chat\n`, 'utf8', err => {
if (err) {
console.error('Error writing to chat log:', err);
}
});

// Remove the user from the registered users file
const searchTerm = `${handle}|${channel}`;
fs.readFile(REGISTERED_USERS_FILE, 'utf8', (err, data) => {
if (err) {
console.error('Error reading registered users file:', err);
return;
}

const users = data.split('\n');
const newUsers = users.filter(user => user !== searchTerm);

fs.writeFile(REGISTERED_USERS_FILE, newUsers.join('\n'), 'utf8', err => {
if (err) {
console.error('Error writing to registered users file:', err);
}
});
});
});
});

Here’s a breakdown of what this code does:

  1. Server Initialization: A WebSocket server is created listening on a specified port (1337). The server prepares a data directory to store logs and registered user information.
  2. User Connection and Validation: When a user connects, the server parses query parameters from the URL to identify the chat channel and user handle. It validates these parameters to ensure they correspond to predefined channels and that a handle is provided. Users are prevented from joining if they don’t specify a valid channel or handle.
  3. User Registration and Chat Log: The code checks for the existence of a user in a “registered users” log file. If the user is not already registered in the specified channel, their details are added. It also manages a chat log file for each channel, where messages are stored.
  4. Message Broadcasting and Logging: The server listens for incoming messages from connected clients. These messages are validated, logged into the appropriate channel’s log file, and then broadcasted to other users in the chat. This allows for real-time communication between users in the same channel.
  5. Real-Time Updates with File Watching: The server watches the chat log file for changes (e.g., new messages) and sends updates to connected clients. This ensures that all participants can see new messages as soon as they’re logged.
  6. Handling User Disconnections: When a user disconnects, the server logs this event in the chat log and updates the registered users file to reflect their departure. This helps maintain an accurate list of active participants in each chat channel.
  7. Utility Functions: The code uses helper functions to format timestamps, construct log prefixes, and handle errors.

The Client-side Code

Now that the server is ready to go, let’s build the client code. Let’s make a handy little hook called useChat that will handle managing our WebSocket connection and storing state values:

import { useEffect, useState } from 'react';

import {
T_WssChannel,
E_WssChannel,
I_WssChatResponse,
I_WssSend,
I_WssChatRequest,
} from '../../ws/src/types/ws.types';

interface I_UseChatReturn {
messages: string[];
wsError: string;
connected: boolean;
connect: (props: I_UseChatConnect) => void;
disconnect: () => void;
sendMessage: (message: string) => void;
validateHandle: (handle: string) => boolean;
}

interface I_UseChatConnect {
channel: T_WssChannel;
handle: string;
}

export const CHANNELS = Object.keys(E_WssChannel);
export const HANDLE_REGEX = /^[a-zA-Z0-9_]{3,16}$/;
export const KEEPALIVE_INTERVAL = 30000; // 30 seconds

export default function useChat(): I_UseChatReturn {
// Validate environment variables
const wssUrl = process.env.NEXT_PUBLIC_WSS_URL;
if (!wssUrl) {
throw new Error('NEXT_PUBLIC_WSS_URL is not defined');
}

// State
const [messages, setMessages] = useState<string[]>([]);
const [ws, setWs] = useState<WebSocket | null>(null);
const [wsError, setWsError] = useState<string>('');
const [connected, setConnected] = useState<boolean>(false);
const [channel, setChannel] = useState<T_WssChannel>('general');
const [handle, setHandle] = useState<string>('');

// Methods
const connect = (props: I_UseChatConnect) => {
const { channel, handle } = props;

// Validate that handle and channel are set
if (!handle || !HANDLE_REGEX.test(handle)) {
throw new Error(`Invalid handle: ${handle}`);
}

if (!channel || !CHANNELS.includes(channel)) {
throw new Error(`Invalid channel: ${channel}`);
}

setChannel(channel);
setHandle(handle);

// Set up the WebSocket connection
const urlParams = new URLSearchParams();
urlParams.append('channel', channel);
urlParams.append('handle', handle);

const socket = new WebSocket(`${wssUrl}?${urlParams.toString()}`);
setWs(socket);
setMessages([`Connected to [${E_WssChannel[channel]}] as ${handle}`]);
};

const disconnect = () => {
if (ws?.readyState === WebSocket.OPEN) {
ws.close();
} else {
console.error('WebSocket is not open');
}
};

const sendMessage = (message: string) => {
if (ws?.readyState === WebSocket.OPEN) {
const payload: I_WssChatRequest = {
type: 'chat',
handle,
message,
};
ws.send(JSON.stringify(payload));
} else {
console.error('WebSocket is not open');
}
};

const sendSystemMessage = (message: string) => {
if (ws?.readyState === WebSocket.OPEN) {
const payload: I_WssChatRequest = {
type: 'system',
handle,
message,
};
ws.send(JSON.stringify(payload));
} else {
console.error('WebSocket is not open');
}
};

const validateHandle = (handle: string) => {
return HANDLE_REGEX.test(handle);
};

// Effects
useEffect(() => {
if (ws) {
ws.onopen = () => {
setConnected(true);
console.info(`Connected to chat channel: ${channel} as ${handle}`);
};

ws.onmessage = event => {
const response = JSON.parse(event.data) as I_WssSend<I_WssChatResponse>;
const { success, message, data } = response;

if (!success || !data) {
setWsError(message || 'Unknown error receiving message');
return;
}

console.info(`Received message from channel: ${data.channel}`);

if (data.message) {
setMessages(prevMessages => [...prevMessages, data.message.trim()]);
}
};

ws.onclose = () => {
setConnected(false);
setMessages([`Disconnected from [${E_WssChannel[channel]}]`]);
setChannel('general');
setHandle('');
console.info('Disconnected from chat channel');
};

ws.onerror = error => {
setConnected(false);
setWsError('Error connecting to chat channel');
console.error('WebSocket error:', error);
};

// Keep the connection alive
const keepAliveInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
sendSystemMessage('keepalive');
}
}, KEEPALIVE_INTERVAL);

return () => {
ws.close();
clearInterval(keepAliveInterval);
setConnected(false);
};
}
}, [ws]);

return {
messages,
wsError,
connected,
connect,
disconnect,
sendMessage,
validateHandle,
};
}

And last, but not least, here is our chat component:

'use client';

import React, { useEffect, useState, useRef } from 'react';

import useChat from '@/hooks/useChat';

import { E_WssChannel, T_WssChannel } from '../../ws/src/types/ws.types';

export default function MainPage() {
const { messages, wsError, connected, sendMessage, connect, disconnect, validateHandle } = useChat();

const logEndRef = useRef<HTMLPreElement>(null);

const [channel, setChannel] = useState<T_WssChannel>('general');
const [handle, setHandle] = useState<string>('');
const [errors, setErrors] = useState<any>({});
const [messageInput, setMessageInput] = useState<string>('');

useEffect(() => {
setErrors((prev: any) => ({ ...prev, connect: wsError }));
}, [wsError]);

// Effect to scroll to the bottom of the log
useEffect(() => {
if (logEndRef.current) {
logEndRef.current.scrollTop = logEndRef.current.scrollHeight;
}
}, [messages]);

const handleChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
setErrors({});

const name = e.target.name;
const value = e.target.value;

if (name === 'channel') {
setChannel(value as T_WssChannel);
} else if (name === 'handle') {
// Format handle and uppercase first letter
const modifiedHandle = value
.replace(/[^a-zA-Z0-9_]/g, '')
.toLowerCase()
.replace(/^[a-z]/, c => c.toUpperCase());
setHandle(modifiedHandle);
}
};

const handleConnect = () => {
setErrors({});

// Validate handle
if (!validateHandle(handle)) {
setErrors({
handle: 'Handle must be 3-16 characters long and contain only letters, numbers, and underscores',
});
return;
}

// Validate channel
const channels = Object.keys(E_WssChannel);
if (!channels.includes(channel)) {
setErrors({ channel: 'Invalid channel' });
return;
}

try {
connect({ channel, handle });
} catch (err: any) {
setErrors({ connect: err.message });
}
};

const handleSendMessage = () => {
setErrors({});

if (!messageInput) {
setErrors({ message: 'Message cannot be empty' });
return;
}

try {
sendMessage(messageInput);
setMessageInput('');
} catch (err: any) {
setErrors({ message: err.message });
}
};

return (
<div className="w-full max-w-5xl flex flex-col gap-6 mx-auto border-2 rounded-xl p-6">
<div className="grid grid-cols-2 gap-4">
<div className="form-control">
<label className="label">
<span className="label-text">Channel</span>
</label>
<select
className="select select-bordered w-full"
name="channel"
value={channel}
onChange={handleChange}
>
{Object.keys(E_WssChannel).map(channel => {
const channelKey = channel as T_WssChannel;
return (
<option key={channelKey} value={channelKey}>
{E_WssChannel[channelKey]}
</option>
);
})}
</select>
<label className="label">
<span className="label-text-alt text-error">{errors.channel || ' '}</span>
</label>
</div>
<div className="form-control">
<label className="label">
<span className="label-text">Handle</span>
</label>
<input
type="text"
className="input input-bordered w-full"
name="handle"
value={handle}
onChange={handleChange}
/>
<label className="label">
<span className="label-text-alt text-error">{errors.handle || ' '}</span>
</label>
</div>
</div>
<div className="flex items-center gap-6 justify-center w-full">
{connected ? (
<button className="btn btn-secondary" onClick={disconnect}>
Disconnect
</button>
) : (
<button className="btn btn-primary" onClick={handleConnect}>
Connect
</button>
)}
</div>
<div className="w-full text-center text-sm text-error">{errors.connect || ' '}</div>
<pre
className="bg-zinc-950 text-green-500 p-6 rounded-xl h-80 overflow-y-auto"
ref={logEndRef}
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
>
{messages.join('\n') || 'No messages'}
</pre>
<div className="form-control">
<textarea
className="textarea textarea-bordered w-full"
value={messageInput}
onChange={e => setMessageInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
placeholder="Type a message..."
/>
<label className="label">
<span className="label-text-alt text-error">{errors.message || ' '}</span>
</label>
</div>
</div>
);
}

Whew! 🥵 That was a lot of coding, but I think it will be worth it. Now simply run npm run dev-all and both the client and server should start up. You should see something like [dev-ws] [1] WebSocket server listening on port 1337. Now go to https://localhost:3000 and give it a try! Type in your handle and choose a channel and then click connect. Try the same with a different handle in a private window and have a little conversation with yourself. You will notice that the communication is nearly instant!

Deploying On a Production Server

Before we go any further, I want to issue a disclaimer. This article and the associated project repo is a bare-minimum example to show you how to use WebSocket with Next.js. There are many security issues to consider in a production environment. We’re also using flat files and the local filesystem to store chat data. You’d probable want to use a database instead.

That being said, I’ll show you quickly how you can use NGINX to secure your WebSocket server and also how to run it as a a service on Ubuntu/Debian systems.

Here’s an example NGINX config file:

server {
server_name chat.example.com;

location / {
proxy_pass http://localhost:1337;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade"; # Correct for WebSocket
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

You can automatically create a SSL configuration and certificate by running certbot --nginx. Note that in a production environment, you'd change your NEXT_PUBLIC_WSS_URL to the live hostname and also change ws:// to wss:// to use SSL.

Also, here is an example systemd config:

[Unit]
Description=Messaging Service
After=network.target

[Service]
Type=simple
User=web
Group=web
WorkingDirectory=/path/to/your/deployment/dir
ExecStart=/bin/sh -c '/usr/bin/npm run start-ws >> /var/log/chat-server/server.log 2>&1'
Restart=on-failure
StandardOutput=null
StandardError=null

[Install]

For a complete guide on how to deploy Next.js apps on a server, please read my article titled: Serverless Sucks: How to Deploy your Next.js App to a VPS and Setup a CI/CD Pipeline.

For the complete code repo for this project, click here.

Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify!

Also, be sure to check out my new app called Snoozle! It’s an app that generates bedtime stories for kids using AI and it’s completely free to use!

Looking for a web developer? I’m available for hire! To inquire, please fill out a contact form.

Originally published at https://blog.designly.biz.

--

--

Designly
Designly

Full-stack web developer and graphics designer.