The case for a custom group chat

Chat has become a default communication channel in both business and social contexts. Workplaces rely on Slack, Microsoft Teams, Chanty, HubSpot Live Chat and Help Scout, while Instagram, Facebook, Reddit and Twitter ship chat as a built-in feature. For Discord, Whatsapp and Telegram, chat — group chat in particular — is the product.

Off-the-shelf options do not cover every case. Many are standalone apps that cannot be embedded inside your own site, and sending users elsewhere to talk to each other can hurt both user experience and conversion. Writing a chat stack from scratch is equally unattractive. Communication APIs such as Twilio Conversations absorb the heavy parts: creating groups, adding participants, sending messages and notifications. A backend that sits on top of such an API only has to authenticate users and call the API, and the front end only has to render the conversations, groups and messages it receives.

This tutorial builds exactly that split. The backend is a Node.js app that authorizes chat participants and manages chat creation. The front end is HTML, CSS and Vanilla JavaScript, covering login, new group creation, friend invitations and message exchange. When it is done, several participants can hold a conversation in a single group.

What you need before starting

Setting Up The Twilio Chat Server

Everything a chat client needs to talk to Twilio is issued here: a conversation holds the messages, participants are the users allowed to post inside it, and both are provisioned server-side. The client never talks to Twilio directly — it receives an access token, generated by this app, and uses it to load subscribed conversations and their messages.

Bootstrapping The Project

The backend is called twilio-chat-server. A scaffolded starter lives in the Twilio twilio-chat-demo-js repository; cloning it and installing yields the layout you will fill in. The app launches with node index.js.

git clone https://github.com/zaracooper/twilio-chat-server.git
cd twilio-chat-server
git checkout starter
.
├── app.js
├── config/
├── controllers/
├── package.json
├── routes/
└── utils/

Eight Dependencies

Install the full set in one go, then start wiring code.

npm i 
  • connect-mongo — MongoDB backed session store;
  • cors — CORS handling;
  • dotenv — reads environment variables from the .env file;
  • express — the web framework;
  • express-session — session middleware;
  • http-errors — server error construction;
  • morgan — request logging;
  • twilio — Twilio client, token generation, conversation creation, participant management.

Configuration And Credentials

Configuration lives in the config folder and is read from environment variables, split into three groups: CORS, Twilio, and the MongoDB session database. When the environment is development, values come from the .env file through dotenv. That file is already listed in .gitignore, so the secrets it holds stay out of the repository.

touch .env

The file itself looks like this:

# Session DB Config
SESSION_DB_HOST=XXXX
SESSION_DB_USER=XXXX
SESSION_DB_PASS=XXXX
SESSION_DB_PORT=XXXX
SESSION_DB_NAME=XXXX
SESSION_DB_SECRET=XXXX

# Twilio Config
TWILIO_ACCOUNT_SID=XXXX
TWILIO_AUTH_TOKEN=XXXX
TWILIO_API_KEY=XXXX
TWILIO_API_SECRET=XXXX

# CORS Client Config
CORS_CLIENT_DOMAIN=XXXX

For the session database you need a Mongo user with write access — the MongoDB manual covers creating one. Fill SESSION_DB_USER, SESSION_DB_PASS and SESSION_DB_NAME accordingly; a local MongoDB instance means SESSION_DB_HOST=localhost and SESSION_DB_PORT=27017. SESSION_DB_SECRET is the string express-session uses to sign the session ID cookie and can be any secret you choose.

Credentials ending in the TWILIO_ prefix come from the Twilio Console. Because the front-end runs at http://localhost:3000 during local development, that is the value for CORS_CLIENT_DOMAIN.

config/index.js loads the variables and exports one object per category for the rest of the app to consume.

import dotenv from 'dotenv';

if (process.env.NODE_ENV == 'development') {
    dotenv.config();
}

const corsClient = {
    domain: process.env.CORS_CLIENT_DOMAIN
};

const sessionDB = {
    host: process.env.SESSION_DB_HOST,
    user: process.env.SESSION_DB_USER,
    pass: process.env.SESSION_DB_PASS,
    port: process.env.SESSION_DB_PORT,
    name: process.env.SESSION_DB_NAME,
    secret: process.env.SESSION_DB_SECRET
};

const twilioConfig = {
    accountSid: process.env.TWILIO_ACCOUNT_SID,
    authToken: process.env.TWILIO_AUTH_TOKEN,
    apiKey: process.env.TWILIO_API_KEY,
    apiSecret: process.env.TWILIO_API_SECRET
};

const port = process.env.PORT || '8000';

export { corsClient, port, sessionDB, twilioConfig };

Four Credentials From The Console

Four values are needed: Account SID, Auth Token, API key, and API secret. The first two sit in the General Settings page under the API Credentials section.

API Credentials section on the General Settings page
API Credentials section on the General Settings page. (Large preview)

The key and secret come from the API Keys page, reached via the + button.

A screenshot where it's shown how to create API key button on API Key list page
Create API Key button on API Key list page. (Large preview)

Name the key, keep KEY TYPE set to Standard, press Create API Key, and copy the resulting key and secret.

A screenshot with the properties on a New API Key page
New API Key page. (Large preview)

Two Utility Functions

utils/token.js holds createToken, which generates a Twilio access token:

import { twilioConfig } from '../config/index.js';
import twilio from 'twilio';

function createToken(username, serviceSid) {
    const AccessToken = twilio.jwt.AccessToken;
    const ChatGrant = AccessToken.ChatGrant;

    const token = new AccessToken(
        twilioConfig.accountSid,
        twilioConfig.apiKey,
        twilioConfig.apiSecret,
        { identity: username }
    );

    const chatGrant = new ChatGrant({
        serviceSid: serviceSid,
    });

    token.addGrant(chatGrant);

    return token.toJwt();
}

The token is built from the Account SID, API key and API secret, with an optional unique identity such as a username or email. Once created, the token needs a chat grant, which may carry a conversation service ID along with other optional values, and the result is converted to a JWT before being returned.

utils/controller.js exports asyncWrapper, a wrapper for async controllers that catches whatever they throw:

function asyncWrapper(controller) {
    return (req, res, next) => Promise.resolve(controller(req, res, next)).catch(next);
}

export { asyncWrapper, createToken };

Controllers And Routes

Four controllers make up the API surface: two for authentication (creating and deleting a token) and two for conversations (creating a conversation and adding participants to it).

Conversations

In controllers/conversations.js, StartConversation is added first, imports included:

import { twilioConfig } from '../config/index.js';
import { createToken } from '../utils/token.js';
import twilio from 'twilio';

async function StartConversation(req, res, next) {
    const client = twilio(twilioConfig.accountSid, twilioConfig.authToken);

    const { conversationTitle, username } = req.body;

    try {
        if (conversationTitle && username) {
            const conversation = await client.conversations.conversations
                .create({ friendlyName: conversationTitle });

            req.session.token = createToken(username, conversation.chatServiceSid);
            req.session.username = username;

            const participant = await client.conversations.conversations(conversation.sid)
                .participants.create({ identity: username })

            res.send({ conversation, participant });
        } else {
            next({ message: 'Missing conversation title or username' });
        }
    }
    catch (error) {
        next({ error, message: 'There was a problem creating your conversation' });
    }
}

It instantiates a Twilio client from twilioConfig.accountSid and twilioConfig.authToken pulled out of config/index.js. Then it creates a conversation using a title taken from the request body. Nobody can post in a conversation without being added to it, and a participant cannot send anything without an access token, so an access token is generated for the username in the request body along with conversation.chatServiceSid, and that username is then added as a participant. The response carries the new conversation and participant.

AddParticipant goes below it in the same file:

async function AddParticipant(req, res, next) {
    const client = twilio(twilioConfig.accountSid, twilioConfig.authToken);

    const { username } = req.body;
    const conversationSid = req.params.id;

    try {
        const conversation = await client.conversations.conversations
            .get(conversationSid).fetch();

        if (username && conversationSid) {
            req.session.token = createToken(username, conversation.chatServiceSid);
            req.session.username = username;

            const participant = await client.conversations.conversations(conversationSid)
                .participants.create({ identity: username })

            res.send({ conversation, participant });
        } else {
            next({ message: 'Missing username or conversation Sid' });
        }
    } catch (error) {
        next({ error, message: 'There was a problem adding a participant' });
    }
}

export { AddParticipant, StartConversation };

This controller works on conversations that already exist. It reads the conversationSid route parameter, fetches that conversation, creates a token for the user named in the request body, and adds them. Both the conversation and the participant are returned.

Auth

The two functions in controllers/auth.js are GetToken and DeleteToken:

function GetToken(req, res, next) {
    if (req.session.token) {
        res.send({ token: req.session.token, username: req.session.username });
    } else {
        next({ status: 404, message: 'Token not set' });
    }
}

function DeleteToken(req, res, _next) {
    delete req.session.token;
    delete req.session.username;

    res.send({ message: 'Session destroyed' });
}

export { DeleteToken, GetToken };

GetToken reads the token and username off the session and returns them if present; DeleteToken destroys the session.

Three Route Files

The routes folder contains index.js, conversations.js, and auth.js.

Authentication first:

import { Router } from 'express';

import { DeleteToken, GetToken } from '../controllers/auth.js';

var router = Router();

router.get('/', GetToken);
router.delete('/', DeleteToken);

export default router;

The GET route on / returns a token; the DELETE route removes it.

Conversation routing comes next:

import { Router } from 'express';
import { AddParticipant, StartConversation } from '../controllers/conversations.js';
import { asyncWrapper } from '../utils/controller.js';

var router = Router();

router.post('/', asyncWrapper(StartConversation));
router.post('/:id/participants', asyncWrapper(AddParticipant));

export default router;

Here a conversations router is created and given a POST route at / for new conversations plus a POST route at /:id/participants for adding participants.

The main router mounts both:

import { Router } from 'express';

import authRouter from './auth.js';
import conversationRouter from './conversations.js';

var router = Router();

router.use('/auth/token', authRouter);
router.use('/api/conversations', conversationRouter);

export default router;

Mounting the two routers exposes them at /api/conversations and /auth/token respectively; the router is then exported.

Wiring The App Together

With the pieces in place, index.js assembles them:

import cors from 'cors';
import createError from 'http-errors';
import express, { json, urlencoded } from 'express';
import logger from 'morgan';
import session from 'express-session';
import store from 'connect-mongo';

import { corsClient, port, sessionDB } from './config/index.js';

import router from './routes/index.js';

var app = express();

app.use(logger('dev'));
app.use(json());
app.use(urlencoded({ extended: false }));

app.use(cors({
    origin: corsClient.domain,
    credentials: true,
    methods: ['GET', 'POST', 'DELETE'],
    maxAge: 3600 * 1000,
    allowedHeaders: ['Content-Type', 'Range'],
    exposedHeaders: ['Accept-Ranges', 'Content-Encoding', 'Content-Length', 'Content-Range']
}));
app.options('*', cors());

app.use(session({
    store: store.create({
        mongoUrl: `mongodb://${sessionDB.user}:${sessionDB.pass}@${sessionDB.host}:${sessionDB.port}/${sessionDB.name}`,
        mongoOptions: { useUnifiedTopology: true },
        collectionName: 'sessions'
    }),
    secret: sessionDB.secret,
    cookie: {
        maxAge: 3600 * 1000,
        sameSite: 'strict'
    },
    name: 'twilio.sid',
    resave: false,
    saveUninitialized: true
}));

app.use('/', router);

app.use(function (_req, _res, next) {
    next(createError(404, 'Route does not exist.'));
});

app.use(function (err, _req, res, _next) {
    res.status(err.status || 500).send(err);
});

app.listen(port);

The file creates the express app, enables JSON and URL-encoded payload parsing, registers the logging middleware, then configures CORS and sessions — MongoDB acting as the session store. After that it attaches the router, configures error handling, and starts listening on the port from the .env file, falling back to 8000 when none is set.

With MongoDB running, launch the server and pass the environment explicitly:

NODE_ENV=development npm start

NODE_ENV=development makes the app load configuration from the local .env file.

Front-End Architecture

The browser-side app, named twilio-chat-app, is a scaffolded starter available on Github. Clone it with:

git clone https://github.com/zaracooper/twilio-vanilla-js-chat-app.git
cd twilio-vanilla-js-chat-app
git checkout starter

Its layout is:

.
├── index.html
├── pages
│   ├── chat.html
│   ├── conversation.html
│   ├── error.html
│   └── login.html
├── scripts
│   ├── chat.js
│   ├── conversation.js
│   └── login.js
└── styles
    ├── chat.css
    ├── main.css
    └── simple-page.css

Four pages cover the whole feature set: conversations (create a conversation), chat (list conversations and exchange messages), login (join via invite), and error. Styling and HTML markup ship with the starter; only the scripts need writing.

Dependencies

Two packages are required: axios for backend requests and @twilio/conversations for fetching conversations and sending messages. Install both from the terminal:

npm i

Landing And Error Pages

index.html is the entry point, styled by styles/main.css (shared by all pages) and styles/simple-page.css (smaller pages). It looks like this:

Twilio Vanilla JS Chat App Landing Page
Twilio Vanilla JS Chat App's Landing Page. (Large preview)

pages/error.html is rendered whenever something fails. Its button returns the user to the home page so they can retry the action. The markup is shown here:

Twilio Vanilla JS Chat App Error Page
Twilio Vanilla JS Chat App's Error Page. (Large preview)

Creating A Conversation

The conversations form in pages/conversation.html collects a conversation title and a username. Put this in scripts/conversation.js:

window.twilioChat = window.twilioChat || {};

function createConversation() {
    let convoForm = document.getElementById('convoForm');
    let formData = new FormData(convoForm);

    let body = Object.fromEntries(formData.entries()) || {};

    let submitBtn = document.getElementById('submitConvo');
    submitBtn.innerText = "Creating..."
    submitBtn.disabled = true;
    submitBtn.style.cursor = 'wait';

    axios.request({
        url: '/api/conversations',
        baseURL: 'http://localhost:8000',
        method: 'post',
        withCredentials: true,
        data: body
    })
        .then(() => {
            window.twilioChat.username = body.username;
            location.href = '/pages/chat.html';
        })
        .catch(() => {
            location.href = '/pages/error.html';
        });
}

A click on Submit invokes createConversation. The form values become the body of a POST to http://localhost:8000/api/conversations/ issued through axios. On success the conversation exists, the creator is enrolled in it, and the browser moves on to the chat page.

Twilio Vanilla JS Chat App Conversation Page
Twilio Vanilla JS Chat App's Conversation Page. (Large preview)

The Chat Script: Client And Conversations

The chat page lists the user's conversations and handles message sending; its markup lives in pages/chat.html and its styling in styles/chat.css. The script opens by declaring a namespace, twilioDemo.

window.twilioChat = window.twilioChat || {};

Next comes initClient, which sets up the Twilio client and pulls in the conversation list.

async function initClient() {
    try {
        const response = await axios.request({
            url: '/auth/token',
            baseURL: 'http://localhost:8000',
            method: 'GETget',
            withCredentials: true
        });

        window.twilioChat.username = response.data.username;
        window.twilioChat.client = await Twilio.Conversations.Client.create(response.data.token);

        let conversations = await window.twilioChat.client.getSubscribedConversations();

        let conversationCont, conversationName;

        const sideNav = document.getElementById('side-nav');
        sideNav.removeChild(document.getElementById('loading-msg'));

        for (let conv of conversations.items) {
            conversationCont = document.createElement('button');
            conversationCont.classList.add('conversation');
            conversationCont.id = conv.sid;
            conversationCont.value = conv.sid;
            conversationCont.onclick = async () => {
                await setConversation(conv.sid, conv.channelState.friendlyName);
            };

            conversationName = document.createElement('h3');
            conversationName.innerText = `💬 ${conv.channelState.friendlyName}`;

            conversationCont.appendChild(conversationName);
            sideNav.appendChild(conversationCont);
        }
    }
    catch {
        location.href = '/pages/error.html';
    }
};

On page load it retrieves the user's access token from the backend, instantiates the client with that token, asks the client for every conversation the user is subscribed to, and renders them into the side-nav. Failures route the user to the error page.

Single conversations are handled by setConversation (note the typo—the function is referred to as setConversion in the surrounding prose), added below:

async function setConversation(sid, name) {
    try {
        window.twilioChat.selectedConvSid = sid;

        document.getElementById('chat-title').innerText = '+ ' + name;

        document.getElementById('loading-chat').style.display = 'flex';
        document.getElementById('messages').style.display = 'none';

        let submitButton = document.getElementById('submitMessage')
        submitButton.disabled = true;

        let inviteButton = document.getElementById('invite-button')
        inviteButton.disabled = true;

        window.twilioChat.selectedConversation = await window.twilioChat.client.getConversationBySid(window.twilioChat.selectedConvSid);

        const messages = await window.twilioChat.selectedConversation.getMessages();

        addMessagesToChatArea(messages.items, true);

        window.twilioChat.selectedConversation.on('messageAdded', msg => addMessagesToChatArea([msg], false));

        submitButton.disabled = false;
        inviteButton.disabled = false;
    } catch {
        showError('loading the conversation you selected');
    }
};

Clicking a conversation passes its conversation SID and name to that function. The SID fetches the conversation and its existing messages, which fill the chat area. A listener is then attached so that messages arriving later are appended as they come in. Any error surfaces as an error message.

A screenshot of the chat page
Twilio Vanilla JS Chat App's Chat Page. (Large preview)

The function that actually paints messages is addMessagedToChatArea:

function addMessagesToChatArea(messages, clearMessages) {
    let cont, msgCont, msgAuthor, timestamp;

    const chatArea = document.getElementById('messages');

    if (clearMessages) {
        document.getElementById('loading-chat').style.display = 'none';
        chatArea.style.display = 'flex';
        chatArea.replaceChildren();
    }

    for (const msg of messages) {
        cont = document.createElement('div');
        if (msg.state.author == window.twilioChat.username) {
            cont.classList.add('right-message');
        } else {
            cont.classList.add('left-message');
        }

        msgCont = document.createElement('div');
        msgCont.classList.add('message');

        msgAuthor = document.createElement('p');
        msgAuthor.classList.add('username');
        msgAuthor.innerText = msg.state.author;

        timestamp = document.createElement('p');
        timestamp.classList.add('timestamp');
        timestamp.innerText = msg.state.timestamp;

        msgCont.appendChild(msgAuthor);
        msgCont.innerText += msg.state.body;

        cont.appendChild(msgCont);
        cont.appendChild(timestamp);

        chatArea.appendChild(cont);
    }

    chatArea.scrollTop = chatArea.scrollHeight;
}

It runs both when a conversation is selected in the side nav and when a new message reaches the active conversation. While fetching is in flight a loading message is shown; it is removed before the real messages are inserted. The current user's messages are right-aligned, everyone else's left-aligned.

A screenshot with the 'loading messages' displayed in the middle
(Large preview)

Sending, Errors And Invites

sendMessage handles outgoing text:

function sendMessage() {
    let submitBtn = document.getElementById('submitMessage');
    submitBtn.disabled = true;

    let messageForm = document.getElementById('message-input');
    let messageData = new FormData(messageForm);

    const msg = messageData.get('chat-message');

    window.twilioChat.selectedConversation.sendMessage(msg)
        .then(() => {
            document.getElementById('chat-message').value = '';
            submitBtn.disabled = false;
        })
        .catch(() => {
            showError('sending your message');
            submitBtn.disabled = false;
        });
};

It reads the text area and disables the submit button, calls sendMessage on the currently selected conversation, and on success clears the text area and re-enables the button. A failure produces an error message instead. That message is rendered by showError and removed by hideError.

function showError(msg) {
    document.getElementById('error-message').style.display = 'flex';
    document.getElementById('error-text').innerText = `There was a problem ${msg ? msg : 'fulfilling your request'}.`;
}

function hideError() {
    document.getElementById('error-message').style.display = 'none';
}
A screenshot with the error message banner on top
Twilio Vanilla JS Chat App's Error Message. (Large preview)

logout asks the backend to clear the user's session and then redirects to the conversations page, where a fresh conversation can be created.

function logout(logoutButton) {
    logoutButton.disabled = true;
    logoutButton.style.cursor = 'wait';

    axios.request({
        url: '/auth/token',
        baseURL: 'http://localhost:8000',
        method: 'DELETEdelete',
        withCredentials: true
    })
        .then(() => {
            location.href = '/pages/conversation.html';
        })
        .catch(() => {
            location.href = '/pages/error.html';
        });
}

inviteFriend produces an invitation link pointing at the login page with the current conversation SID as a query parameter. Pressing the invite button copies the link to the clipboard and shows an alert with instructions.

async function inviteFriend() {
    try {
        const link = `http://localhost:3000/pages/login.html?sid=${window.twilioChat.selectedConvSid}`;

        await navigator.clipboard.writeText(link);

        alert(`The link below has been copied to your clipboard.\n\n${link}\n\nYou can invite a friend to chat by sending it to them.`);
    } catch {
        showError('preparing your chat invite');
    }
}
A screenshot of the invite alert
Twilio Vanilla JS Chat App's Invite Alert. (Large preview)

Joining Through The Login Page

pages/login.html serves invited users, and scripts/login.js contains the login function:

function login() {
    const convParams = new URLSearchParams(window.location.search);
    const conv = Object.fromEntries(convParams.entries());

    if (conv.sid) {
        let submitBtn = document.getElementById('login-button');
        submitBtn.innerText = 'Logging in...';
        submitBtn.disabled = true;
        submitBtn.style.cursor = 'wait';

        let loginForm = document.getElementById('loginForm');
        let formData = new FormData(loginForm);
        let body = Object.fromEntries(formData.entries());
        
        axios.request({
            url: `/api/conversations/${conv.sid}/participants`,
            baseURL: 'http://localhost:8000',
            method: 'POSTpost',
            withCredentials: true,
            data: body
        })
            .then(() => {
                location.href = '/pages/chat.html';
            })
            .catch(() => {
                location.href = '/pages/error.html';
            });
    } else {
        location.href = '/pages/conversation.html';
    }
}

It reads the conversation sid from the URL and the username from the form, then POSTs to api/conversations/{sid}/participants/. The backend adds the participant, mints an access token for messaging, and starts a session. Success sends the user to the chat page, an error response to the error page, and a missing sid query parameter back to the conversation page.

A screenshot of the login page
Twilio Vanilla JS Chat App's Login Page. (Large preview)

Serving And Trying It Out

The backend must already be running — start it with:

NODE_ENV=development npm start

Then, in another terminal window, serve the front end:

http-server -p 3000

That listens on http://localhost:3000. Visit http://localhost:3000/pages/conversation.html, name the conversation, supply a username and create it. On the chat page, select the conversation and hit Invite. Paste the invite link into a separate incognito window under a different username; once at the chat page there, both windows can exchange messages in the same conversation.

A video demonstration of the app.

Wrap-Up

What was built: a Node.js backend that issues user access tokens, keeps sessions, creates conversations and adds participants; plus an HTML/CSS/Vanilla JS front end that creates conversations, sends messages and invites others, pulling access tokens from the backend to do so. Twilio Conversations documentation is available for anything beyond this scope:

Smashing Editorial