Back to blog
Blog

How to Build a YouTube Clone with React and Cosmic

Cosmic Intelligence's avatar

Cosmic Intelligence

March 23, 2025

cover image

How to Build a YouTube Clone with React and Cosmic

In this tutorial, we'll build a video sharing platform similar to YouTube using React for the frontend and Cosmic as our content management backend. We'll implement core features like video uploads, channels, and comments.

Tip: click the copy markdown button at the top of this page to copy the code to your clipboard and paste into your AI-powered code editor.

Setting Up the Project

First, let's create a new React application:

npx create-next-app youtube-clone cd youtube-clone npm install @cosmicjs/sdk styled-components

Configuring Cosmic

Create a Cosmic bucket and set up environment variables in a .env.local file:

NEXT_PUBLIC_COSMIC_BUCKET_SLUG=your-bucket-slug NEXT_PUBLIC_COSMIC_READ_KEY=your-read-key COSMIC_WRITE_KEY=your-write-key

Let's create a seed script to set up our Cosmic bucket with the necessary object types and sample content:

import { createBucketClient } from "@cosmicjs/sdk"; const BUCKET_SLUG = process.env.COSMIC_BUCKET_SLUG; const WRITE_KEY = process.env.COSMIC_WRITE_KEY; const READ_KEY = process.env.COSMIC_READ_KEY; if (!BUCKET_SLUG || !WRITE_KEY || !READ_KEY) { throw new Error("Missing required environment variables"); } const cosmic = createBucketClient({ bucketSlug: BUCKET_SLUG, writeKey: WRITE_KEY, readKey: READ_KEY, }); async function uploadMedia(url: string, filename: string) { const response = await fetch(url); const buffer = await response.blob().then((b) => b.arrayBuffer()); const { media } = await cosmic.media.insertOne({ media: { originalname: filename, buffer: Buffer.from(buffer) }, }); return media; } async function seedObjectTypes() { const types = [ { title: "Channels", slug: "channels", emoji: "📺", metafields: [ { title: "Username", key: "username", type: "text", required: true }, { title: "Avatar", key: "avatar", type: "file", required: true, media_validation_type: "image", }, { title: "Description", key: "description", type: "textarea" }, { title: "Subscribers", key: "subscribers", type: "number", default: 0, }, ], }, { title: "Videos", slug: "videos", emoji: "🎬", metafields: [ { title: "Title", key: "title", type: "text", required: true }, { title: "Description", key: "description", type: "textarea" }, { title: "Thumbnail", key: "thumbnail", type: "file", required: true, media_validation_type: "image", }, { title: "Video File", key: "video_file", type: "file", required: true, media_validation_type: "video", }, { title: "Channel", key: "channel", type: "object", object_type: "channels", required: true, }, { title: "Views", key: "views", type: "number", default: 0 }, { title: "Likes", key: "likes", type: "number", default: 0 }, ], }, { title: "Comments", slug: "comments", emoji: "💬", metafields: [ { title: "Content", key: "content", type: "textarea", required: true }, { title: "Video", key: "video", type: "object", object_type: "videos", required: true, }, { title: "User", key: "user", type: "object", object_type: "channels", required: true, }, ], }, ]; await Promise.all(types.map((type) => cosmic.objectTypes.insertOne(type))); } async function seedContent() { // Upload avatar and thumbnail const avatarImage = await uploadMedia( "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=400", "user-avatar.jpg" ); const thumbnailImage = await uploadMedia( "https://images.unsplash.com/photo-1536240478700-b869070f9279?w=600", "video-thumbnail.jpg" ); // Create channel const { object: channel } = await cosmic.objects.insertOne({ title: "TechTips", type: "channels", metadata: { username: "techtips", avatar: avatarImage.name, description: "Tips and tricks about the latest technology", subscribers: 1250, }, }); // Create video const { object: video } = await cosmic.objects.insertOne({ title: "How to Build a Video Sharing Platform", type: "videos", metadata: { title: "How to Build a Video Sharing Platform", description: "Learn how to create your own YouTube-like platform using React and Cosmic", thumbnail: thumbnailImage.name, video_file: "sample-video.mp4", // You would upload an actual video file channel: channel.id, views: 342, likes: 56, }, }); // Create comment await cosmic.objects.insertOne({ title: "Great tutorial!", type: "comments", metadata: { content: "This was really helpful, thanks for sharing!", video: video.id, user: channel.id, }, }); } async function seed() { await seedObjectTypes(); await seedContent(); console.log( "✅ Cosmic bucket has been seeded with object types and demo content!" ); } seed();

Run the seed script:

npx ts-node scripts/seed-cosmic.ts

Creating Our API Service

Let's create a service to communicate with Cosmic:

// src/services/cosmic.js import { createBucketClient } from "@cosmicjs/sdk"; const cosmic = createBucketClient({ bucketSlug: process.env.NEXT_PUBLIC_COSMIC_BUCKET_SLUG, readKey: process.env.NEXT_PUBLIC_COSMIC_READ_KEY, writeKey: process.env.COSMIC_WRITE_KEY, }); export const getVideos = async () => { const { objects } = await cosmic.objects .find({ type: "videos", }) .props(["title", "slug", "metadata"]) .depth(1); return objects; }; export const getVideoBySlug = async (slug) => { const { object } = await cosmic.objects .findOne({ type: "videos", slug, }) .props(["title", "slug", "metadata"]) .depth(1); return object; }; export const getCommentsByVideo = async (videoId) => { const { objects } = await cosmic.objects .find({ type: "comments", "metadata.video": videoId, }) .props(["title", "metadata"]) .depth(1); return objects; }; export const addComment = async (content, videoId, userId) => { const { object } = await cosmic.objects.insertOne({ title: content.substring(0, 30), type: "comments", metadata: { content, video: videoId, user: userId, }, }); return object; };

Building the UI Components

Home Page with Video List

// src/app/page.js "use client"; import React, { useEffect, useState } from "react"; import Link from "next/link"; import styled from "styled-components"; import { getVideos } from "../services/cosmic"; const VideoGrid = styled.div` display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; padding: 20px; `; const VideoCard = styled.div` border-radius: 8px; overflow: hidden; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); `; const Thumbnail = styled.img` width: 100%; height: 160px; object-fit: cover; `; const VideoInfo = styled.div` padding: 12px; `; export default function Home() { const [videos, setVideos] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { const fetchVideos = async () => { try { const videosData = await getVideos(); setVideos(videosData); } catch (error) { console.error("Error fetching videos:", error); } finally { setLoading(false); } }; fetchVideos(); }, []); if (loading) return <div>Loading...</div>; return ( <VideoGrid> {videos.map((video) => ( <VideoCard key={video.id}> <Link href={`/video/${video.slug}`}> <Thumbnail src={video.metadata.thumbnail.url} alt={video.metadata.title} /> <VideoInfo> <h3>{video.metadata.title}</h3> <p>{video.metadata.channel.title}</p> <span>{video.metadata.views} views</span> </VideoInfo> </Link> </VideoCard> ))} </VideoGrid> ); }

Video Player Page

// src/app/video/[slug]/page.js "use client"; import React, { useEffect, useState } from "react"; import styled from "styled-components"; import { getVideoBySlug, getCommentsByVideo, addComment, } from "../../../services/cosmic"; const VideoContainer = styled.div` display: grid; grid-template-columns: 2fr 1fr; gap: 20px; padding: 20px; @media (max-width: 768px) { grid-template-columns: 1fr; } `; const VideoPlayer = styled.video` width: 100%; border-radius: 8px; `; const VideoInfo = styled.div` margin-top: 15px; `; const CommentSection = styled.div` margin-top: 20px; `; const Comment = styled.div` display: flex; margin-bottom: 15px; `; const Avatar = styled.img` width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; `; const CommentForm = styled.form` display: flex; margin-bottom: 20px; `; const CommentInput = styled.input` flex: 1; padding: 10px; border: 1px solid #ccc; border-radius: 4px; margin-right: 10px; `; export default function VideoPage({ params }) { const { slug } = params; const [video, setVideo] = useState(null); const [comments, setComments] = useState([]); const [commentText, setCommentText] = useState(""); const [loading, setLoading] = useState(true); useEffect(() => { const fetchVideoData = async () => { try { const videoData = await getVideoBySlug(slug); setVideo(videoData); const commentsData = await getCommentsByVideo(videoData.id); setComments(commentsData); } catch (error) { console.error("Error fetching video data:", error); } finally { setLoading(false); } }; fetchVideoData(); }, [slug]); const handleAddComment = async (e) => { e.preventDefault(); if (!commentText.trim()) return; try { // In a real app, you'd use the current user's ID const newComment = await addComment( commentText, video.id, video.metadata.channel.id ); setComments([...comments, newComment]); setCommentText(""); } catch (error) { console.error("Error adding comment:", error); } }; if (loading) return <div>Loading...</div>; if (!video) return <div>Video not found</div>; return ( <VideoContainer> <div> <VideoPlayer src={video.metadata.video_file.url} controls poster={video.metadata.thumbnail.url} /> <VideoInfo> <h1>{video.metadata.title}</h1> <div> <span>{video.metadata.views} views • </span> <span>{video.metadata.likes} likes</span> </div> <hr /> <div> <img src={video.metadata.channel.metadata.avatar.url} alt={video.metadata.channel.title} style={{ width: "50px", height: "50px", borderRadius: "50%" }} /> <h3>{video.metadata.channel.title}</h3> <p>{video.metadata.channel.metadata.subscribers} subscribers</p> </div> <p>{video.metadata.description}</p> </VideoInfo> <CommentSection> <h3>{comments.length} Comments</h3> <CommentForm onSubmit={handleAddComment}> <CommentInput type="text" placeholder="Add a comment..." value={commentText} onChange={(e) => setCommentText(e.target.value)} /> <button type="submit">Comment</button> </CommentForm> {comments.map((comment) => ( <Comment key={comment.id}> <Avatar src={comment.metadata.user.metadata.avatar.url} alt={comment.metadata.user.title} /> <div> <strong>{comment.metadata.user.title}</strong> <p>{comment.metadata.content}</p> </div> </Comment> ))} </CommentSection> </div> <div>{/* Recommended videos would go here */}</div> </VideoContainer> ); }

Integrating Cosmic Intelligence for Content Enhancement

We can use Cosmic Intelligence to enhance our video descriptions or generate comment responses:

// src/services/cosmic.js // Add this function to use Cosmic Intelligence export const generateDescription = async (title) => { const { data } = await cosmic.ai.generateText({ prompt: `Write an engaging video description for a video titled "${title}"`, max_tokens: 200, }); return data.text; };

Setting Up Routing

// src/App.js import React from "react"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import Home from "./pages/Home"; import VideoPage from "./pages/VideoPage"; import Header from "./components/Header"; function App() { return ( <Router> <Header /> <Routes> <Route path="/" element={<Home />} /> <Route path="/video/:slug" element={<VideoPage />} /> </Routes> </Router> ); } export default App;

Conclusion

We've built a basic YouTube clone with React and Cosmic that includes video listing, video playback, and commenting functionality. From here, you could expand the application by adding:

  • User authentication
  • Video upload functionality
  • Likes and subscriptions
  • Search and recommendations
  • Video analytics

You can leverage Cosmic Intelligence for generating content descriptions, creating thumbnails, or providing personalized recommendations to users. Learn more about how businesses benefit from Cosmic for managing content across their applications.

Adding Dark Mode Support

Let's enhance our application by adding a dark mode theme. First, create a theme.ts file:

// src/lib/theme.ts export const lightTheme = { background: "#f9f9f9", text: "#121212", primary: "#ff0000", secondary: "#606060", card: "#ffffff", border: "#e5e5e5", hover: "#f0f0f0", shadow: "0 4px 8px rgba(0, 0, 0, 0.1)", }; export const darkTheme = { background: "#0f0f0f", text: "#ffffff", primary: "#ff0000", secondary: "#aaaaaa", card: "#212121", border: "#303030", hover: "#383838", shadow: "0 4px 8px rgba(0, 0, 0, 0.3)", };

Next, create a theme context to manage the theme state:

// src/context/ThemeContext.tsx "use client"; import React, { createContext, useState, useContext, useEffect, ReactNode, } from "react"; import { ThemeProvider as StyledThemeProvider } from "styled-components"; import { lightTheme, darkTheme } from "../lib/theme"; type Theme = "light" | "dark"; interface ThemeContextType { theme: Theme; toggleTheme: () => void; } const ThemeContext = createContext<ThemeContextType | undefined>(undefined); export const ThemeProvider = ({ children }: { children: ReactNode }) => { const [theme, setTheme] = useState<Theme>("light"); useEffect(() => { // Check for stored theme preference or system preference const storedTheme = localStorage.getItem("theme") as Theme | null; const prefersDark = window.matchMedia( "(prefers-color-scheme: dark)" ).matches; if (storedTheme) { setTheme(storedTheme); } else if (prefersDark) { setTheme("dark"); } }, []); useEffect(() => { // Update body class and store preference when theme changes document.body.classList.remove("light-mode", "dark-mode"); document.body.classList.add(`${theme}-mode`); localStorage.setItem("theme", theme); }, [theme]); const toggleTheme = () => { setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); }; const currentTheme = theme === "light" ? lightTheme : darkTheme; return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> <StyledThemeProvider theme={currentTheme}>{children}</StyledThemeProvider> </ThemeContext.Provider> ); }; export const useTheme = (): ThemeContextType => { const context = useContext(ThemeContext); if (context === undefined) { throw new Error("useTheme must be used within a ThemeProvider"); } return context; };

Create a ThemeProviderWrapper to use in the app's layout:

// src/components/ThemeProviderWrapper.tsx "use client"; import React from "react"; import { ThemeProvider } from "../context/ThemeContext"; export default function ThemeProviderWrapper({ children, }: { children: React.ReactNode; }) { return <ThemeProvider>{children}</ThemeProvider>; }

Update the global CSS to support theme variables:

/* src/app/globals.css */ :root { --background: #f9f9f9; --foreground: #121212; --primary: #ff0000; --secondary: #606060; --card-bg: #ffffff; --border: #e5e5e5; --shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .dark-mode { --background: #0f0f0f; --foreground: #ffffff; --primary: #ff0000; --secondary: #aaaaaa; --card-bg: #212121; --border: #303030; --shadow: 0 4px 8px rgba(0, 0, 0, 0.3); } body { background: var(--background); color: var(--foreground); margin: 0; padding: 0; transition: background-color 0.3s ease, color 0.3s ease; } /* Additional global styles... */

Update the Header component to include a theme toggle button:

// src/components/Header.tsx "use client"; import React from "react"; import Link from "next/link"; import styled from "styled-components"; import { useTheme } from "../context/ThemeContext"; const HeaderContainer = styled.header` display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; background-color: ${(props) => props.theme.card}; box-shadow: ${(props) => props.theme.shadow}; transition: background-color 0.3s ease, box-shadow 0.3s ease; `; const Logo = styled.div` font-size: 1.5rem; font-weight: bold; color: ${(props) => props.theme.text}; `; const SearchBar = styled.input` padding: 8px 12px; border-radius: 20px; border: 1px solid ${(props) => props.theme.border}; background-color: ${(props) => props.theme.background}; color: ${(props) => props.theme.text}; width: 300px; transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; &::placeholder { color: ${(props) => props.theme.secondary}; } `; const AuthButtons = styled.div` display: flex; gap: 10px; align-items: center; `; const ThemeToggle = styled.button` display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background-color: transparent; color: ${(props) => props.theme.text}; border: 1px solid ${(props) => props.theme.border}; cursor: pointer; padding: 0; &:hover { background-color: ${(props) => props.theme.hover}; } `; export default function Header() { const { theme, toggleTheme } = useTheme(); return ( <HeaderContainer> <Link href="/"> <Logo>YouTubeClone</Logo> </Link> <SearchBar placeholder="Search..." /> <AuthButtons> <ThemeToggle onClick={toggleTheme} title="Toggle theme"> {theme === "light" ? "🌙" : "☀️"} </ThemeToggle> <button>Sign In</button> </AuthButtons> </HeaderContainer> ); }

Finally, update the app's layout to use the ThemeProvider:

// src/app/layout.tsx import StyledComponentsRegistry from "../lib/registry"; import Header from "../components/Header"; import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import ThemeProviderWrapper from "../components/ThemeProviderWrapper"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { title: "YouTube Clone", description: "A video sharing platform built with React and Cosmic", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={inter.className}> <StyledComponentsRegistry> <ThemeProviderWrapper> <Header /> {children} </ThemeProviderWrapper> </StyledComponentsRegistry> </body> </html> ); }

With these changes, your YouTube clone now supports both light and dark modes. The application will automatically detect the user's system preference and can be toggled with the moon/sun button in the header. All components will respond to theme changes with smooth transitions, providing a better user experience.