cd ../blog
NextjsTrpc

Building a Full-Stack YouTube Clone with nextjs 15, trpc, and more web Technologies

Mar 10, 2026 / by lora

Table of Contents

  1. Introduction
  2. Project Overview
  3. Architecture Deep Dive
  4. Core Technologies and Features
  5. Challenges and Solutions
  6. Conclusion

Introduction

Building a video-sharing platform like YouTube is one of the most challenging yet rewarding projects for full-stack developers. It involves complex requirements: video processing, real-time streaming, user authentication, subscription systems, and much more.

In this technical deep dive, I’ll walk you through how I built NewTube - a fully functional YouTube clone using modern web technologies. This isn’t just a UI clone; it’s a production-ready application with real video processing, AI-powered features, and a scalable architecture.

GitHub Repository: NewTube Clone

Live Demo: Available in the repository


Project Overview

What is NewTube?

NewTube is a full-featured video-sharing platform that includes:

  • Video Upload & Streaming: Powered by Mux with HLS adaptive streaming
  • User Authentication: Complete auth flow with Clerk
  • Creator Studio: Dashboard for video management and analytics
  • Subscription System: Follow your favorite creators
  • Comments & Reactions: Full engagement features
  • Playlists: Create and manage video collections
  • AI Features: Auto-generate titles, descriptions, and thumbnails
  • Responsive Design: Works seamlessly on all devices

Tech Stack at a Glance

LayerTechnology
FrontendNext.js 15, React 19, TypeScript, Tailwind CSS
BackendtRPC, Drizzle ORM
DatabasePostgreSQL (Neon)
AuthClerk
VideoMux
StorageUploadThing
CacheUpstash Redis
QueueQStash

Architecture Deep Dive

System Architecture

The application follows a three-tier architecture with serverless patterns optimized for modern cloud deployment:

┌─────────────────────────────────────────────────────────────────┐
│                      CLIENT LAYER                                │
│  Browser / Mobile → React Components → tRPC Client              │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                      API LAYER (tRPC)                            │
│  - protectedProcedure middleware                                 │
│  - Input validation (Zod)                                        │
│  - Rate limiting (Upstash)                                       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────���───────────────────────────────────────────┐
│                      DATA LAYER                                  │
│  PostgreSQL (Drizzle ORM) ←→ Redis Cache ←→ External Services   │
└─────────────────────────────────────────────────────────────────┘

Project Structure

I organized the codebase using a feature-based module structure (which I call “moubles”):

moubles/
├── videos/
│   ├── server/
│   │   └── procedures.ts    # tRPC API endpoints
│   ├── ui/
│   │   └── components/      # React components
│   └── type.ts              # TypeScript types
├── comments/
├── subscriptions/
├── playlists/
└── ... (other features)

This approach keeps related code together, making the codebase more maintainable as it grows.

Request Flow Example

Here’s how a typical API request flows through the system:

// 1. Client Component triggers the query
const [videos] = trpc.videos.getMany.useSuspenseQuery({
	categoryId: selectedCategory
});

// 2. tRPC Client serializes and sends request
// POST /api/trpc/videos.getMany
// Body: { json: { categoryId: "..." } }

// 3. Server-side tRPC handles the request
export const videosRouter = createTRPCRouter({
	getMany: baseProcedure
		.input(
			z.object({
				categoryId: z.string().uuid().optional(),
				limit: z.number().min(1).max(100).default(10)
			})
		)
		.query(async ({ input }) => {
			// Database query with Drizzle ORM
			const data = await db
				.select({
					id: videos.id,
					title: videos.title
					// ... other fields
				})
				.from(videos)
				.where(eq(videos.videoVisiblity, 'public'));

			return { items: data, nextCursor: null };
		})
});

Core Technologies and Features

1. Server Components with SSR Prefetching

Next.js 15’s App Router allows us to prefetch data on the server and hydrate it to the client:

// app/(home)/videos/[videoId]/page.tsx (Server Component)
import { HydrateClient, trpc } from '@/trpc/server';

export default async function VideoPage({ params }: { params: { videoId: string } }) {
	// Prefetch on the server
	void trpc.videos.getOne.prefetch({ id: params.videoId });
	void trpc.comments.getMany.prefetch({ videoId: params.videoId });

	return (
		<HydrateClient>
			<VideoSection videoId={params.videoId} />
		</HydrateClient>
	);
}

// VideoSection.tsx (Client Component)
('use client');

export function VideoSection({ videoId }: { videoId: string }) {
	// Data is already prefetched - no loading state!
	const [video] = trpc.videos.getOne.useSuspenseQuery({ id: videoId });

	return <VideoPlayer video={video} />;
}

Benefits:

  • Zero loading states for initial page load
  • Better SEO with server-rendered content
  • Reduced client-side JavaScript

2. Type-Safe API Layer with tRPC

tRPC provides end-to-end type safety without code generation:

// Backend: trpc/router/videos.ts
export const videosRouter = createTRPCRouter({
	create: protectedProcedure.mutation(async ({ ctx }) => {
		const upload = await mux.video.uploads.create({
			new_asset_settings: {
				playback_policies: ['public'],
				inputs: [
					{
						generated_subtitles: [
							{
								language_code: 'en',
								name: 'English'
							}
						]
					}
				]
			},
			cors_origin: process.env.MUX_CORS_ORIGIN || '*'
		});

		const [video] = await db
			.insert(videos)
			.values({
				userId: ctx.user.id,
				title: 'Untitled',
				muxStatus: 'waiting',
				muxUploadId: upload.id
			})
			.returning();

		return { video, url: upload.url };
	})
});

// Frontend: Auto-completion and type checking
const createMutation = trpc.videos.create.useMutation();
//     ^? { video: Video, url: string } - fully typed!

3. Authentication Middleware with Caching

The authentication flow is optimized with a three-tier caching strategy:

// trpc/init.ts
export const protectedProcedure = t.procedure.use(async (opts) => {
	const { ctx } = opts;

	if (!ctx.clerkUserId) {
		throw new TRPCError({ code: 'UNAUTHORIZED' });
	}

	// Tier 1: Check JWT publicMetadata (fastest)
	if (ctx.dbUserId) {
		return opts.next({ ctx: { user: { id: ctx.dbUserId } } });
	}

	// Tier 2: Check Redis cache
	const cachedDbId = await redis.get(`user:dbId:${ctx.clerkUserId}`);
	if (cachedDbId) {
		return opts.next({ ctx: { user: { id: cachedDbId } } });
	}

	// Tier 3: Database query (fallback)
	const [user] = await db.select().from(users).where(eq(users.clerkId, ctx.clerkUserId));

	// Cache for future requests
	await redis.set(`user:dbId:${ctx.clerkUserId}`, user.id, { ex: 3600 });

	// Update JWT for next time
	await clerkClient.users.updateUserMetadata(ctx.clerkUserId, {
		publicMetadata: { dbUserId: user.id }
	});

	return opts.next({ ctx: { user } });
});

4. Video Processing Pipeline

The video upload and processing flow uses webhooks for async updates:

User Upload → Mux Upload URL → Mux Processing → Webhook → Database Update
     │              │                │               │            │
     └──────────────┴────────────────┴───────────────┴────────────┘
                    Asynchronous flow with real-time updates
// app/api/videos/webhook/route.ts
export async function POST(req: Request) {
	const payload = await req.json();

	switch (payload.type) {
		case 'video.asset.ready': {
			const asset = payload.data;
			const playbackId = asset.playback_ids[0].id;

			// Generate thumbnail from Mux
			const thumbnailUrl = `https://image.mux.com/${playbackId}/thumbnail.jpg`;
			const uploaded = await utapi.uploadFilesFromUrl(thumbnailUrl);

			// Update database
			await db
				.update(videos)
				.set({
					muxPlaybackId: playbackId,
					muxStatus: 'ready',
					duration: Math.round(asset.duration),
					thumbnailUrl: uploaded.data.ufsUrl
				})
				.where(eq(videos.muxUploadId, asset.upload_id));

			break;
		}
	}

	return new Response('OK');
}

5. AI-Powered Content Generation

Using QStash for async AI tasks:

// Trigger AI task
const { workflowRunId } = await workflow.trigger({
	url: `${process.env.QSTASH_WORKFLOW_URL}/api/videos/workflows/title`,
	body: { userId, videoId }
});

// Workflow handler
export async function POST(req: Request) {
	const { videoId } = await req.json();

	// Get video transcript from Mux
	const track = await mux.video.tracks.get(video.muxTrackId);
	const transcript = track.text;

	// Generate title with AI
	const response = await openai.chat.completions.create({
		model: 'gpt-4',
		messages: [
			{
				role: 'user',
				content: `Generate a YouTube title based on: ${transcript}`
			}
		]
	});

	const title = response.choices[0].message.content;

	// Update database
	await db.update(videos).set({ title }).where(eq(videos.id, videoId));
}

6. Optimistic Updates for Better UX

const utils = trpc.useUtils();

const likeMutation = trpc.videoReactions.like.useMutation({
	onMutate: async ({ videoId }) => {
		// Cancel outgoing refetches
		await utils.videos.getOne.cancel({ id: videoId });

		// Snapshot previous value
		const previous = utils.videos.getOne.getData({ id: videoId });

		// Optimistically update UI
		utils.videos.getOne.setData({ id: videoId }, (old) => ({
			...old!,
			viewerReaction: 'like',
			likeCount: old!.likeCount + 1
		}));

		return { previous };
	},
	onError: (err, { videoId }, context) => {
		// Rollback on error
		utils.videos.getOne.setData({ id: videoId }, context.previous);
		toast.error('Failed to like video');
	},
	onSettled: ({ videoId }) => {
		// Refetch to ensure consistency
		utils.videos.getOne.invalidate({ id: videoId });
		utils.playlists.getLiked.invalidate();
	}
});

Challenges and Solutions

Challenge 1: SSR Authentication with Protected Routes

Problem: When using SSR with protected routes, the server doesn’t have access to the client’s authentication state, causing 401 errors or hydration mismatches.

Solution: Implement a multi-layered approach:

// In protected components
"use client";

import { useAuth, useClerk } from "@clerk/nextjs";
import { useEffect, useRef } from "react";

export function ProtectedSection() {
  const { isSignedIn, isLoaded } = useAuth();
  const { openSignIn } = useClerk();
  const hasTriggeredSignIn = useRef(false);

  useEffect(() => {
    // Prevent multiple calls with useRef
    if (isLoaded && !isSignedIn && !hasTriggeredSignIn.current) {
      hasTriggeredSignIn.current = true;
      openSignIn();
    }
  }, [isLoaded, isSignedIn, openSignIn]);

  if (!isLoaded) {
    return <Skeleton />;
  }

  if (!isSignedIn) {
    return null;
  }

  return <ActualContent />;
}

Key Insight: Using useRef prevents the sign-in modal from opening multiple times during React’s strict mode double-render.

Challenge 2: Hydration Mismatch in Mobile Detection

Problem: A mobile detection hook caused hydration errors because the initial value differed between server and client.

// ❌ Problem: undefined on server, boolean on client
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
return !!isMobile;

Solution: Default to false and only update on client:

// ✅ Fixed: Consistent initial value
const [isMobile, setIsMobile] = useState<boolean>(false);

useEffect(() => {
	const checkMobile = () => {
		setIsMobile(window.innerWidth < 768);
	};

	checkMobile();
	window.addEventListener('resize', checkMobile);
	return () => window.removeEventListener('resize', checkMobile);
}, []);

return isMobile;

Challenge 3: Cache Invalidation Across Features

Problem: When a user likes a video, the “Liked Videos” playlist wasn’t updating in real-time.

Solution: Invalidate all related queries:

const likeMutation = trpc.videoReactions.like.useMutation({
	onSuccess: () => {
		// Primary data
		utils.videos.getOne.invalidate({ id: videoId });

		// Related features
		utils.playlists.getLiked.invalidate();
		utils.playlists.getLikedPreview.invalidate();
	}
});

Challenge 4: CORS Configuration for Video Uploads

Problem: In production, Mux uploads failed due to CORS restrictions.

Solution: Use environment-based CORS configuration:

const upload = await mux.video.uploads.create({
	new_asset_settings: {
		/* ... */
	},
	// Development: "*" | Production: specific domain
	cors_origin: process.env.MUX_CORS_ORIGIN || '*'
});

Challenge 5: Real-time Video Processing Status

Problem: Users couldn’t see when their video finished processing.

Solution: Implement polling with automatic cleanup:

const [workflowRunId, setWorkflowRunId] = useState<string | null>(null);

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

	const interval = setInterval(() => {
		// Invalidate cache to refetch latest data
		utils.studio.getOne.invalidate({ id: videoId });
	}, 2000);

	// Cleanup after 60 seconds
	const timeout = setTimeout(() => {
		clearInterval(interval);
		toast.info('Processing may take longer than expected');
	}, 60000);

	return () => {
		clearInterval(interval);
		clearTimeout(timeout);
	};
}, [workflowRunId, videoId, utils]);

Conclusion

Building NewTube was an incredible learning experience that pushed me to solve real-world problems at scale. Here’s what I learned:

Key Takeaways

  1. Type Safety is Non-Negotiable: tRPC + TypeScript + Zod eliminated entire categories of bugs
  2. Server Components are Game-Changers: SSR prefetching dramatically improved UX and SEO
  3. Caching Strategy Matters: The three-tier auth caching reduced database queries by 90%
  4. Webhooks Enable Async Workflows: Critical for video processing and AI tasks
  5. Feature-Based Architecture Scales: The “moubles” structure kept the codebase maintainable

What’s Next?

Future improvements I’m planning:

  • Real-time notifications with Server-Sent Events
  • Video chapters and timestamps
  • Live streaming support
  • Advanced analytics dashboard
  • Mobile app with React Native

Resources


Thank you for reading! If you found this article helpful, please give the repository a ⭐ and feel free to reach out with questions.

Happy coding! 🚀

$ cd ../blog by lora

Tags

#Nextjs#Trpc#Nextjs#Trpc#Nextjs#Trpc
#Nextjs#Trpc#Nextjs#Trpc#Nextjs#Trpc
#Nextjs#Trpc#Nextjs#Trpc#Nextjs#Trpc
#Nextjs#Trpc#Nextjs#Trpc#Nextjs#Trpc