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

2022-04-07: The code snippets in this post reference an older version of the examples repo prior to the release of Daily React Hooks. Any code not using Daily React Hooks is still valid and will work; however, we strongly suggest used Daily React Hooks in any React apps using Daily. To see the pre-Daily hooks version of the examples repo, please refer to the pre-daily-hooks branch. To learn more about Daily React Hooks, read our announcement post.

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 any other web framework according to 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 built with Daily and Next.js.
A video chat app built with Daily and Next.js.

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 shared contexts and hooks that make up the core call loop and how to generate meeting tokens with Next.js API routes.

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/custom/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>
  );

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>
 );

<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]);

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]; 

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]);

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]
 );

<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]
 );

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,
 });

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]
 );

<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]);

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);
}

In pages/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();

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!

Never miss a story

Get the latest direct to your inbox.