├── .eslintrc.json ├── .gitignore ├── README.md ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── ClientProvider.tsx │ ├── CreateMeetingPage.tsx │ ├── actions.ts │ ├── error.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── meeting │ │ └── [id] │ │ │ ├── MeetingLoginPage.tsx │ │ │ ├── MeetingPage.tsx │ │ │ ├── left │ │ │ └── page.tsx │ │ │ └── page.tsx │ ├── meetings │ │ ├── MyMeetingsPage.tsx │ │ └── page.tsx │ └── page.tsx ├── components │ ├── AudioVolumeIndicator.tsx │ ├── Button.tsx │ ├── EndCallButton.tsx │ ├── FlexibleCallLayout.tsx │ ├── Navbar.tsx │ ├── PermissionPrompt.tsx │ └── RecordingsList.tsx ├── hooks │ ├── useLoadCall.ts │ ├── useLoadRecordings.ts │ └── useStreamCall.ts ├── lib │ └── utils.ts └── middleware.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Full-Stack Next.js 14 Zoom Clone 2 | 3 | This is a fully-fledged video calling application built with [Stream's React Video SDK](https://getstream.io/video/sdk/react/) and Next.js. It includes all features you would expect from a video calling app, like mic & cam controls, roles & permissions, call recordings, screen sharing, picture-in-picture, and more. 4 | 5 | Learn how to build this app from scratch in my YouTube tutorial: https://www.youtube.com/watch?v=BL1ixDaanY8 6 | 7 | ![thumbnail 3](https://github.com/codinginflow/nextjs-zoom-clone/assets/52977034/aeb15a28-e4dd-443a-a646-ea4123f94a1e) 8 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-zoom-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@clerk/nextjs": "^4.29.9", 13 | "@stream-io/node-sdk": "^0.1.11", 14 | "@stream-io/video-react-sdk": "^0.5.1", 15 | "clsx": "^2.1.0", 16 | "lucide-react": "^0.344.0", 17 | "nanoid": "^5.0.6", 18 | "next": "14.1.2", 19 | "react": "^18", 20 | "react-dom": "^18", 21 | "tailwind-merge": "^2.2.1" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^20", 25 | "@types/react": "^18", 26 | "@types/react-dom": "^18", 27 | "autoprefixer": "^10.0.1", 28 | "eslint": "^8", 29 | "eslint-config-next": "14.1.2", 30 | "eslint-config-prettier": "^9.1.0", 31 | "postcss": "^8", 32 | "prettier": "^3.2.5", 33 | "prettier-plugin-tailwindcss": "^0.5.11", 34 | "tailwindcss": "^3.3.0", 35 | "typescript": "^5" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["prettier-plugin-tailwindcss"], 3 | }; 4 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/ClientProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useUser } from "@clerk/nextjs"; 4 | import { 5 | StreamVideo, 6 | StreamVideoClient, 7 | User, 8 | } from "@stream-io/video-react-sdk"; 9 | import { Loader2 } from "lucide-react"; 10 | import { nanoid } from "nanoid"; 11 | import { useEffect, useState } from "react"; 12 | import { getToken } from "./actions"; 13 | 14 | interface ClientProviderProps { 15 | children: React.ReactNode; 16 | } 17 | 18 | export default function ClientProvider({ children }: ClientProviderProps) { 19 | const videoClient = useInitializeVideoClient(); 20 | 21 | if (!videoClient) { 22 | return ( 23 |
24 | 25 |
26 | ); 27 | } 28 | 29 | return {children}; 30 | } 31 | 32 | function useInitializeVideoClient() { 33 | const { user, isLoaded: userLoaded } = useUser(); 34 | const [videoClient, setVideoClient] = useState( 35 | null, 36 | ); 37 | 38 | useEffect(() => { 39 | if (!userLoaded) return; 40 | 41 | let streamUser: User; 42 | 43 | if (user?.id) { 44 | streamUser = { 45 | id: user.id, 46 | name: user.username || user.id, 47 | image: user.imageUrl, 48 | }; 49 | } else { 50 | const id = nanoid(); 51 | streamUser = { 52 | id, 53 | type: "guest", 54 | name: `Guest ${id}`, 55 | }; 56 | } 57 | 58 | const apiKey = process.env.NEXT_PUBLIC_STREAM_VIDEO_API_KEY; 59 | 60 | if (!apiKey) { 61 | throw new Error("Stream API key not set"); 62 | } 63 | 64 | const client = new StreamVideoClient({ 65 | apiKey, 66 | user: streamUser, 67 | tokenProvider: user?.id ? getToken : undefined, 68 | }); 69 | 70 | setVideoClient(client); 71 | 72 | return () => { 73 | client.disconnectUser(); 74 | setVideoClient(null); 75 | }; 76 | }, [user?.id, user?.username, user?.imageUrl, userLoaded]); 77 | 78 | return videoClient; 79 | } 80 | -------------------------------------------------------------------------------- /src/app/CreateMeetingPage.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Button from "@/components/Button"; 4 | import { useUser } from "@clerk/nextjs"; 5 | import { 6 | Call, 7 | MemberRequest, 8 | useStreamVideoClient, 9 | } from "@stream-io/video-react-sdk"; 10 | import { Copy, Loader2 } from "lucide-react"; 11 | import Link from "next/link"; 12 | import { useState } from "react"; 13 | import { getUserIds } from "./actions"; 14 | 15 | export default function CreateMeetingPage() { 16 | const [descriptionInput, setDescriptionInput] = useState(""); 17 | const [startTimeInput, setStartTimeInput] = useState(""); 18 | const [participantsInput, setParticipantsInput] = useState(""); 19 | 20 | const [call, setCall] = useState(); 21 | 22 | const client = useStreamVideoClient(); 23 | 24 | const { user } = useUser(); 25 | 26 | async function createMeeting() { 27 | if (!client || !user) { 28 | return; 29 | } 30 | 31 | try { 32 | const id = crypto.randomUUID(); 33 | 34 | const callType = participantsInput ? "private-meeting" : "default"; 35 | 36 | const call = client.call(callType, id); 37 | 38 | const memberEmails = participantsInput 39 | .split(",") 40 | .map((email) => email.trim()); 41 | 42 | const memberIds = await getUserIds(memberEmails); 43 | 44 | const members: MemberRequest[] = memberIds 45 | .map((id) => ({ user_id: id, role: "call_member" })) 46 | .concat({ user_id: user.id, role: "call_member" }) 47 | .filter( 48 | (v, i, a) => a.findIndex((v2) => v2.user_id === v.user_id) === i, 49 | ); 50 | 51 | const starts_at = new Date(startTimeInput || Date.now()).toISOString(); 52 | 53 | await call.getOrCreate({ 54 | data: { 55 | starts_at, 56 | members, 57 | custom: { description: descriptionInput }, 58 | }, 59 | }); 60 | 61 | setCall(call); 62 | } catch (error) { 63 | console.error(error); 64 | alert("Something went wrong. Please try again later."); 65 | } 66 | } 67 | 68 | if (!client || !user) { 69 | return ; 70 | } 71 | 72 | return ( 73 |
74 |

75 | Welcome {user.username}! 76 |

77 |
78 |

Create a new meeting

79 | 83 | 84 | 88 | 91 |
92 | {call && } 93 |
94 | ); 95 | } 96 | 97 | interface DescriptionInputProps { 98 | value: string; 99 | onChange: (value: string) => void; 100 | } 101 | 102 | function DescriptionInput({ value, onChange }: DescriptionInputProps) { 103 | const [active, setActive] = useState(false); 104 | 105 | return ( 106 |
107 |
Meeting info:
108 | 119 | {active && ( 120 |