Create a Video App with SolidJS, Express, and Twilio Video

June 27, 2022
Written by
Dainyl Cua
Twilion
Reviewed by
Mia Adjei
Twilion

Header

SolidJS is an emerging JavaScript framework with a 90% user satisfaction rate according to the State of JS 2021 survey. With a similar code structure to React, developers can feel at home with this new framework and integrate Twilio Video into their applications in a similar manner.

In this tutorial, you will be building both a backend server that generates Twilio Video access tokens and connects to Twilio Video rooms, and a frontend application that shares the audio and video of every participant in a room.

Prerequisites

To build this video app, you will need the following:

  • A free or paid Twilio account. If you are new to Twilio, click here to sign up for a free Twilio account and get a $10 credit when you upgrade!
  • Node.js (v16+) and npm

Setting up your developer environment

To get started, open your terminal window and navigate to the directory where you will be creating the two applications. First, set up the backend by copying and pasting the commands below in your terminal and pressing enter:

mkdir solid-video-frontend solid-video-backend
cd solid-video-backend
npm init -y
npm install twilio express dotenv cors nodemon
touch index.js tokens.js .env

These commands will create both the frontend and backend directory (solid-video-frontend and solid-video-backend respectively), navigate into the backend directory, initialize a new Node.js project, install the five required dependencies, and create the files index.js, tokens.js, and .env.

The five dependencies required for the backend are:

  • twilio, to utilize Twilio’s Video API 
  • express, to build your server
  • dotenv, to access your important credentials in .env and use them in environment variables
  • cors, to allow cross-origin resource sharing
  • nodemon, to run your server

In tokens.js, you will be utilizing the Twilio client and Video API to create Twilio Video rooms and serve video-granted access tokens. In index.js, you will be handling all incoming requests to a single route and serving the generated access token. In .env, you will be storing your important Twilio credentials.

Next, set up your frontend by running the following commands in your terminal:

cd ..
npx degit solidjs/templates/js solid-video-frontend
cd solid-video-frontend
npm init -y
npm install twilio-video

These commands will navigate you back to the main directory, create a new SolidJS app, initialize a new Node.js project, and install the only dependency required, twilio-video. This dependency contains a helper library which you will use to connect to the Twilio Video rooms created by your backend server.

Creating your backend server

As stated earlier, your backend server will handle both access token generation and room creation. Navigate to your backend directory and open the index.js file. Establish a basic outline for your server by copying and pasting the following code below:

require("dotenv").config();
const express = require("express");
const cors = require("cors");

const app = express();
app.use(cors({
  origin: `http://localhost:3000`
}));
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

app.post('/video/token', async (req, res) => {
  
})

app.listen(3001, () => {
  console.log('Express server listening on localhost: 3001.')
})

This code initializes the dotenv, express, and cors packages, includes middleware that allows cross-origin resource sharing from the frontend app at localhost:3000 and parses incoming data, and establishes both the /video/token route and listener. The /video/token route will be the only route in your server.

Before the server can perform any functionality, you will need to first set up your Twilio credentials.

Getting your Twilio credentials

In .env, copy and paste the following code:

TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_KEY_SID=
TWILIO_KEY_SECRET=

The first two variables to add to .env can be found in the dashboard of your Twilio Console (assuming you created a free account). Your Account SID and Auth Token are located under the Account Info section on the bottom of the page. Copy your Account SID by clicking the button next to the respective box and paste it after TWILIO_ACCOUNT_SID=. Repeat the process with your Auth Token and paste it after TWILIO_AUTH_TOKEN=.

Twilio Console

The last two things to add to .env are generated on this page of your Twilio console. You may need to verify your account by entering a code sent to your email the first time you access this page. On the upper right side of the page, click the blue button labeled Create API key. Enter an easily distinguishable Friendly name and then click the blue button on the bottom of the page labeled Create API Key.

The next page (pictured below) contains your API key SID and Secret. Copy and paste your SID by clicking on the button next to the respective box and paste it after TWILIO_KEY_SID=. Repeat this process with your Secret and paste it after TWILIO_KEY_SECRET=.

Secret key page

If you want to use this API key for another project, then be sure to copy and paste TWILIO_KEY_SECRET in a safe place. You will NOT be able to access it again after you proceed from this page.

With the .env file filled out, you can now check the acknowledgement box and hit the blue button labeled Done.

Generating access tokens

To generate an access token, users will need to provide a valid identity to the function and provide the room name they want to connect to. Copy and paste the code below into tokens.js:

const AccessToken = require("twilio").jwt.AccessToken;
const VideoGrant = AccessToken.VideoGrant;

const generateToken = (identity) => {
  return new AccessToken(
    process.env.TWILIO_ACCOUNT_SID,
    process.env.TWILIO_KEY_SID,
    process.env.TWILIO_KEY_SECRET,
    { identity }
  );
};

const videoToken = (identity, room) => {
  const videoGrant = new VideoGrant({ room });
  const token = generateToken(identity);
  token.addGrant(videoGrant);
  return token.toJwt();
};

module.exports = { videoToken }

This code initializes the AccessToken object and the VideoGrant method. The helper function generateToken() creates a new access token using a provided identity. The videoToken() function utilizes generateToken() to create a token, adds a video grant to it, and finally returns it. The videoToken() function is exported and will be used as a helper function in your index.js file.

Finally, open index.js up and add the highlighted lines of code:

require("dotenv").config();
const express = require("express");
const cors = require("cors");
const { videoToken } = require("./tokens");

const app = express();
app.use(cors({
  origin: `http://localhost:3000`
}));
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

app.post('/video/token', async (req, res) => {
  const identity = req.body.identity;
  const room = req.body.room;
  const token = await videoToken(identity, room);
  res.set('Content-Type', 'application/json');
  res.send(
    JSON.stringify({ token })
  );
});

app.listen(3001, () => {
  console.log('Express server listening on localhost: 3001.');
});

The videoToken() function now gets imported into index.js and is used in the /video/token route to create a room and generate the token that will be sent back to the user. To receive a token, users should include their identity and the room they wish to connect to in the body of their request.

Open up package.json and add the start script under the scripts property:

{
  "scripts": {
    "start": "nodemon index.js",
  },
}

Return to your terminal and make sure you are in the solid-video-backend directory. Then, run the following command to start your server:

npm run start

Now that your backend server is complete, you can start creating your frontend app.

Be sure to keep your server running for the duration of the entire tutorial. If you don’t, then you won’t be able to access any Twilio Video rooms.

Additionally, if you would like to test the route before proceeding, send an HTTP request to http://localhost:3001/video/token using Postman or cURL! You will not need to enter your environment variables in the request as they are already specified in your server.

Creating your frontend application

Navigate to your solid-video-frontend directory and open it up in your code editor. If you’ve worked with React before, then this file structure should seem familiar to you. If you haven’t, though, don’t worry!

First, generate the frontend file structure you’ll be working with for this tutorial. Open up a terminal in your frontend directory and run the following commands:

cd src
mkdir components
cd components
touch Lobby.jsx Participant.jsx Room.jsx Main.jsx
cd ../..

These commands will generate the components directory and four components that you’ll be using for this tutorial, and then will redirect you back to the frontend directory.

  • Lobby is a form component where users enter their identity and the room to connect to
  • Participant is the main video component that displays all room participants’ video and audio
  • Room is a component that provides the layout for all participants
  • VideoChat is a parent component for Room and Lobby which handles page layout and state

Before diving into any of the components, open up src/App.jsx and replace all of the preexisting code with the code below:

function App() {
  return (
    <div className="app">
      <header>
        <h1>Video Chat in SolidJS</h1>
      </header>
      <main>
        Video will go here
      </main>
      <footer>
        <p>Made in SolidJS!</p>
      </footer>
    </div>
  );
};

export default App;

Solid utilizes JSX to structure the HTML that gets loaded onto the app’s page, similar to React. To serve your application locally, open your terminal, ensure that you are in solid-video-frontend, and run the following command:

npm run dev

Keep your application running for the entire tutorial. Any changes made to files will be saved and Solid will automatically refresh your application with the new changes. Navigate to localhost:3000 in your browser, and you should see the following page:

Plain app

The layout looks as expected, but is a bit plain. Open up src/index.css and replace it with the code found in this file. Save the index.css file and your page should now look like this:

Formatted app

Now, open up components/Main.jsx to set up your page layout and initialize some important variables.

Developing the main room—Main.jsx

The Main component will handle the entire page layout, which includes the Room and Lobby components. Solid has a Show component which makes conditional rendering more readable and can be passed in the props fallback and when. Solid will render whatever is passed into the fallback prop if the when condition is not truthy.

The Main component will show the Lobby component if a user has not acquired a token from the backend, and will show the Room component whenever they acquire a token and connect to the room.

Copy and paste the following code into components/Main.jsx:

import { createSignal, Show } from "solid-js";
import { createStore } from "solid-js/store";
import Lobby from "./Lobby";
import Room from "./Room";

export default function Main() {
  const [formState, setFormState] = createStore({
    identity: "",
    room: ""
  });
  const [token, setToken] = createSignal(null);

  return(
    <div className="main-content">
      <Show
        when={token() === null}
        fallback={
          <Room
            room={formState.room}
            token={token}
            setToken={setToken}
          />
        }
      >
        <Lobby
          formState={formState}
          setFormState={setFormState}
          token={token}
          setToken={setToken}
        />
      </Show>
    </div>
  );
};

If you recall from earlier, the backend server requires two parameters to be sent in the body of a request: identity and room. These variables and their setter function will be passed into the Room and Lobby components alongside the token variable and its setter function.

createSignal() and createStore() are both similar to React’s useState() hook. createSignal() handles a single value which is stored as a whereas createStore() handles multiple values stored as proxy objects.

Handling the form—Lobby.jsx

When a user first loads up your application, you will want them to fill out a form where they can set their username and the name of the room they want to connect to. Open components/Lobby.jsx and paste the following code:

export default function Lobby(props) {
  
  const handleChange = (event) => {

  };

  const handleSubmit = async (event) => {
    event.preventDefault();
  };

  return(
    <form onSubmit={handleSubmit} action="POST">
        <label>
          Username 
          <input 
            type="text" 
            placeholder="Username"
            onChange={(evt) => handleChange(evt)} 
            name="identity" 
            id="identity"
            required
          />
        </label>
        <label>
          Room Name 
          <input 
            type="text" 
            placeholder="Room Name"
            onChange={(evt) => handleChange(evt)} 
            name="room" 
            id="room" 
            required
          />
        </label>
      <input type="submit" value="Submit" />
    </form>
  );
};

There are two form inputs that will track the user’s name and the room they want to connect to. When the form input is committed (focus on the input is lost), then the handleChange() function will be invoked. When the form is submitted, the handleSubmit() will be invoked.

Unlike how React tracks every keystroke in a form input when an onChange() function is passed, Solid tracks onChange() form inputs whenever unfocused or submitted by default. Form inputs in Solid are not controlled by the value attribute.

Now, add proper functionality into the handleChange() and handleSubmit() function by adding the highlighted lines of code below:

  const handleChange = (event) => {
    const name = event.target.name
    const value = event.target.value
    props.setFormState(() => ({
      [name]: value
    }));
  };

  const handleSubmit = async (event) => {
    event.preventDefault();
    const data = await fetch('http://localhost:3001/video/token', {
      method: 'POST',
      body: JSON.stringify({
        identity: props.formState.identity,
        room: props.formState.room
      }),
      headers: {
        'Content-Type': 'application/json'
      }
    });
    const response = await data.json();
    await props.setToken(response.token);
  };

Do NOT destructure the props object in the function invocation as you would in React. This will ruin the props’ reactivity in Solid. If you want to improve readability while maintaining reactivity, use the splitProps() function instead.

Unlike in React, you do not need to merge the previous formState with the updated value when handling state variables.

The handleChange() function attached to the form inputs will now invoke the setFormState() function that was passed into it from the Main component whenever an input is committed. When the form is submitted, a request is sent to the backend server via the fetch API and returns a valid access token. From here, the setToken() function is invoked and the token variable is set. This makes the condition property truthy in the For component in Main.jsx. The Room component is then rendered instead of the Lobby component.

To see how the Lobby component looks in the app, modify src/App.jsx with the following lines:

import Main from './components/Main';

function App() {
  return (
    <div className="app">
      <header>
        <h1>Video Chat in SolidJS</h1>
      </header>
      <main>
        <Main />
      </main>
      <footer>
        <p>Made in SolidJS!</p>
      </footer>
    </div>
  );
};

export default App;

Then navigate to components/Room.jsx and add the following lines as a placeholder:

export default function Room(props) {
  console.log(props.token())
  return(
    <div>
      Check your console for the token! (Press F12)
    </div>
  );
};

Now, your page should now look like this:

Lobby screen

This is the Lobby component that you just finished creating. If you enter any username and room name then hit the button labeled Submit, you should get a token back from the server. Upon doing so, the Lobby component will unrender (also known as dismounting) and the Room component will render. The app should now look like this:

Room component without functionality

When you open up your console (you can do this by hitting the F12 button on your keyboard), you will get a long string of numbers and letters - this is your access token. The token will be utilized by the Room component to connect to the video room.

Creating rooms—Room.jsx

Now that you have the token, you can use it to connect to a Twilio Video room. Once a user connects to the room, they can and allow the app to read both audio and video tracks that come in from each participant in the room. The Room component will handle connecting to the room and the layout of each participant (including the user themselves).

The next section may take some time to understand, so try to console.log() variables after their setter functions and see what you’re working with! The upcoming room and participants variables are key objects with many properties that are good candidates to log.

First, start laying out the basics of your Room component by replacing the code in components/Room.jsx:

import { createSignal, createEffect, Show, onCleanup, For } from "solid-js";
import { connect } from "twilio-video";
import Participant from "./Participant";

export default function Room(props) {
  const [room, setRoom] = createSignal(null);
  const [participants, setParticipants] = createSignal([]);

  createEffect(async () => {

  });

  onCleanup(() => {

  });

  const handleLogout = () => {
    props.setToken(null);
  };
  
  return(
    <div className="room">
      <div className="room-info">
        <h2>Room: {props.room}</h2>
        <button onClick={handleLogout}>Log out</button>
      </div>
      <h3>You</h3>
      <div className="local-participant">
        <Show
          when={room() !== null}
          fallback={''}>
          <Participant
            participant={room().localParticipant}
          />
        </Show>
      </div>
      <h3>Everyone Else</h3>
      <div className="remote-participants">
        <For each={participants()}>{(participant, i) => 
          <Participant
            participant={participant}
          />
        }</For>
      </div>
    </div>
  );
};

On the top of the component, there will be room information and a button labeled Log out that invokes the handleLogout() function when clicked. The handleLogout() function should remove the user’s token, which then triggers the Show component to dismount the Room component and render the Lobby component.

Below that, the local participant (the user) will be displayed above all other participants connected to the room. Solid’s For component is utilized to pass in every participant from the participants array. The room variable will store all information about the room the user is connected to, while the participants array will store the participants.

The main functionality of the code will be written in the createEffect() and onCleanup() functions—this includes connecting to the room and monitoring both participant connections and disconnections. The createEffect() function will run whenever any signals inside it change as well as when the component is first rendered (also known as mounting). The onCleanup() function will run when the component dismounts.

Similar to React class components’ lifecycle methods or the React useEffect() hook, Solid has lifecycle methods with onCleanup(), onMount(), and effects with the createEffect() function.

For the first step to full functionality, fill out createEffect() with the lines of code below:

  createEffect(async () => {
    const connectParticipant = (participant) => {
      setParticipants((participants) => [...participants, participant]);
    };

    const disconnectParticipant = (participant) => {
      setParticipants((participants) => participants.filter((p) => p !== participant));
    };

    const foundRoom = await connect(props.token(), {
      name: props.room
    });

    setRoom(foundRoom);
    
    room().participants.forEach((p) => {
      setParticipants((participants) => [...participants, p]);
    });
    room().on('participantDisconnected', disconnectParticipant);
    room().on('participantConnected', connectParticipant);
  });

There are two helper functions inside of createEffect(): connectParticipant() and disconnectParticipant(). These functions will add or remove participants respectively from the participants array whenever called.

Whenever the Room component is first rendered, the user’s token and desired room name get passed into the connect() function imported from twilio-video. The function then attempts to make a connection to the desired room. Once a room is found (or created, if it doesn’t exist yet), then the array of participants is updated with setParticipants() for every participant currently in the room.

Finally, the connected Twilio room can detect when a user connects to it or disconnects from it and can invoke the proper helper function.

Now, modify onCleanup() and add some event listeners below it:

  onCleanup(() => {
    setRoom((room) => {
      room.disconnect();
      return null;
    });
  });

  window.addEventListener('beforeunload', () => room().disconnect());
  window.addEventListener('pagehide', () => room().disconnect());
  window.addEventListener('onunload', () => room().disconnect());

Whenever the user logs out of the room, the component will dismount, then run the code inside of onCleanup(), which disconnects them from the room. Whenever a user closes the window or navigates away from the page, then they will also be disconnected from the room.

Testing this code functionality will be difficult until you set up the Participant component and render every participant’s video and audio tracks.

Seeing and hearing participants—Participant.jsx

Now that you have access to the participants in your Twilio Video room, you need to enable communication across all room participants. Once all the functionality for the Participant component is completed, users will be able to identify each other, see each others’ video, and hear each others’ audio.

Open components/Participant.jsx and paste the following code outline:

import { createEffect, createSignal, onCleanup } from "solid-js"

export default function Participant(props) {
  let video, audio;
  const [videoTracks, setVideoTracks] = createSignal([]);
  const [audioTracks, setAudioTracks] = createSignal([]);

  const trackpubsToTracks = (trackMap) => {
    return Array.from(trackMap.values())
      .map((publication) => publication.track)
      .filter((track) => track !== null);
  };

  createEffect(() => {
    // Will handle participants
  });

  createEffect(() => {
    // Will handle video tracks
  });
  
  createEffect(() => {
    // Will handle audio tracks
  });

  onCleanup(() => {
    
  });

  return(
    <div className="participant">
      <video ref={video} autoPlay={true}></video>
      <audio ref={audio} autoPlay={true}></audio>
      <h3>{props.participant.identity}</h3>
    </div>
  );
};

Every participant object that was passed into the Participant component has a Map of TrackPublication objects which are either audio or video tracks. This Map needs to be broken down to track objects before users can subscribe to them, and any null tracks must be removed. These tracks will be stored in the audioTracks and videoTracks arrays and updated based on the number of participants in the room.

There are three different createEffect() functions that will handle participant changes, video track changes, and audio track changes. Additionally, there is an onCleanup() function that will run if a user disconnects from the room.

First, modify the createEffect() function that will handle participants:

  createEffect(() => {
    const trackSubscribe = (track) => {
      if(track.kind === 'video') {
        setVideoTracks((tracks) => [...tracks, track]);
      } else {
        setAudioTracks((tracks) => [...tracks, track]);
      };
    };

    const trackUnsubscribe = (track) => {
      if(track.kind === 'video') {
        setVideoTracks((videoTracks) => videoTracks.filter((v) => v !== track));
      } else {
        setAudioTracks((audioTracks) => audioTracks.filter((a) => a !== track));
      };
    };

    setVideoTracks(trackpubsToTracks(props.participant.videoTracks));
    setAudioTracks(trackpubsToTracks(props.participant.audioTracks));

    props.participant.on('trackSubscribed', trackSubscribe);
    props.participant.on('trackUnsubscribed', trackUnsubscribe);
  });

Whenever a participant gets passed into this component, this code will run. There are two helper functions, trackSubscribe() and trackUnsubscribe(), that will update either the audioTracks or the videoTracks depending on the type of track passed into the function. These functions will run whenever a participant subscribes or unsubscribes to another participant’s track.

Next, update the two remaining createEffect() functions to handle video and audio tracks:

  createEffect(() => {
    const videoTrack = videoTracks()[0];
    if(videoTrack) {
      videoTrack.attach(video);
    };
  });
  
  createEffect(() => {
    const audioTrack = audioTracks()[0];
    if(audioTrack) {
      audioTrack.attach(audio);
    };
  });

These functions will attach video and audio tracks to the video and audio variables that are used as refs for the <video> and <audio> tags respectively. In other words, these functions allow users to see and hear each other!

Finally, modify the onCleanup() function:

  onCleanup(() => {
    setVideoTracks([]);
    setAudioTracks([]);
    props.participant.removeAllListeners();
  });

Whenever a user disconnects from the room, this function removes the video and audio tracks they’re subscribed to immediately. Additionally, all other users will unsubscribe from the disconnected user’s tracks.

With that complete, navigate to localhost:3000 in your browser. Enter your desired username and room name, then hit Submit!

If you would like to test how your application works with multiple participants, you can connect to localhost:3000 from multiple browser tabs or windows.

Working video with three participants

Conclusion

Congratulations! You’ve just learned how to create a video app with Solid and Twilio’s Video API. Hopefully this tutorial helped you with the basics of not only Twilio Video, but Solid! I personally love both of these technologies and can’t wait to see how they develop in the future.

If you want to learn how to implement Twilio Video with React, check out the post that inspired mine which uses hooks and this post which uses class components. Also check out this post to learn how to add mute and unmute functionality or this post to add “raise hand” functionality.

I can’t wait to see what you build next!

Dainyl Cua is a Developer Voices Intern on Twilio’s Developer Network. They would love to talk at any time and help you out. They can be reached through email via dcua[at]twilio.com or through LinkedIn.