DEV Community

loading...
Cover image for Build a real time video chat app with Next.js and Daily
Daily

Build a real time video chat app with Next.js and Daily

Kimberlee Johnson
Developer Relations Engineer at Daily. I used to draft press releases, but now I draft pull requests too 💚
Originally published at daily.co ・7 min read

We built one of our first Daily demos with React, because we like working with the framework. We’re not alone. More developers expressed interest in learning React than in picking up any other web framework in the 2020 Stack Overflow Developer Survey.

Meta frameworks for React like Next.js are also gaining traction, so we built a basic video call demo app using Next.js and the Daily call object.

Screenshot of a video chat app

The demo draws inspiration from the new Daily Prebuilt (We’ll eventually open source Daily Prebuilt’s components, stay tuned!), using shared contexts and custom hooks that we hope help get your own apps up and running ASAP. Dive right into the repository or read on for a sneak peek at some of the most foundational pieces, like the core call loop (shared contexts and hooks) and generating meeting tokens.

Run the demo locally

You can find our basic Next.js and Daily video chat demo in our ✨ new ✨ daily-demos/examples repository. This is a living repo. It’ll grow and evolve as Daily does and as we receive feedback. Poke around and you might notice a few other demos in progress. To hop right into the basic Next.js and Daily app:

  1. Fork and clone the repository
  2. cd examples/dailyjs/basic-call
  3. Set your DAILY_API_KEY and DAILY_DOMAIN environment variables (see env.example)
  4. yarn
  5. yarn workspace @dailyjs/basic-call dev

The core call loop: shared contexts and hooks

As you’re probably well aware in the year 2021, lots of things can happen on video calls. Participants join and leave, mute and unmute their devices, not to mention the funny things networks can decide to do. Application state can get unwieldy quickly, so we make use of the Context API to avoid passing ever-changing props to all the different components that need to know about the many states.

Six contexts make up what we refer to as our call loop. They handle four different sets of state: devices, tracks, participants, and call state, in addition to a waiting room experience and the overall user interface.

// pages/index.js

  return (
    <UIStateProvider>
      <CallProvider domain={domain} room={roomName} token={token}>
        <ParticipantsProvider>
          <TracksProvider>
            <MediaDeviceProvider>
              <WaitingRoomProvider>
                <App />
              </WaitingRoomProvider>
            </MediaDeviceProvider>
          </TracksProvider>
        </ParticipantsProvider>
      </CallProvider>
    </UIStateProvider>
  );
Enter fullscreen mode Exit fullscreen mode

Some of the contexts also make use of custom hooks that abstract some complexity, depending on the, well, context.

With that pun out of the way, let’s dive into each of the contexts except for <WaitingRoomProvider>, You’ll have to...wait for a post on that one.

Okay, really, we’re ready now.

Managing devices

The <MediaDeviceProvider> grants the entire app access to the cams and mics used during the call.

// MediaDeviceProvider.js

return (
   <MediaDeviceContext.Provider
     value={{
       cams,
       mics,
       speakers,
       camError,
       micError,
       currentDevices,
       deviceState,
       setMicDevice,
       setCamDevice,
       setSpeakersDevice,
     }}
   >
     {children}
   </MediaDeviceContext.Provider>
 );
Enter fullscreen mode Exit fullscreen mode

<MediaDeviceProvider> relies on a useDevices hook to listen for changes to the call object to make sure the app has an up to date list of the devices on the call and each device’s state.

// useDevices.js

const updateDeviceState = useCallback(async () => {

   try {
     const { devices } = await callObject.enumerateDevices();

     const { camera, mic, speaker } = await callObject.getInputDevices();

     const [defaultCam, ...videoDevices] = devices.filter(
       (d) => d.kind === 'videoinput' && d.deviceId !== ''
     );
     setCams(
       [
         defaultCam,
         ...videoDevices.sort((a, b) => sortByKey(a, b, 'label', false)),
       ].filter(Boolean)
     );
     const [defaultMic, ...micDevices] = devices.filter(
       (d) => d.kind === 'audioinput' && d.deviceId !== ''
     );
     setMics(
       [
         defaultMic,
         ...micDevices.sort((a, b) => sortByKey(a, b, 'label', false)),
       ].filter(Boolean)
     );
     const [defaultSpeaker, ...speakerDevices] = devices.filter(
       (d) => d.kind === 'audiooutput' && d.deviceId !== ''
     );
     setSpeakers(
       [
         defaultSpeaker,
         ...speakerDevices.sort((a, b) => sortByKey(a, b, 'label', false)),
       ].filter(Boolean)
     );

     setCurrentDevices({
       camera,
       mic,
       speaker,
     });

   } catch (e) {
     setDeviceState(DEVICE_STATE_NOT_SUPPORTED);
   }
 }, [callObject]);

Enter fullscreen mode Exit fullscreen mode

useDevices also handles device errors, like if a cam or mic is blocked, and updates a device’s state when something changes for the participant using the device, like if their tracks change.

Keeping track of tracks

Different devices share different kinds of tracks. A microphone shares an audio type track; a camera shares video. Each track contains its own state: playable, loading, off, etc. <TracksProvider> simplifies keeping track of all those tracks as the number of call participants grows. This context listens for changes in track state and dispatches updates. One type of change, for example, could be when a participant’s tracks start or stop.

// TracksProvider.js

export const TracksProvider = ({ children }) => {
 const { callObject } = useCallState();
 const [state, dispatch] = useReducer(tracksReducer, initialTracksState);

 useEffect(() => {
   if (!callObject) return false;

   const handleTrackStarted = ({ participant, track }) => {
     dispatch({
       type: TRACK_STARTED,
       participant,
       track,
     });
   };
   const handleTrackStopped = ({ participant, track }) => {
     if (participant) {
       dispatch({
         type: TRACK_STOPPED,
         participant,
         track,
       });
     }
   };

   /** Other things happen here **/

   callObject.on('track-started', handleTrackStarted);
   callObject.on('track-stopped', handleTrackStopped);
   }, [callObject];
Enter fullscreen mode Exit fullscreen mode

Handling participants

<ParticipantsProvider> makes sure any and all participant updates are available across the app. It listens for participant events:

// ParticipantsProvider.js

 useEffect(() => {
   if (!callObject) return false;

   const events = [
     'joined-meeting',
     'participant-joined',
     'participant-updated',
     'participant-left',
   ];

   // Listen for changes in state
   events.forEach((event) => callObject.on(event, handleNewParticipantsState));

   // Stop listening for changes in state
   return () =>
     events.forEach((event) =>
       callObject.off(event, handleNewParticipantsState)
     );
 }, [callObject, handleNewParticipantsState]);
Enter fullscreen mode Exit fullscreen mode

And dispatches state updates depending on the event:

// ParticipantsProvider.js

const handleNewParticipantsState = useCallback(
   (event = null) => {
     switch (event?.action) {
       case 'participant-joined':
         dispatch({
           type: PARTICIPANT_JOINED,
           participant: event.participant,
         });
         break;
       case 'participant-updated':
         dispatch({
           type: PARTICIPANT_UPDATED,
           participant: event.participant,
         });
         break;
       case 'participant-left':
         dispatch({
           type: PARTICIPANT_LEFT,
           participant: event.participant,
         });
         break;
       default:
         break;
     }
   },
   [dispatch]
 );
Enter fullscreen mode Exit fullscreen mode

<ParticipantsProvider> also calls on use-deep-compare to memoize expensive calculations, like all of the participants on the call:

// ParticipantsProvider.js

const allParticipants = useDeepCompareMemo(
   () => Object.values(state.participants),
   [state?.participants]
 );
Enter fullscreen mode Exit fullscreen mode

Managing room and call state

<CallProvider> handles configuration and state for the room where the call happens, where all those devices, participants, and tracks interact.

<CallProvider> imports the abstraction hook useCallMachine to manage call state.

// CallProvider.js

 const { daily, leave, join, state } = useCallMachine({
   domain,
   room,
   token,
 });
Enter fullscreen mode Exit fullscreen mode

useCallMachine listens for changes in call access, for example, and updates overall call state accordingly:

// useCallMachine.js

useEffect(() => {
   if (!daily) return false;

   daily.on('access-state-updated', handleAccessStateUpdated);
   return () => daily.off('access-state-updated', handleAccessStateUpdated);
 }, [daily, handleAccessStateUpdated]);

// Other things happen here

 const handleAccessStateUpdated = useCallback(
   async ({ access }) => {

     if (
       [CALL_STATE_ENDED, CALL_STATE_AWAITING_ARGS, CALL_STATE_READY].includes(
         state
       )
     ) {
       return;
     }

     if (
       access === ACCESS_STATE_UNKNOWN ||
       access?.level === ACCESS_STATE_NONE
     ) {
       setState(CALL_STATE_NOT_ALLOWED);
       return;
     }

     const meetingState = daily.meetingState();
     if (
       access?.level === ACCESS_STATE_LOBBY &&
       meetingState === MEETING_STATE_JOINED
     ) {
       return;
     }
     join();
   },
   [daily, state, join]
 );
Enter fullscreen mode Exit fullscreen mode

<CallProvider> then uses that information, to do things like verify a participant’s access to a room, and whether or not they’re permitted to join the call:

// CallProvider.js

useEffect(() => {
   if (!daily) return;

   const { access } = daily.accessState();
   if (access === ACCESS_STATE_UNKNOWN) return;

   const requiresPermission = access?.level === ACCESS_STATE_LOBBY;
   setPreJoinNonAuthorized(requiresPermission && !token);
 }, [state, daily, token]);
Enter fullscreen mode Exit fullscreen mode

If the participant requires permission to join, and they’re not joining with a token, then the participant will not be allowed into the call.

Generating Daily meeting tokens with Next.js

Meeting tokens control room access and session configuration on a per-user basis. They’re also a great use case for Next API routes.

API routes let us query endpoints directly within our app, so we don’t have to maintain a separate server. We call the Daily /meeting-tokens endpoint in /pages/api/token.js:

// pages/api/token.js

export default async function handler(req, res) {
 const { roomName, isOwner } = req.body;

 if (req.method === 'POST' && roomName) {

   const options = {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
       Authorization: `Bearer ${process.env.DAILY_API_KEY}`,
     },
     body: JSON.stringify({
       properties: { room_name: roomName, is_owner: isOwner },
     }),
   };

   const dailyRes = await fetch(
     `${process.env.DAILY_REST_DOMAIN}/meeting-tokens`,
     options
   );

   const { token, error } = await dailyRes.json();

   if (error) {
     return res.status(500).json({ error });
   }

   return res.status(200).json({ token, domain: process.env.DAILY_DOMAIN });
 }

 return res.status(500);
}
Enter fullscreen mode Exit fullscreen mode

In index.js, we fetch the endpoint:

// pages/index.js

const res = await fetch('/api/token', {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
     },
     body: JSON.stringify({ roomName: room, isOwner }),
   });
   const resJson = await res.json();
Enter fullscreen mode Exit fullscreen mode

What’s Next.js?

Please fork, clone, and hack away! There are lots of ways you could start building on top of this demo: adding custom user authentication, building a chat component, or pretty much anything that springs to mind.

We’d appreciate hearing what you think about the demo, especially how we could improve it. We’re also curious about other framework and meta-framework specific sample code that you’d find useful.

If you’re hoping for more Daily and Next.js sample code, we’ve got you covered. Come back soon!

Discussion (0)