Blog

How to build private video consultations in Next.js

· Andrij Tkachenko · 9 min read

A browser window with a video consultation of two people, a padlock on its top bar and the call controls under them, beside a column of messages and a file with a paper clip

A video consultation looks like one call, and in a Next.js app it is several features. A client books half an hour with a doctor, a lawyer, a tutor or a counsellor, and that time goes from every page at once, so nobody else books it. At its time the client waits, and the expert, on whatever page they are, sees them waiting and lets them in. The visit is a video call of the two that nobody else may join, and that the service carrying it can neither see nor hear. Beside it, the messages and files of the appointment, kept by your own server, and after it, the notes of the expert.

This post goes through every part a real app needs, with a whole working example: Consultations, the private consultations site of our Next.js guide, where clients book experts, wait in a waiting room and meet them on video. You can try it live: be Ava, whose visit with Lena is open now, and press I'm ready. Then open the site in a private window as Lena, who sees Ava in the waiting room, and let Ava in.

What a video consultation needs

  • A booking that holds. The times still free on every page, and a time gone from all of them the moment someone books it.
  • A waiting room. The client says they are ready, and the expert sees them waiting on any page, not only the visit's.
  • Letting in. Only the two of the appointment, only around its time, and the client only once the expert lets them in.
  • Privacy. The camera and the microphone encrypted end to end, so the servers that carry the call forward what they can't see or hear.
  • Messages and files beside it, kept where you keep your data, and notes for the client afterwards.
  • An end. The visit closes at the end of its half hour, and nobody comes back into it.

You can build each of these yourself, on WebRTC, media servers and a websocket server of your own. Or you let a realtime cloud run the media, the waiting room and the call screen, while your server keeps what is really yours: the bookings, the rules and the files. That is what Consultations does, through WordPlus Cloud.

The example: a consultations site

Experts offer half hours: a family doctor, a lawyer, a tutor, a counsellor. Clients book a time, or talk now to an expert who is free. Each appointment has a page of its own: its visit, its messages, and once the visit is over, the notes of the expert. It is a Next.js app with the App Router, Better Auth and Postgres, and its realtime part is a few files. Five steps make its visits.

1. Times that go as they are booked

A client books a time with a server action. The server keeps it, and a half hour already taken is refused, by the database itself. Then it says so through WordPlus Cloud, twice:

app/(app)/actions.ts
export async function bookVisit(expert: string, startsAt: number, topic: string): Promise<{ error: string }> {
    const me = await currentUserId();
    if (!me) return { error: 'Sign in first.' };
    const booked = await book(me, expert, startsAt, topic);
    if (typeof booked === 'string') return { error: booked };
    await realtime.toUser(expert).emit('booked', { appointment: booked.id, name: booked.clientName, startsAt: booked.startsAt });
    // Every page showing this expert's times takes this one away.
    await realtime.to('experts').emit('changed');
    redirect(`/appointments/${booked.id}`);
}

toUser() reaches every page the expert has open, on any device, with a notice of the new visit. experts is a room every signed-in page joins, and only the server speaks in it. Each page hears changed and asks its server for itself again, with no reload:

app/(app)/live.tsx
useRoom('experts', { changed: () => router.refresh() });

A client who was looking at that expert's times sees the half hour go. The same room tells the pages when a visit starts or ends, so the experts who are free right now stay current too. WordPlus Cloud knows which visits are on (realtime.listCalls()), and the site keeps no table of it.

2. The waiting room

Each appointment has a waiting room, a presence room named after it:

lib/realtime.ts
realtime.room('waiting:{id}', { presence: true }, async (user, { id }) => mayChat(user, id));

Only the expert and the client of the appointment get in. When the client presses I'm ready, their page joins the room, and stays in it while the page is open. The expert's page reads who is in it:

app/(app)/appointments/[id]/visit.tsx
function useWaiting(id: string, client: string): boolean {
    return usePresence(`waiting:${id}`).some((member) => String(member.id) === client);
}

Presence follows the page: a client who closes the tab leaves the waiting room by that alone. And the expert doesn't have to be on the visit's page. The server tells them, with a waiting event through realtime.toUser(), and on whatever page they are, a notice says "Ava is in the waiting room" and opens the visit.

3. Letting the client in

The visit is a group call named after the appointment, visit:81, declared once, by that pattern, with its rule:

lib/realtime.ts
realtime.groupCalls('visit:{id}', async (user, { id }) => mayJoinVisit(user, id), { encrypt: true });
lib/clinic.ts
export async function mayJoinVisit(user: string | number | null, id: string): Promise<boolean> {
    const appointment = await appointmentOf(id);
    if (!appointment || !isParty(appointment, user) || !visitOpen(appointment)) return false;
    return String(user) === appointment.expert || appointment.admittedAt !== null;
}

The two of the appointment, and nobody else, while the visit is open: from ten minutes before its time to the end of its half hour, unless it ended sooner. The expert comes in whenever it is open, the client once the expert has let them in. Letting in is a server action, which marks the appointment and tells the client:

app/(app)/actions.ts
export async function letIn(id: string): Promise<boolean> {
    const me = await currentUserId();
    const appointment = me ? await admit(id, me) : null;
    if (!appointment) return false;
    await realtime.toUser(appointment.client).emit('admitted', { appointment: id, name: appointment.expertName });
    return true;
}

From then on, the rule says yes to the client, and the client's page, waiting, comes into the visit by itself. When a page asks to join, it asks the site's own server first, at /api/realtime/calls, the same route handler the dating site's calls go through (how to implement video calls in Next.js). The rule runs with the site's own session, and the page gets a pass for the visit, signed with the app's secret, or the reason why not.

4. The visit, encrypted end to end

{ encrypt: true } on the rule makes the visit end to end. Each browser encrypts its camera and microphone with a key the site's server makes, which only those the rule lets in get. The keys stay with the site and the two browsers, and WordPlus Cloud's media servers forward what they can't see or hear (how media servers work).

The visit shows in the appointment's page itself, where the page puts it:

app/(app)/appointments/[id]/visit.tsx
<CallSpot rt={rt} call={`visit:${id}`} join={join} joinWith="microphone camera" className="h-[32rem] rounded-2xl">
    {/* The waiting room as each of the two sees it, until the page joins */}
</CallSpot>

Before the visit, CallSpot shows what is inside it: the waiting room as each of the two sees it, with Let Ava in for the expert. join takes the page into the visit, once the expert lets the client in, or once the client is let in while waiting. joinWith="microphone camera" turns both on as they come in, as a visit is meant to be seen and heard at once. On another page of the app, the visit goes on in a window in the corner, and comes back here when the appointment's page opens again.

A visit also ends. The expert ends it with a button, and the site ends it at the end of its half hour, from a timer it sets as the visit starts:

lib/realtime.ts
export async function endVisit(appointment: Appointment) {
    await realtime.call(visitOf(appointment.id)).end().catch(() => {}); // not on, if nobody came in
    await markEnded(appointment.id);
    await realtime.toUser([appointment.expert, appointment.client]).emit('ended', { appointment: appointment.id });
    await realtime.to('experts').emit('changed');
}

end() takes both out of the call, the rule, seeing the appointment ended, lets nobody back in, and both pages say the visit has ended.

5. Messages and files, then the notes

Each appointment has a conversation, the room chat:{id}, for its two alone, before the visit, during it and after. The ready-made Chat shows it beside the visit, with typing, read ticks and files:

app/(app)/appointments/[id]/messages.tsx
<Chat
    rt={useRealtime()}
    room={room}
    messages={`/api/messages?room=${room}`}
    upload="/api/files"
    accept="image/*,.pdf,.doc,.docx,.txt"
    maxFileSize={10 * 1024 * 1024}
/>

A picture, a scan or a contract goes up to the site's own route as it is picked, and the route asks the room's own rule before keeping it:

app/api/files/route.ts
if (!me || !appointment || !(file instanceof File) || !(await realtime.mayJoin(room, me))) {
    return Response.json({ error: 'This file is not for this conversation.' }, { status: 403 });
}

The site keeps the file in its own database and opens it to the two alone. WordPlus Cloud carries only the message that says where it is, sealed with a key the site holds, and never the file. The site keeps each message too, from the message webhook, so the conversation is there for both of them after the visit. Once the visit is over, the expert writes notes for the client, which the site keeps, and the client's page shows them at once, through toUser() again.

What comes with it

  • Talk now. A client asks an expert who is free for a session now. The expert's page shows the request, with a minute to take it, and both go straight into a new visit.
  • Who is free, current on every page. An expert in a visit shows as busy, from realtime.listCalls(), with no table of yours.
  • Online or not. The waiting room tells the client whether the expert is online yet, from useOnline().
  • Names and faces from your app. Each tile and message shows a name and photo as the site's profile gives them, with no list of users kept in WordPlus Cloud.

Try it, then build your own

On the live demo, the experts and clients are yours alone for a day, so whatever you write or whoever you meet there, nobody else sees, and a visit ends by itself after 10 minutes. Under each page, How this page works shows the code that makes it, a step at a time, with the values of the page you have open.

The private consultations guide builds the whole site, with every file of its realtime side, calls in Next.js has every way to put a call in your own app, and encryption says what is sealed and who holds each key. For a meeting of a whole team rather than two, see how to implement video conferencing in Next.js.

WordPlus Cloud for Apps is priced by concurrent connections, starting from 1,000, billed monthly or yearly. A visit is a group call of two. With 1,000 connections, group calls are unmetered up to 32 people on video at once, 16 visits, a person on audio counting half, and each further 1,000 connections add 12. Above that, each extra person on video takes a credit a minute, and calls between two people take none. See video calls for apps, or the apps pricing. Questions about your integration? Write to us.