Tuesday, March 4, 2025

How to Build a Social Learning Platform using Next.js, Stream, and Supabase

Programming LanguageHow to Build a Social Learning Platform using Next.js, Stream, and Supabase


Social media and real-time communication have transformed how people interact, making it easier to share ideas, collaborate, and learn from others, regardless of location. From professional networks to online study groups, these platforms allow various forms of communication such as instant messaging, video calls, and content sharing.

In this tutorial, you’ll learn how to build a social learning platform that connects students with professionals across various fields. The platform enables users to:

  • Schedule video conferencing sessions that students can join,

  • Share posts or announcements about trending tools and upcoming sessions, and

  • Create community channels where students can engage with one another.

The Stream Video & Audio SDK and Stream Chat SDK will enable us to integrate video calls and community channels easily into the application.

Table of Contents

App Overview

The application consists of two types of users (students and instructors), each with access to specific features:

Students can do the following:

  • View an activity feed with posts from instructors and react to them.

  • Follow instructors in their field of interest.

  • Join upcoming video sessions and community channels.

  • Each student has an interest attribute that helps match them with relevant instructors.

Instructors can also:

  • Access a dashboard showing their follower count and post activity.

  • Schedule video conferences for students to join.

  • Make announcements or share posts.

  • Create community channels (if they haven’t already).

  • The platform suggests instructors to students based on shared career interests.

Here is an image showing the various functions that the users can perform:

Flowchart titled "User Functions" showing a hierarchy. "Users" split into "Students" and "Instructors". Students can "Follow Instructors", "Join Video Sessions", "Join Community Channels", and "Read and React to Posts". Instructors can "View Dashboard", "Create Posts", "Schedule Video Sessions", and "Create Community Channels".

Prerequisites

To fully understand this tutorial, you need to have a basic understanding of React or Next.js.

We will use the following tools:

  • Supabase: a Backend-as-a-service platform that makes it easy to integrate authentication, database, real-time communication, file storage, and edge functions within your software applications. It also supports multiple programming languages.

  • Stream Chat and Audio & Video SDK: a real-time communication platform that enables you to add video, chat, and various types of communication to your application.

  • Shadcn UI: a UI component library that provides customizable, beautifully designed, and accessible UI components for your applications.

Create a Next.js project by running the following code snippet:

npx create-next-app stream-lms

Install the package dependencies for the project:

npm install @supabase/supabase-js @supabase/ssr @stream-io/node-sdk @stream-io/video-react-sdk stream-chat stream-chat-react @emoji-mart/data @emoji-mart/react

To install the Shadcn UI library, follow the installation guide.

Once everything is set up, your Next.js project is ready. Now, let’s start building! 🚀

How to Set up Server-Side Authentication with Supabase

Here, you’ll learn how to configure Supabase, add server-side authentication, and protect pages from unauthorized users in a Next.js application. You’ll also learn how to handle the authentication logic efficiently using Next.js server actions.

How to Configure Supabase Authentication in a Next.js application

First, create a Supabase account and an organization that will contain your various Supabase projects.

Screenshot of a form on the Supabase website to create a new organization. It includes fields for organization name, type, and plan, with options such as "Personal" and "Free - $0/month." There are buttons for "Cancel" and "Create organization."

Add a new Supabase project to the organisation and copy the following credentials on your dashboard into a .env.local file at the root of your project:

NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon_key_from_Supabase_dashboard>
NEXT_PUBLIC_SUPABASE_URL=<supabase_project_url>

Create a utils/supabase folder at the root of the Next.js project and add the following files to the folder: client.ts, middleware.ts, and server.ts.

mkdir utils && cd utils
mkdir supabase && cd supabase
touch client.ts middleware.ts server.ts

Copy the following code into utils/supabase/client.ts. This initializes a Supabase browser client to interact with Supabase on client-side routes:

import  createBrowserClient  from "@supabase/ssr";

export function createClient() 
    return createBrowserClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
    );

Next, copy the following code into utils/supabase/server.ts. This creates a Supabase server client for handling authentication and interacting with Supabase in server-side requests:

import  createServerClient  from "@supabase/ssr";
import  cookies  from "next/headers";

export async function createClient() {
    const cookieStore = await cookies();

    return createServerClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
        {
            cookies: 
                getAll() 
                    return cookieStore.getAll();
                ,
                setAll(cookiesToSet) 
                    try 
                        cookiesToSet.forEach(( name, value, options ) =>
                            cookieStore.set(name, value, options)
                        );
                     catch 
                        
                        
                        
                    
                ,
            ,
        }
    );
}

Now, copy the following code into utils/supabase/middleware.ts. This middleware creates authentication cookies and protects pages from unauthorized access:

import  createServerClient  from "@supabase/ssr";
import  NextResponse, type NextRequest  from "next/server";

export async function updateSession(request: NextRequest) {
    let supabaseResponse = NextResponse.next(
        request,
    );
    
    const supabase = createServerClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
        
            cookies: 
                getAll() 
                    return request.cookies.getAll();
                ,
                setAll(cookiesToSet) 
                    cookiesToSet.forEach(( name, value ) =>
                        request.cookies.set(name, value)
                    );
                    supabaseResponse = NextResponse.next(
                        request,
                    );
                    cookiesToSet.forEach(( name, value, options ) =>
                        supabaseResponse.cookies.set(name, value, options)
                    );
                ,
            ,
        
    );

    
}

To enforce authentication, add the following code inside the placeholder in middleware.ts. This checks if a user is signed in and redirects unauthenticated users to the login page:


const 
    data:  user ,
 = await supabase.auth.getUser();


if (
    !user &&
    request.nextUrl.pathname !== "/" &&
    !request.nextUrl.pathname.startsWith("/instructor/auth") &&
    !request.nextUrl.pathname.startsWith("/student/auth")
) 
    
    const url = request.nextUrl.clone();
    url.pathname = "/student/auth/login"; 
    return NextResponse.redirect(url);


return supabaseResponse;

Add another middleware.ts file to the root of the Next.js project and copy the following code into the file:

import  type NextRequest  from "next/server";
import  updateSession  from "./utils/supabase/middleware";

export async function middleware(request: NextRequest) 
    return await updateSession(request);


export const config = 
    matcher: [
        
        "/((?!_next/static;

Finally, create an auth/confirm route and error page within the Next.js app folder.

You’ve successfully configured authentication in your Next.js project using Supabase.

Student Authentication with Supabase

In this section, you will learn how to create the signup and login functions for the students within the application.

First, create an actions folder in the root of your Next.js project and add an auth.ts file inside it. This file will contain all Supabase authentication functions.

Add the following imports to the top of the auth.ts file:

"use server";
import  revalidatePath  from "next/cache";
import  redirect  from "next/navigation";
import  createClient  from "../utils/supabase/server";

Next, you need to create the server functions that accept form data from the client and sign users up or log them in as students.

Copy the following code snippet into the actions/auth.ts file to create the user sign-up function:

export async function studentSignUp(formData: FormData) 
    const supabase = await createClient();

    
    const credentials = 
        email: formData.get("email") as string,
        password: formData.get("password") as string,
        interest: formData.get("interest") as string,
        name: formData.get("name") as string,
    ;

    
    const  data, error  = await supabase.auth.signUp(
        email: credentials.email,
        password: credentials.password,
        options: 
            data: 
                interest: credentials.interest,
                name: credentials.name,
            ,
        ,
    );

    

The code snippet above accepts the form credentials such as email, password, interest, and name, and signs the user up as a Supabase user.

Modify the function to return the user or error object.

export async function studentSignUp(formData: FormData) 
    

    if (error) 
        return  error: error.message, status: error.status, user: null ;
     else if (data.user?.identities?.length === 0) 
        return  error: "User already exists", status: 409, user: null ;
    

    revalidatePath("/", "layout");
    return  error: null, status: 200, user: data.user ;

Create the student login function as shown below:

export async function studentLogIn(formData: FormData) 
    const supabase = await createClient();

    const credentials = 
        email: formData.get("email") as string,
        password: formData.get("password") as string,
    ;
    const  data, error  = await supabase.auth.signInWithPassword(credentials);

    if (error) 
        return  error: error.message, status: error.status, user: null ;
    
    
    if (data && data.user.user_metadata.image) 
        return  error: "You are not a student", status: 400, user: null ;
    

    

    revalidatePath("/", "layout");
    return  error: null, status: 200, user: data.user ;

The code above takes the student’s email and password to log them into the application.

  • If an error occurs, it returns an error message.

  • If the user object includes an image attribute (indicating that they are an instructor), they are prevented from logging in.

Once the student is signed in, you must store their details in a Supabase table. This allows you to add a following_list column that tracks the instructors they follow. The list will be updated whenever the student follows or unfollows an instructor.

export async function studentLogIn(formData: FormData) 
    

    const  data: existingUser  = await supabase
        .from("students")
        .select()
        .eq("email", credentials.email)
        .single();

    
    if (!existingUser) 
        const  error: insertError  = await supabase.from("students").insert(
            email: credentials.email,
            name: data.user.user_metadata.name,
            interest: data.user.user_metadata.interest,
            id: data.user.id,
            following_list: [] as string[],
        );

        if (insertError) 
            return  error: insertError.message, status: 500, user: null ;
        
    

    revalidatePath("/", "layout");
    return  error: null, status: 200, user: data.user ;

Every time a student logs in, the code checks if they already exist in the students table.

  • If the student is found, no new entry is created.

  • If the student is not found, a new row with their details is added.

Each student’s data includes two primary keys: id and email and additional columns: interest, name, and following_list.

Student registration form with fields for full name, email address, interest, and password. Includes a "Register" button and a sign-in option for existing accounts. The browser tab shows the URL "localhost:3000".

Instructor Authentication with Supabase

The instructor’s user object is quite different from the student’s. It includes data such as email, password, name, interest, occupation, bio, URL, and image.

Add the following function to actions/auth.ts to handle instructor sign-ups:

export async function instructorSignUp(formData: FormData) 
    const supabase = await createClient();

    
    const credentials = 
        email: formData.get("email") as string,
        password: formData.get("password") as string,
        interest: formData.get("interest") as string,
        name: formData.get("name") as string,
        occupation: formData.get("occupation") as string,
        bio: formData.get("bio") as string,
        url: formData.get("url") as string,
        image: formData.get("image") as File,
    ;

    

Next, upload the image to Supabase Storage and retrieve its download URL before signing up the user as an instructor. Update the instructorSignUp function to show this:

export async function instructorSignUp(formData: FormData) 
    
    const  data: imageData, error: imageError  = await supabase.storage
        .from("headshots")
        .upload(`$crypto.randomUUID()/image`, credentials.image);

    if (imageError) 
        return  error: imageError.message, status: 500, user: null ;
    
    
    const imageURL = `$process.env.STORAGE_URL!$imageData.fullPath`;

    
    const  data, error  = await supabase.auth.signUp(
        email: credentials.email,
        password: credentials.password,
        options: 
            data: 
                interest: credentials.interest,
                name: credentials.name,
                occupation: credentials.occupation,
                bio: credentials.bio,
                url: credentials.url,
                image: imageURL,
            ,
        ,
    );

    
    if (error) 
        return  error: error.message, status: error.status, user: null ;
    

    revalidatePath("/", "layout");
    return  error: null, status: 200, user: data.user ;

Finally, an instructor login function that authenticates the user, similar to the student login function, should be created. It should check whether the instructor already exists in the instructors table. If the instructor does not exist, execute the function to add the instructor’s user object to the database table.

Here is the Supabase function for adding an instructor to the table:

const  error: insertError  = await supabase.from("instructors").insert(
    email: credentials.email,
    name: data.user.user_metadata.name,
    occupation: data.user.user_metadata.occupation,
    bio: data.user.user_metadata.bio,
    url: data.user.user_metadata.url,
    image: data.user.user_metadata.image,
    id: data.user.id,
    interest: data.user.user_metadata.interest,
    followers: [],
);

The instructors table includes an additional followers attribute, which stores an array of student IDs following the instructor. You can find the complete code on GitHub.

Additionally, authentication functions like getUserSession and logOut must be created. These functions will retrieve the current user’s object and allow them to log out when necessary, such as when clicking a logout button.

Instructor login page with fields for email and password, a "Sign in" button, and a link to create an account. A sidebar on the left displays "LinkedUp" with a "Student Sign-in" link.

The Application Database Design

In the previous section, we created two database tables: instructors and students, which store instructors and students separately. Instructors can also upload headshot images to Supabase Storage.

In this section, you’ll learn how to create these tables, define their access policies, and retrieve or modify data within the tables.

Announcements (data type) Instructors (data type) Students (data type)
id (int8) id (uuid) id (uuid)
created_at (timestamptz) created_at (timestamptz) created_at (timestamptz)
author_name (text) name (text) email (text)
interest (text) email (text) name (text)
author_title (text) occupation (text) interest (text)
author_id (uuid) bio (text) following_list (uuid[])
content (text) url (text)
likes (uuid []) interest (text)
author_image (text) image (text)
followers (uuid[])

Note: The instructors table includes an image column that stores the instructor’s headshot URL. You can obtain this by creating a Supabase bucket named headshot and uploading the image when the instructor signs up.

The instructors and students tables have two primary keys: id and email.

Supabase allows you to define policies for your tables, controlling the operations different users can perform within the application.

Next, let’s create the access policies for each table.

Access Policy for the Announcements Table

The announcements table has four access policies:

  • Enable delete operation for users based on their user ID.

      alter policy "Enable delete for users based on user_id"
      on "public"."announcements"
      to public
      using (
        (( SELECT auth.uid() AS uid) = author_id)
      );
    
  • Enable insert operation for authenticated users only.

      alter policy "Enable insert for authenticated users only"
      on "public"."announcements"
      to authenticated
      with check (
        true
      );
    
  • Enable read access for all users.

      alter policy "Enable read access for all users"
      on "public"."announcements"
      to public
      using (
        true
      );
    
  • Enable update operation for authenticated users only.

      alter policy "Enable update for authenticated users"
      on "public"."announcements"
      to authenticated
      using (
        (auth.role() = 'authenticated'::text)
      );
    

Access Policy for the Instructors Table

The instructors table has three policies:

  • Allow only authenticated users to update the instructors table.

      alter policy "Allow only authenticated users"
      on "public"."instructors"
      to authenticated
      using (
        (auth.role() = 'authenticated'::text)
      );
    
  • Enable insert operation for authenticated users only.

      alter policy "Enable insert for authenticated users only"
      on "public"."instructors"
      to authenticated
      with check (
        true
      );
    
  • Enable read access for all users.

      alter policy "Enable read access for all users"
      on "public"."instructors"
      to public
      using (
        true
      );
    

Access Policy for the Students Table

The students table has three access policies:

  • Enable insert operation for authenticated users only.

      alter policy "Enable insert for authenticated users only"
      on "public"."students"
      to authenticated
      with check (
        true
      );
    
  • Enable update operation for authenticated users only.

      alter policy "Enable update for only authenticated users"
      on "public"."students"
      to authenticated
      using ((auth.role() = 'authenticated'::text))
    
  • Enable read access for authenticated users only.

      alter policy "Read access for only authenticated users"
      on "public"."students"
      to authenticated
      using (
        true
      );
    

How to Add a Video Conferencing Feature with Stream

In this section, I’ll walk you through adding a video conferencing feature to the application using the Stream Audio & Video SDK. This will enable instructors to schedule educational sessions and allow students to join the meetings.

Setting Up Stream Video & Audio SDK in Next.js

Create a Stream account and a new organization that holds all your apps.

Form for creating an organization, with fields for organization name, e-mail address, and website URL, and buttons labeled "Cancel" and "Submit".

Add a new app to the organization and copy the Stream API and Secret key into the .env.local file.

NEXT_PUBLIC_STREAM_API_KEY=<paste_from_Stream_app_dashboard>
STREAM_SECRET_KEY=<paste_from_Stream_app_dashboard>

Dashboard interface displaying chat overview with key metrics like Monthly Active Users (4 MAUs), Max Concurrent Connections (2), and Message Volume (3). Includes app access keys created on February 17th, 2025.

Create a new file named stream.action.ts inside the actions folder at the root of your Next.js project. This is the same folder where the authentication server actions for Supabase are stored. Then, copy the following code snippet into the file:

"use server";

import  getUserSession  from "./auth";
import  StreamClient  from "@stream-io/node-sdk";

const STREAM_API_KEY = process.env.NEXT_PUBLIC_STREAM_API_KEY!;
const STREAM_API_SECRET = process.env.STREAM_SECRET_KEY!;

export const tokenProvider = async () => 
    const  user  = await getUserSession();

    if (!user) throw new Error("User is not authenticated");
    if (!STREAM_API_KEY) throw new Error("Stream API key secret is missing");
    if (!STREAM_API_SECRET) throw new Error("Stream API secret is missing");

    const streamClient = new StreamClient(STREAM_API_KEY, STREAM_API_SECRET);

    const expirationTime = Math.floor(Date.now() / 1000) + 3600;
    const issuedAt = Math.floor(Date.now() / 1000) - 60;

    const token = streamClient.generateUserToken(
        user_id: user.id,
        exp: expirationTime,
        validity_in_seconds: issuedAt,
    );

    return token;
;

Create a providers folder containing a StreamVideoProvider component within the Next.js app folder and copy the following code snippet into the file:

"use client";
import  createClient  from "../../../utils/supabase/client";
import  tokenProvider  from "../../../actions/stream.action";
import  StreamVideo, StreamVideoClient  from "@stream-io/video-react-sdk";
import  useState, ReactNode, useEffect, useCallback  from "react";
import  Loader2  from "lucide-react";

const apiKey = process.env.NEXT_PUBLIC_STREAM_API_KEY!;

export const StreamVideoProvider = ( children :  children: ReactNode ) =>  null>(
        null
    );
    const supabase = createClient();

    const getUser = useCallback(async () => 
        
        
        
    , [supabase.auth]);

    useEffect(() => 
        getUser();
    , [getUser]);

    if (!videoClient)
        return (
            <div className='h-screen flex items-center justify-center'>
                <Loader2 size='32' className='mx-auto animate-spin' />
            </div>
        );

    return <StreamVideo client=videoClient>children</StreamVideo>;
;

The StreamVideoProvider component is initialized and manages Stream’s video functionality across the application. It wraps all pages that require access to Stream’s real-time video features. This includes:

Update the getUser function as shown below:

const getUser = useCallback(async () => 
    const  data, error  = await supabase.auth.getUser();
    const  user  = data;
    if (error , [supabase.auth]);

The getUser function retrieves the current user’s data from Supabase Auth, sets up the Stream user, and initializes a Stream video client using the Stream API key, the user’s object and the token.

Creating and Scheduling Calls with Stream

Here, you will learn how to allow instructors to schedule calls using the Stream Video & Audio SDK.

Before we proceed, create a hooks folder within the Next.js app folder and add these files:

cd app && mkdir hooks
cd hooks
touch useGetCallById.ts useGetCalls.ts

The useGetCallById file defines a React hook that fetches details of a specific Stream call via its ID, while the useGetCalls hook retrieves all calls created by a particular Stream user.

Let’s create these custom React hooks.

Copy the following code snippet into the useGetCallById.ts file:

import  useEffect, useState  from "react";
import  Call, useStreamVideoClient  from "@stream-io/video-react-sdk";

export const useGetCallById = (id: string | string[]) => {
    const [call, setCall] = useState<Call>();
    const [isCallLoading, setIsCallLoading] = useState(true);

    const client = useStreamVideoClient();

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

        const loadCall = async () => 
            try 
                
                const  calls  = await client.queryCalls(
                    filter_conditions:  id ,
                );

                if (calls.length > 0) setCall(calls[0]);

                setIsCallLoading(false);
             catch (error) 
                console.error(error);
                setIsCallLoading(false);
            
        ;

        loadCall();
    }, [client, id]);

    return  call, isCallLoading ;
};

Add the following to the useGetCalls.ts file:

import  useEffect, useState  from "react";
import  Call, useStreamVideoClient  from "@stream-io/video-react-sdk";
import  useParams  from "next/navigation";

export const useGetCalls = () => {
    const client = useStreamVideoClient();
    const [calls, setCalls] = useState<Call[]>();
    const [isLoading, setIsLoading] = useState(false);
    const  id  = useParams< id: string >();

    useEffect(() => {
        const loadCalls = async () => {
            if (!client || !id) return;

            setIsLoading(true);

            try {
                const  calls  = await client.queryCalls(
                    sort: [ field: "starts_at", direction: 1 ],
                    filter_conditions: 
                        starts_at:  $exists: true ,
                        $or: [ created_by_user_id: id ,  members:  $in: [id]  ],
                    ,
                );

                setCalls(calls);
            } catch (error) 
                console.error(error);
             finally 
                setIsLoading(false);
            
        };

        loadCalls();
    }, [client, id]);

    const now = new Date();
    
    const upcomingCalls = calls?.filter(( state:  startsAt  : Call) => 
        return startsAt && new Date(startsAt) > now;
    );
    
    const ongoingCalls = calls?.filter(
        ( state:  startsAt, endedAt  : Call) => 
            return startsAt && new Date(startsAt) < now && !endedAt;
        
    );

    return  upcomingCalls, isLoading, ongoingCalls ;
};

The useGetCalls hook retrieves all calls where the instructor is either the creator or a participant, returning both current and upcoming calls. It also returns an isLoading state to indicate when data is being fetched, allowing for conditional rendering.

Add the function below to the instructor’s dashboard to allow instructors to create or schedule calls. This function accepts a call description along with the scheduled date and time.


import  useStreamVideoClient, Call  from "@stream-io/video-react-sdk";
const client = useStreamVideoClient();

const [description, setDescription] = useState<string>("");
const [dateTime, setDateTime] = useState<string>("");

const handleScheduleMeeting = async (e: React.FormEvent<HTMLFormElement>) =>  !user) return;

    try 
        const id = crypto.randomUUID();
        const call = client.call("default", id);
        if (!call) throw new Error("Failed to create meeting");
    
        await call.getOrCreate(
            data: 
                starts_at: new Date(dateTime).toISOString(),
                custom: 
                    description,
                ,
            ,
        );

        
        console.log( call );
     catch (error) 
        console.error(error);
    
;

The code snippet above initializes a Stream video call with a default call type. It assigns the call a unique ID, sets the scheduled date and time, and includes a custom description.

Note: Ensure that the <StreamVideoProvider> component wraps the instructor’s dashboard where the video call is being created. You can achieve this by adding a layout.tsx file to the dashboard page and wrapping all child elements with <StreamVideoProvider>.

Dashboard interface titled "LinkedUp" with sections for followers, announcements, and options to make an announcement, schedule a call, or access the community channel. Logout button is visible at the top right.

Joining Stream Video Calls

The instructor/[id] page displays detailed information about a specific instructor from Supabase and lists of their current and upcoming calls. This allows students to view scheduled meetings and join them when they start.

Screenshot of a profile page for Carl John, a UI Designer, on a platform called LinkedUp. It includes a profile picture, a button for joining a community channel, announcements with delete options, and upcoming meetings with join and copy link options.

To implement this functionality, we will use the MeetingsBox component within the instructor’s profile page and create a dedicated calls/[id] page route for joining calls.

First, create a (stream) folder and add a calls/[id] page route. Then, create a layout.tsx file within the (stream) folder and insert the following code:

import  StreamVideoProvider  from "../providers/StreamVideoProvider";
import type  Metadata  from "next";

export const metadata: Metadata =  LinkedUp",
    description: "Generated by create next app",
;

export default function AuthLayout(
    children,
: Readonly<
    children: React.ReactNode;
>) 
    return <StreamVideoProvider>children</StreamVideoProvider>;

The layout.tsx file ensures that the StreamVideoProvider component wraps all pages inside the (stream) folder, enabling access to Stream’s video and audio features across these pages.

Next, render the calls within the MeetingsBox component and and let students join meetings.

"use client";
import  formatDateTime  from "@/lib/utils";
import  Call  from "@stream-io/video-react-sdk";
import  useRouter  from "next/navigation";

export default function MeetingsBox(
    upcomingCalls,
    isLoading,
    ongoingCalls,
:  undefined;
)  !upcomingCalls 

Return the following UI elements from the component to allow everyone to see the instructor’s current and upcoming meetings.

return (
    <div className='space-y-4'>
        
        ongoingCalls.map((call) => (
            <div className='bg-white p-2 rounded-md' key=call.id>
                <h3 className='text-sm font-bold text-gray-500 mb-2'>
                    call.state.custom.description
                </h3>
                <p className='text-xs'>
                    Started: formatDateTime(call.state?.startsAt?.toLocaleString())
                </p>
                <div className='flex items-center space-x-4'>
                    <button
                        className='bg-blue-500 text-white px-4 py-2 text-xs rounded-md mt-2'
                        onClick=() => handleJoinCall(call)
                    >
                        Join In
                    </button>

                    <button
                        className='bg-gray-500 text-white px-4 py-2 text-xs rounded-md mt-2'
                        onClick=() => handleCopyLink(call)
                    >
                        Copy Link
                    </button>
                </div>
            </div>
        ))
        
        upcomingCalls.map((call) => (
            <div className='bg-white p-2 rounded-md' key=call.id>
                <h3 className='text-sm font-bold text-gray-500 mb-2'>
                    call.state.custom.description
                </h3>

                <div className='flex items-center space-x-4'>
                    <button
                        className='bg-blue-500 text-white px-4 py-2 text-xs rounded-md mt-2'
                        disabled=true
                    >
                        formatDateTime(call.state?.startsAt?.toLocaleString())
                    </button>

                    <button
                        className='bg-gray-500 text-white px-4 py-2 text-xs rounded-md mt-2'
                        onClick=() => handleCopyLink(call)
                    >
                        Copy Link
                    </button>
                </div>
            </div>
        ))
    </div>
);

The MeetingsBox component renders the instructor’s current and upcoming calls, allowing users to copy the call link and join meetings.

Profile page for Carl John, a UI Designer, featuring announcements and upcoming meetings. Links to "Join my Community Channel" and meeting details are shown.

Execute the handleJoinCall function to redirect the user to the call page. This allows them to confirm the action before joining the call. The handleCopyLink function copies the call link to the clipboard.

const handleJoinCall = (call: Call) => 
    router.push(`/call/$call.id`);
;

const handleCopyLink = (call: Call) => 
    navigator.clipboard.writeText(
        `$process.env.NEXT_PUBLIC_PAGE_URL!/call/$call.id`
    );
    console.log(
        title: "Link copied to clipboard",
        description: "You can now share the link with interested participants",
    );
;

Now, create the call/[id]/page.tsx component and copy the following code into the file:

"use client";
import  useParams  from "next/navigation";
import  useEffect, useState, useCallback  from "react";
import  useRouter  from "next/navigation";
import  User  from "@supabase/supabase-js";
import  createClient  from "../../../../../utils/supabase/client";

export default function CallPage()  null>(null);
    const router = useRouter();

    const authenticateUser = useCallback(async () => 
        const supabase = createClient();
        const  data  = await supabase.auth.getUser();
        const userData = data.user;
        if (!userData) 
            return router.push("/student/auth/login");
        
        setUser(userData);
    , [router, call, camMicEnabled]);

    useEffect(() => 
        authenticateUser();
    , [authenticateUser]);

    return 
        
    ;

The code snippet authenticates the user to ensure they are signed in.

Next, fetch the call details using the call ID from the page route via the useParams hook.

"use client";

import  useGetCallById  from "@/app/hooks/useGetCallById";
import  StreamCall, StreamTheme  from "@stream-io/video-react-sdk";

export default function CallPage() 
    
    const  call, isCallLoading  = useGetCallById(id);
    const [confirmJoin, setConfirmJoin] = useState<boolean>(false);
    const [camMicEnabled, setCamMicEnabled] = useState<boolean>(false);

    const handleJoin = () => 
        
        call?.join();
        setConfirmJoin(true);
    ;

    if (isCallLoading) return <p>Loading...</p>;

    if (!call) return <p>Call not found</p>;

    return (
        <main className='min-h-screen w-full items-center justify-center'>
            <StreamCall call=call>
                <StreamTheme>
                    confirmJoin ? (
                        <MeetingRoom call=call />
                    ) : (
                        <div className='flex flex-col items-center justify-center gap-5 h-screen w-full'>
                            <h1 className='text-3xl font-bold'>Join Call</h1>
                            <p className='text-lg'>
                                Are you sure you want to join this call?
                            </p>
                            <div className='flex gap-5'>
                                <button
                                    onClick=handleJoin
                                    className='px-4 py-3 bg-blue-600 text-blue-50'
                                >
                                    Join
                                </button>
                                <button
                                    onClick=() => router.back()
                                    className='px-4 py-3 bg-red-600 text-red-50'
                                >
                                    Cancel
                                </button>
                            </div>
                        </div>
                    )
                </StreamTheme>
            </StreamCall>
        </main>
    );

In the code snippet above,

  • The StreamCall component wraps the entire call page, allowing access to various audio and video calling features. It accepts the call object as a prop.

  • The StreamTheme component provides UI styling for the call, enabling you to use different themes.

  • The confirmJoin state is initially set to false. When the user clicks the Join button, it triggers the handleJoin function, which joins the call and updates confirmJoin to true.

  • When confirmJoin is true, the component renders the MeetingRoom component, which includes all prebuilt and customizable UI elements for the call provided by Stream.

Finally, update the authenticateUser function to prompt the Stream user to enable or disable the camera and microphone immediately after joining a call.


const [camMicEnabled, setCamMicEnabled] = useState<boolean>(false);

const authenticateUser = useCallback(async () => 
    const supabase = createClient();
    const  data  = await supabase.auth.getUser();
    const userData = data.user;
    if (!userData) 
        return router.push("/student/auth/login");
    
    setUser(userData);
    
    if (camMicEnabled) 
        call?.camera.enable();
        call?.microphone.enable();
     else 
        call?.camera.disable();
        call?.microphone.disable();
    
, [router, call, camMicEnabled]);

useEffect(() => 
    authenticateUser();
, [authenticateUser]);

Stream Call UI Components

Stream makes setting up a call page easy using minimal UI components. It provides two prebuilt call layouts (PaginatedGridLayout and SpeakerLayout) and a customizable CallControls component.

  • PaginatedGridLayout and SpeakerLayout define how call participants are displayed on the call page.

  • CallControls provides essential call functionalities such as toggling video and audio, sharing the screen, leaving the call, and more.

Video call interface with a person in a small window. The microphone is muted, indicated by a crossed-out microphone icon. Other controls and a red "End Call for Everyone" button are visible at the bottom.

Create the MeetingRoom component as follows:

const MeetingRoom = (call : call: Call) => 
    const [layout, setLayout] = useState<CallLayoutType>("grid");
    const router = useRouter();


    const handleLeave = () => 
        if (confirm("Are you sure you want to leave the call?")) 
            router.push("/");
        
    ;


    const CallLayout = () => 
        switch (layout) 
            case "grid":
                return <PaginatedGridLayout />;
            case "speaker-right":
                return <SpeakerLayout participantsBarPosition='left' />;
            default:
                return <SpeakerLayout participantsBarPosition='right' />;
        
    ;

  return (
    
  )

The handleLeave function enables call participants to leave the call and the CallLayout component determines how they are laid out on the screen.

Return the following from the MeetingRoom component:

return (
    <section className='relative min-h-screen w-full overflow-hidden pt-4'>
        <div className='relative flex size-full items-center justify-center'>
            <div className='flex size-full max-w-[1000px] items-center'>
                <CallLayout />
            </div>
            <div className='fixed bottom-0 flex w-full items-center justify-center gap-5'>
                <CallControls onLeave=handleLeave />
            </div>

            <div className='fixed bottom-0 right-0 flex items-center justify-center gap-5 p-5'>
                <EndCallButton call=call />
            </div>
        </div>
    </section>
);

The CallLayout and CallControls components are rendered on the page, allowing users to communicate, share their screen, turn their camera on or off, and engage in conversations through reactions.

Finally, create the EndCallButton component to enable the host (instructor) to end the call for everyone.


import  useCallStateHooks  from "@stream-io/video-react-sdk";

const EndCallButton = ( call :  call: Call ) => 
    const  useLocalParticipant  = useCallStateHooks();
    const localParticipant = useLocalParticipant();
    const router = useRouter();

    const participantIsHost =
        localParticipant &&
        call.state.createdBy &&
        localParticipant.userId === call.state.createdBy.id;

    if (!participantIsHost) return null;

    const handleEndCall = () => 
        call.endCall();
        console.log(
            title: "Call Ended",
            description: "The call has been ended for everyone",
        );
        router.push("/");
    ;

    return (
        <button
            className='bg-red-500 text-white px-4 py-2 rounded-md mt-2'
            onClick=handleEndCall
        >
            End Call for Everyone
        </button>
    );
;

The code snippet above ensures that only the call host can end the call for all participants. It first checks if the current user is the host before displaying the “End Call for Everyone” button.

Profile page of a UI Designer named Carl John. The page includes an announcement section with posts, an upcoming meetings section with join and copy link options, and a log out button. A button to join a community channel is also present.

How to Integrate a Group Chat Feature Using Stream Chat Messaging

In this section, you will learn how to integrate a community chat feature into the application. Each instructor will create a group chat for their followers (students). The chat will allow students to interact with one another and share documents, video links, text, images, and so on using the Stream Chat Messaging SDK.

Setting Up the Stream Chat SDK in Next.js

Add the following code snippet to the stream.action.ts file:

import  StreamChat  from "stream-chat";
import  getUserSession  from "./auth";


const serverClient = StreamChat.getInstance(STREAM_API_KEY, STREAM_API_SECRET);


export async function createToken(): Promise<string> 
    const  user  = await getUserSession();
    if (!user) throw new Error("User is not authenticated");
    return serverClient.createToken(user.id);

The code snippet above initializes a Stream Chat instance using its API key and secret key. It also includes a function that generates and returns a token based on the current user’s ID.

To ensure that only instructors can create a community channel, follow these steps:

  1. Retrieve all the channels where the instructor is a member.

  2. If no channels are found (i.e., the returned array is empty), the instructor can create a new channel.

  3. An error message is displayed if a channel already exists, informing the instructor that they can only have one community channel.

     export async function createChannel(
         userId,
         data,
     : 
         userId: string;
         data:  name: string; imageUrl: string ;
     ) 
         try 
             
             const channels = await serverClient.queryChannels(
                 
                     members:  $in: [userId] ,
                     type: "messaging",
                 ,
                  last_message_at: -1 
             );
             
             if (channels.length > 0) 
                 return 
                     success: false,
                     error: "You already have an existing channel",
                     id: channels[0].id,
                 ;
             
             
             const channel = serverClient.channel("messaging", `channel-$userId`, 
                 name: data.name,
                 image: data.imageUrl,
                 members: [userId],
                 created_by_id: userId,
             );
             
             await channel.create();
             return  success: true, error: null, id: channel.id ;
          catch (err) 
             return  success: false, error: "Failed to create channel", id: null ;
         
     
    

The code snippet above creates a public channel, meaning anyone can join at any time. Also, the channel name is linked to the instructor’s ID, ensuring it remains unique to that instructor.

To retrieve the instructor’s channel link, add a function inside the stream.action.ts file. This function should return the channel URL (channel ID), allowing members to access the channel whenever needed. Then, you can display this link on the instructor’s profile for easy access.

export async function getInstructorChannel(userId: string) 
    try 
        const channels = await serverClient.queryChannels(
            
                members:  $in: [userId] ,
                type: "messaging",
            ,
             last_message_at: -1 
        );
        return `/chat/$channels[0].id`;
     catch (err) 
        return null;
    

Finally, to grant users access to the channel page, check if the user is already a member. If not, add the student as a member before rendering the chat page. This ensures that only authorized users can participate in the conversation.

export async function addUserToChannel(channelId: string, userId: string) 
    try 
        
        const channels = await serverClient.queryChannels(
            
                members:  $in: [userId] ,
                type: "messaging",
                id: channelId,
            ,
             last_message_at: -1 
        );
        
        if (channels.length > 0) 
            return 
                success: true,
                message: "Already a member",
                id: channels[0].id,
                error: null,
            ;
        
        
        const channel = serverClient.channel("messaging", channelId);
        
        await channel.addMembers([userId]);
        
        return 
            success: true,
            error: null,
            id: channel.id,
            message: "Member just added",
        ;
     catch (error) 
        console.error("Error adding user to channel:", error);
        return 
            success: false,
            error: "Failed to add user to channel",
            id: null,
            message: null,
        ;
    

The Stream Chat page using the Stream Call UI components

Stream Chat UI Components

Inside the (stream) folder, create a chat/[id]/page.tsx file. This page retrieves the channel ID from the page route and checks whether the user is already a channel member. If not, the user is automatically added as a member before displaying the chat interface.

Copy the following code snippet into the chat/[id]/page.tsx file:

"use client";
import  useCallback, useEffect, useState  from "react";
import StreamChat from "./../(components)/StreamChat";
import  useParams  from "next/navigation";
import  useRouter  from "next/navigation";

export default function ChatPage() 
    const [userData, setUserData] = useState<UserData 

function ConfirmMember() 
    return (
        <div className='flex flex-col items-center justify-center h-screen'>
            <h1 className='text-2xl font-bold mb-4 text-blue-500'>
                You are not a member of this channel
            </h1>
            <p className='text-lg mb-4'>
                Please wait while we add you to the channel
            </p>

            <div className='loader'>
                <Loader2 size=48 className='animate-spin' />
            </div>
        </div>
    );

This code snippet ensures that a user is either already a member of the channel or is added before displaying the chat interface. The StreamChat component is a custom React component that contains all the Stream Chat UI elements. The ConfirmMember component shows a loading message while the user is added to the channel.

Create a StreamChat component and add the following imports to the file:

"use client";
import  useCallback  from "react";

import 
    Chat,
    Channel,
    ChannelList,
    Window,
    ChannelHeader,
    MessageList,
    MessageInput,
    useCreateChatClient,
 from "stream-chat-react";



import  EmojiPicker  from "stream-chat-react/emojis";
import  init, SearchIndex  from "emoji-mart";
import data from "@emoji-mart/data";
init( data );


import  createToken  from "../../../../../actions/stream.action";

Declare the StreamChat component as follows:

export default function StreamChat( user :  user: UserData ) {

    const tokenProvider = useCallback(async () => 
        return await createToken();
    , []);

    const filters =  members:  $in: [user.id] , type: "messaging" ;
    const options =  presence: true, state: true ;

    const client = useCreateChatClient(
        apiKey: process.env.NEXT_PUBLIC_STREAM_API_KEY!,
        tokenOrProvider: tokenProvider,
        userData:  id: user.id, name: user.name, image: user.image ,
    );

    if (!client) return <div>Loading...</div>;

  return (
    
  )

The useCreateChatClient hook creates a Stream chat client using the Stream API key, the user’s data, and the token created using the createToken() function declared earlier in this section.

Finally, return the chat UI from the StreamChat component:

return (
    <Chat client=client>
        <div className='chat-container'>
            
            <div className='channel-list'>
                <ChannelList
                    sort= last_message_at: -1 
                    filters=filters
                    options=options
                />
            </div>

            
            <div className='chat-panel'>
                <Channel EmojiPicker=EmojiPicker emojiSearchIndex=SearchIndex>
                    <Window>
                        <ChannelHeader />
                        <MessageList />
                        <MessageInput />
                    </Window>
                </Channel>
            </div>
        </div>
    </Chat>
);

A screenshot of a group chat named "UI Design Students" with 2 members, showing a brief conversation with messages "Hello" and "Hi," timestamped at 11:33 AM.

Congratulations! You’ve completed this tutorial. The source code for this article is also available on GitHub.

Next Steps

So far, you’ve learned how to build a full-stack social learning platform using Stream and Supabase. This platform enables users to interact with one another through real-time chat powered by Stream.

Stream helps you build engaging apps that scale to millions with performant and flexible Chat, Video, Voice, Feeds, and Moderation APIs and SDKs powered by a global edge network and enterprise-grade infrastructure.

Here are some useful resources to help you get started:

Thank you for reading! 🎉

Check out our other content

Check out other tags:

Most Popular Articles