How to Build Realtime Chat in Node.js
A step-by-step guide to building a working realtime chat app with Node.js and Apinator — from server setup to client subscription in under 50 lines of code.
Realtime chat is the "hello world" of WebSocket applications. It's concrete enough to be interesting, and simple enough that you can understand every piece.
In this guide we'll build a multi-room chat app: a Node.js server that publishes messages, and a browser client that subscribes and sends. The finished server is about 30 lines. The client is about 20.
What We're Building
- Users pick a room name and a username
- Messages typed in the client are sent to the server via
fetch - The server publishes to a WebSocket channel for that room
- All clients in the room receive the message instantly
We'll keep state minimal: no database, no auth, no persistence. Those are straightforward to add once the realtime layer is working.
Server Setup
Install dependencies
mkdir chat-server && cd chat-server
npm init -y
npm install @apinator/server express
Create the server
// server.js
import express from 'express';
import { ApinatorServer } from '@apinator/server';
const app = express();
app.use(express.json());
app.use(express.static('public'));
const apinator = new ApinatorServer({
appId: process.env.APINATOR_APP_ID,
apiKey: process.env.APINATOR_API_KEY,
apiSecret: process.env.APINATOR_API_SECRET,
});
app.post('/messages', async (req, res) => {
const { room, username, text } = req.body;
if (!room || !username || !text) {
return res.status(400).json({ error: 'room, username, and text are required' });
}
await apinator.publish(`chat-${room}`, 'message', {
username,
text,
timestamp: new Date().toISOString(),
});
res.json({ ok: true });
});
app.listen(3000, () => console.log('Chat server running on http://localhost:3000'));
That's the entire server. Apinator handles WebSocket connections, channel subscriptions, and message fanout. Your server only receives HTTP posts and publishes events.
Configure environment variables
# .env
APINATOR_APP_ID=your-app-id
APINATOR_API_KEY=your-api-key
APINATOR_API_SECRET=your-api-secret
Get these from the Apinator console after creating an app.
Client Setup
Create public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Chat</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
#messages { border: 1px solid #ccc; height: 300px; overflow-y: auto; padding: 1rem; margin-bottom: 1rem; }
.message { margin-bottom: 0.5rem; }
.message strong { color: #333; }
input, button { padding: 0.5rem; font-size: 1rem; }
#text { width: calc(100% - 90px); }
</style>
</head>
<body>
<div id="setup">
<input id="username" placeholder="Your name" />
<input id="room" placeholder="Room name" />
<button onclick="joinRoom()">Join</button>
</div>
<div id="chat" style="display:none">
<div id="messages"></div>
<input id="text" placeholder="Type a message..."
onkeydown="if(event.key==='Enter') sendMessage()" />
<button onclick="sendMessage()">Send</button>
</div>
<script type="module">
import { RealtimeClient } from 'https://cdn.jsdelivr.net/npm/@apinator/sdk/+esm';
let username, room;
window.joinRoom = function () {
username = document.getElementById('username').value.trim();
room = document.getElementById('room').value.trim();
if (!username || !room) return alert('Enter a name and room');
const client = new RealtimeClient({ appKey: 'your-app-key' });
const channel = client.subscribe(`chat-${room}`);
channel.bind('message', (data) => {
const div = document.getElementById('messages');
div.innerHTML += `<div class="message"><strong>${data.username}:</strong> ${data.text}</div>`;
div.scrollTop = div.scrollHeight;
});
document.getElementById('setup').style.display = 'none';
document.getElementById('chat').style.display = 'block';
};
window.sendMessage = async function () {
const textEl = document.getElementById('text');
const text = textEl.value.trim();
if (!text) return;
textEl.value = '';
await fetch('/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room, username, text }),
});
};
</script>
</body>
</html>
Replace 'your-app-key' with the public app key from your Apinator console.
Run It
node --env-file=.env server.js
Open http://localhost:3000 in two browser tabs, pick the same room name, and start chatting. Messages appear in both tabs instantly.
How It Works
User types message → POST /messages (Node.js server)
↓
apinator.publish('chat-room', 'message', data)
↓
Apinator infrastructure
↓
All clients subscribed to 'chat-room'
receive the 'message' event
Your server never touches WebSocket connections directly. It receives HTTP posts and publishes events. Apinator manages every connection, subscription, and delivery.
Adding Private Rooms
Switch to private channels for invite-only rooms:
// Client — subscribe with auth
const channel = client.subscribe(`private-chat-${room}`, {
auth: { endpoint: '/auth/channel' }
});
Your server signs the subscription:
app.post('/auth/channel', (req, res) => {
const user = verifyToken(req.headers.authorization);
if (!isAuthorized(user, req.body.channel_name)) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(apinator.authorizeChannel(req.body.socket_id, req.body.channel_name));
});
Adding Typing Indicators
Once on a private channel, use client events for typing signals — no server roundtrip:
textInput.addEventListener('input', () => {
channel.trigger('client-typing', { username });
});
channel.bind('client-typing', (data) => {
showTypingIndicator(`${data.username} is typing...`);
});
Adding Message Persistence
Persist before publishing, then load history on join:
// Server — save before publishing
app.post('/messages', async (req, res) => {
const message = await db.messages.create(req.body);
await apinator.publish(`chat-${req.body.room}`, 'message', message);
res.json({ message });
});
// Client — load history on join, then subscribe
const history = await fetch(`/messages?room=${room}`).then(r => r.json());
history.forEach(renderMessage);
// then subscribe to real-time events
WebSockets carry live messages; your database carries history. They're complementary layers.
Next Steps
The patterns here — publish on HTTP POST, subscribe on the client, private channels for auth, client events for transient signals — apply to almost every realtime feature you'll build.
The Apinator documentation has guides for presence channels, multi-region deployments, and mobile SDKs for iOS and Android.