Building a Loom-style screen recorder with Next.js, Stream, and Firebase brings together tools that make real-time video capture and sharing possible in a modern web app. It allows creators, teams, and users to record their screen, upload the video instantly, and access it from anywhere without any heavy setup.
This kind of system matters because people rely on fast visual communication, and a smooth recording flow removes friction from everyday work. By combining these technologies, we can shape a lightweight video workflow that feels quick, reliable, and easy to maintain.
Prerequisites
1. Create Next.js App:
npx create-next-app@latest loom-clone --typescript --tailwind --eslint
cd loom-clone2. Install Dependencies:
npm install firebase
npm install -D @types/node3. Firebase Setup:
1. Firebase Console → New Project
2. Enable Auth (Email/Password) + Storage + Firestore
3. Storage Rules: allow read, write: if request.auth != null;
4. Download Firebase config → src/lib/firebase.ts4. pubspec.yaml → firebase.ts (complete config):
// src/lib/firebase.ts
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getStorage } from 'firebase/storage';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_PROJECT.firebaseapp.com",
projectId: "YOUR_PROJECT_ID",
// ... full config from Firebase Console
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const storage = getStorage(app);
export const db = getFirestore(app);Building a Loom Clone Using Next.js, Stream, and Firebase
Building a Loom clone with Next.js, Stream, and Firebase brings together the tools needed for smooth screen recording, instant uploads, and simple playback. It creates a workflow where video capture and access feel direct and dependable, helping users share clear visual messages without extra effort.
Authentication (app/login/page.tsx)
'use client';
import { useState } from 'react';
import { signInWithEmailAndPassword, createUserWithEmailAndPassword } from 'firebase/auth';
import { auth } from '@/lib/firebase';
import { useRouter } from 'next/navigation';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLogin, setIsLogin] = useState(true);
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleAuth = async () => {
setLoading(true);
try {
if (isLogin) {
await signInWithEmailAndPassword(auth, email, password);
} else {
await createUserWithEmailAndPassword(auth, email, password);
}
router.push('/dashboard');
} catch (error) {
console.error('Auth failed:', error);
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto mt-20 p-6 bg-white rounded-lg shadow-xl">
<h1 className="text-2xl font-bold mb-6">{isLogin ? 'Login' : 'Sign Up'}</h1>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-3 mb-4 border rounded-lg"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 mb-4 border rounded-lg"
/>
<button
onClick={handleAuth}
disabled={loading}
className="w-full bg-blue-500 text-white p-3 rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
{loading ? 'Loading...' : (isLogin ? 'Login' : 'Sign Up')}
</button>
<button
onClick={() => setIsLogin(!isLogin)}
className="w-full mt-2 text-blue-500 hover:underline"
>
{isLogin ? 'Need an account? Sign Up' : 'Have an account? Login'}
</button>
</div>
);
}Dashboard (app/dashboard/page.tsx)
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { onAuthStateChanged } from 'firebase/auth';
import { collection, query, where, onSnapshot, deleteDoc, doc } from 'firebase/firestore';
import { ref, deleteObject } from 'firebase/storage';
import { auth, db, storage } from '@/lib/firebase';
interface Video {
id: string;
title: string;
url: string;
thumbnail: string;
duration: number;
createdAt: Date;
}
export default function Dashboard() {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
if (!user) router.push('/login');
});
if (auth.currentUser) {
const q = query(
collection(db, 'videos'),
where('userId', '==', auth.currentUser!.uid)
);
const unsubscribeVideos = onSnapshot(q, (snapshot) => {
const videoList: Video[] = [];
snapshot.forEach((doc) => {
videoList.push({ id: doc.id, ...doc.data() } as Video);
});
setVideos(videoList);
setLoading(false);
});
return () => unsubscribeVideos();
}
return unsubscribe;
}, [router]);
const deleteVideo = async (videoId: string, storagePath: string) => {
try {
await deleteDoc(doc(db, 'videos', videoId));
await deleteObject(ref(storage, storagePath));
} catch (error) {
console.error('Delete failed:', error);
}
};
if (loading) return <div className="p-8">Loading...</div>;
return (
<div className="p-8 max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Your Videos</h1>
<a href="/record" className="bg-red-500 text-white px-6 py-3 rounded-lg hover:bg-red-600">
+ Record New
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{videos.map((video) => (
<div key={video.id} className="bg-white rounded-lg shadow-md overflow-hidden">
<video
src={video.url}
className="w-full h-48 object-cover"
poster={video.thumbnail}
/>
<div className="p-4">
<h3 className="font-semibold mb-2">{video.title}</h3>
<div className="text-sm text-gray-500 mb-4">
{new Date(video.createdAt.toDate()).toLocaleDateString()}
</div>
<div className="flex gap-2">
<a
href={video.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 text-center"
>
Play
</a>
<button
onClick={() => deleteVideo(video.id, `videos/${video.id}.webm`)}
className="bg-gray-500 text-white py-2 px-4 rounded hover:bg-gray-600"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
</div>
);
}Screen Recorder (app/record/page.tsx)
'use client';
import { useState, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
import { collection, addDoc, serverTimestamp } from 'firebase/firestore';
import { useAuthState } from 'react-firebase-hooks/auth';
import { auth, storage, db } from '@/lib/firebase';
export default function Record() {
const [user] = useAuthState(auth);
const router = useRouter();
const [isRecording, setIsRecording] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
const [recordedChunks, setRecordedChunks] = useState<BlobPart[]>([]);
const videoRef = useRef<HTMLVideoElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always',
resizeMode: 'crop-and-scale',
},
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 44100,
},
});
streamRef.current = stream;
videoRef.current!.srcObject = stream;
const recorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9',
});
const chunks: BlobPart[] = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = () => {
setRecordedChunks(chunks);
setIsPreviewing(true);
stream.getTracks().forEach(track => track.stop());
};
mediaRecorderRef.current = recorder;
recorder.start(250); // 250ms chunks
setIsRecording(true);
} catch (error) {
console.error('Recording failed:', error);
}
}, []);
const stopRecording = () => {
if (mediaRecorderRef.current) {
mediaRecorderRef.current.stop();
setIsRecording(false);
}
};
const uploadVideo = async () => {
if (!recordedChunks.length || !user) return;
const videoBlob = new Blob(recordedChunks, { type: 'video/webm' });
const videoRefPath = ref(storage, `videos/${user.uid}_${Date.now()}.webm`);
try {
// Upload video
await uploadBytes(videoRefPath, videoBlob);
const videoUrl = await getDownloadURL(videoRefPath);
// Save metadata
await addDoc(collection(db, 'videos'), {
userId: user.uid,
title: `Recording ${new Date().toLocaleString()}`,
url: videoUrl,
thumbnail: videoUrl, // Generate thumbnail server-side in production
duration: videoBlob.size / 1000000, // MB
createdAt: serverTimestamp(),
});
router.push('/dashboard');
} catch (error) {
console.error('Upload failed:', error);
}
};
if (!user) return <div>Please log in</div>;
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Screen Recorder</h1>
<div className="bg-white rounded-lg shadow-xl p-8 max-w-2xl mx-auto">
<video
ref={videoRef}
className="w-full rounded-lg mb-6 bg-black"
autoPlay
muted
playsInline
/>
{!isPreviewing ? (
<div className="flex gap-4 justify-center">
{!isRecording ? (
<button
onClick={startRecording}
className="bg-red-500 text-white px-8 py-4 rounded-full text-xl hover:bg-red-600 font-semibold"
>
Start Recording
</button>
) : (
<>
<button
onClick={stopRecording}
className="bg-gray-600 text-white px-8 py-4 rounded-full text-xl hover:bg-gray-700 font-semibold"
>
Stop Recording
</button>
<div className="text-lg text-red-500 font-mono animate-pulse">
● Recording...
</div>
</>
)}
</div>
) : (
<div className="space-y-4">
<video
src={URL.createObjectURL(new Blob(recordedChunks, { type: 'video/webm' }))}
controls
className="w-full rounded-lg"
/>
<button
onClick={uploadVideo}
className="w-full bg-green-500 text-white py-4 px-8 rounded-full text-xl hover:bg-green-600 font-semibold"
>
Upload to Dashboard
</button>
</div>
)}
</div>
</div>
</div>
);
}Production Testing Checklist
Testing verifies that recording, uploading, and playback behave as expected in real use. Running the app end-to-end helps confirm that every part works together without errors.
1. npm run dev → localhost:3000/login
2. Sign up → /dashboard (empty)
3. /record → Share screen → Record 10s → Stop → Preview → Upload
4. Dashboard → See thumbnail → Play video
5. Share URL → Works in incognito
6. Delete Video → Gone from dashboard + Storage
Production Deployment:
1. firebase deploy (Hosting + Functions)
2. Generate thumbnails (Cloud Function)
3. Add HLS transcoding (FFmpeg)
4. Custom domain + SSL
5. Scales to 10k+ users [web:151][web:152]
