Skip to main content

Next.js App Router Structure

Watch N Chill uses Next.js 14+ App Router with a page-based structure:
src/app/
├── (landing)/           # Landing page group
│   ├── layout.tsx
│   └── page.tsx         # Home page
├── create/
│   ├── layout.tsx
│   └── page.tsx         # Create room form
├── join/
│   ├── layout.tsx
│   └── page.tsx         # Join room form
├── room/
│   └── [roomId]/
│       ├── layout.tsx
│       └── page.tsx     # Main room interface
├── layout.tsx           # Root layout
└── loading.tsx          # Global loading state
The (landing) group uses route grouping to apply a specific layout without affecting the URL structure.

SocketProvider Context

The SocketProvider manages the Socket.IO connection lifecycle and makes it available throughout the app:
contexts/socket-provider.tsx:31-84
export const SocketProvider: React.FC<SocketProviderProps> = ({ children }) => {
  const [socket, setSocket] = useState<Socket<SocketEvents, SocketEvents> | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const [isInitialized, setIsInitialized] = useState(false);

  useEffect(() => {
    // Only run on client side
    if (typeof window === 'undefined') return;

    console.log('Initializing socket...');
    setIsInitialized(true);

    const socketUrl = process.env.NODE_ENV === 'production' ? undefined : 'http://localhost:3000';
    console.log('Connecting to:', socketUrl);

    const socketInstance = io(socketUrl, {
      path: '/api/socket/io',
      transports: ['websocket', 'polling'],
      autoConnect: true,
    });

    setSocket(socketInstance);

    const handleConnect = () => {
      console.log('Socket connected:', socketInstance.id);
      setIsConnected(true);
    };

    const handleDisconnect = (reason: string) => {
      console.log('Socket disconnected:', reason);
      setIsConnected(false);
    };

    const handleConnectError = (error: Error) => {
      console.error('Socket connection error:', error);
      setIsConnected(false);
    };

    // Attach listeners
    socketInstance.on('connect', handleConnect);
    socketInstance.on('disconnect', handleDisconnect);
    socketInstance.on('connect_error', handleConnectError);

    // Cleanup function
    return () => {
      console.log('Cleaning up socket...');
      socketInstance.off('connect', handleConnect);
      socketInstance.off('disconnect', handleDisconnect);
      socketInstance.off('connect_error', handleConnectError);
      socketInstance.disconnect();
    };
  }, []); // Empty dependency array - run once

  return <SocketContext.Provider value={{ socket, isConnected, isInitialized }}>{children}</SocketContext.Provider>;
};

useSocket Hook

contexts/socket-provider.tsx:19-25
export const useSocket = () => {
  const context = useContext(SocketContext);
  if (!context) {
    throw new Error('useSocket must be used within a SocketProvider');
  }
  return context;
};
The provider tracks three states:
  • isInitialized: Socket.IO client has been created
  • isConnected: Active WebSocket connection established
  • socket: Socket.IO client instance (null until initialized)
Components can use these to show loading states or handle offline scenarios.

Core React Hooks

Watch N Chill uses custom hooks to encapsulate business logic and state management:

useCreateRoom

Handles room creation flow:
hooks/use-create-room.ts:28-81
const handleCreateRoom = useCallback(
  async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    try {
      // Validate with Zod schema
      const validatedData = CreateRoomDataSchema.parse({
        hostName: hostName.trim(),
      });

      if (!socket || !isConnected) {
        setError('Not connected to server. Please try again.');
        return;
      }

      setIsLoading(true);

      // Listen for room creation response
      socket.once('room-created', ({ roomId, hostToken }) => {
        setIsLoading(false);
        trackUmamiEvent('room_created', { roomId });
        
        // Store creator info so room page knows not to prompt again
        roomSessionStorage.setRoomCreator({
          roomId,
          hostName: validatedData.hostName,
          hostToken,
        });
        
        // Navigate immediately
        router.push(`/room/${roomId}`);
      });

      socket.once('room-error', ({ error }) => {
        setIsLoading(false);
        setError(error);
        trackUmamiEvent('room_create_error', { message: error });
      });

      // Create the room
      socket.emit('create-room', validatedData);
    } catch (error) {
      if (error instanceof z.ZodError) {
        setError(error.issues[0].message);
      } else {
        setError('Invalid input. Please check your name.');
      }
    }
  },
  [hostName, socket, isConnected, router]
);
The hook stores the hostToken in session storage so the room page can automatically join as host without prompting for credentials.

useJoinRoom

Handles guest joining flow:
hooks/use-join-room.ts:32-100
const handleJoinRoom = useCallback(
  async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');

    try {
      // Validate with Zod schemas
      const roomIdResult = RoomIdSchema.safeParse(roomId.trim().toUpperCase());
      if (!roomIdResult.success) {
        setError(roomIdResult.error.issues[0].message);
        return;
      }

      const userNameResult = UserNameSchema.safeParse(userName.trim());
      if (!userNameResult.success) {
        setError(userNameResult.error.issues[0].message);
        return;
      }

      const joinData = {
        roomId: roomIdResult.data,
        userName: userNameResult.data,
      };

      // Validate the complete join data
      const validatedData = JoinRoomDataSchema.parse(joinData);

      if (!socket || !isConnected) {
        setError('Not connected to server. Please try again.');
        return;
      }

      setIsLoading(true);

      // Listen for room join response
      socket.once('room-joined', () => {
        setIsLoading(false);
        trackUmamiEvent('room_joined', { roomId: validatedData.roomId });
        
        // Store the join data for the room page
        roomSessionStorage.setJoinData({
          roomId: validatedData.roomId,
          userName: validatedData.userName,
        });
        router.push(`/room/${validatedData.roomId}`);
      });

      socket.once('room-error', ({ error }) => {
        setIsLoading(false);
        setError(error);
        trackUmamiEvent('room_join_error', {
          roomId: validatedData.roomId,
          message: error,
        });
      });

      // Join the room
      socket.emit('join-room', validatedData);
    } catch (error) {
      if (error instanceof z.ZodError) {
        setError(error.issues[0].message);
      } else {
        setError('Invalid input. Please check your entries.');
      }
    }
  },
  [roomId, userName, socket, isConnected, router]
);

useRoom

Manages room state, users, and chat messages:
hooks/use-room.ts:75-300
useEffect(() => {
  if (!socket || !isConnected) return;

  const handleRoomJoined = ({ room: joinedRoom, user }: { room: Room; user: User }) => {
    console.log('Room joined successfully:', {
      room: joinedRoom.id,
      user: user.name,
      isHost: user.isHost,
      userId: user.id,
    });
    setRoom(joinedRoom);
    setCurrentUser(user);
    setError('');
    setSyncError('');
    setIsJoining(false);
    hasAttemptedJoinRef.current = false;

    // Show info banner for guests when joining a room with video
    if (!user.isHost && joinedRoom.videoUrl) {
      setShowGuestInfoBanner(true);
      setTimeout(() => setShowGuestInfoBanner(false), 5000);
    }
  };

  const handleUserJoined = ({ user }: { user: User }) => {
    setRoom(prev => {
      if (!prev) return null;
      const existingUserIndex = prev.users.findIndex(u => u.id === user.id);
      if (existingUserIndex >= 0) {
        const updatedUsers = [...prev.users];
        updatedUsers[existingUserIndex] = user;
        return { ...prev, users: updatedUsers };
      }
      return { ...prev, users: [...prev.users, user] };
    });
  };

  const handleUserLeft = ({ userId }: { userId: string }) => {
    setTypingUsers(prev => prev.filter(user => user.userId !== userId));
    setRoom(prev => {
      if (!prev) return null;
      const updatedUsers = prev.users.filter(u => u.id !== userId);
      return { ...prev, users: updatedUsers };
    });
  };

  const handleVideoSet = ({ videoUrl, videoType }: { videoUrl: string; videoType: 'youtube' }) => {
    setRoom(prev =>
      prev
        ? {
            ...prev,
            videoUrl,
            videoType,
            videoState: {
              isPlaying: false,
              currentTime: 0,
              duration: 0,
              lastUpdateTime: Date.now(),
            },
          }
        : null
    );

    if (currentUser && !currentUser.isHost) {
      setShowGuestInfoBanner(true);
      setTimeout(() => setShowGuestInfoBanner(false), 5000);
    }
  };

  const handleNewMessage = ({ message }: { message: ChatMessage }) => {
    const messageWithReadStatus = {
      ...message,
      isRead: message.userId === currentUser?.id || false,
    };
    setMessages(prev => [...prev, messageWithReadStatus]);
  };

  socket.on('room-joined', handleRoomJoined);
  socket.on('user-joined', handleUserJoined);
  socket.on('user-left', handleUserLeft);
  socket.on('video-set', handleVideoSet);
  socket.on('new-message', handleNewMessage);
  // ... more handlers

  return () => {
    socket.off('room-joined', handleRoomJoined);
    socket.off('user-joined', handleUserJoined);
    socket.off('user-left', handleUserLeft);
    socket.off('video-set', handleVideoSet);
    socket.off('new-message', handleNewMessage);
    // ... cleanup
  };
}, [socket, isConnected, router, currentUser, room]);
The hook automatically attempts to join the room based on session storage:
hooks/use-room.ts:302-396
useEffect(() => {
  if (!socket || !isConnected || !roomId) return;
  if (room && currentUser) return;
  if (isJoining || hasAttemptedJoinRef.current) return;

  // Check if this user is the room creator first
  const creatorData = roomSessionStorage.getRoomCreator(roomId);
  if (creatorData) {
    console.log('Room creator detected, joining as host');
    roomSessionStorage.clearRoomCreator();
    socket.emit('join-room', {
      roomId,
      userName: creatorData.hostName,
      hostToken: creatorData.hostToken,
    });
    return;
  }

  // Check if user came from join page
  const joinData = roomSessionStorage.getJoinData(roomId);
  if (joinData) {
    roomSessionStorage.clearJoinData();
    socket.emit('join-room', { roomId, userName: joinData.userName });
    return;
  }

  // Prompt for name if no stored data
  const userName = prompt('Enter your name to join the room:');
  if (!userName || !userName.trim()) {
    router.push('/');
    return;
  }
  
  socket.emit('join-room', { roomId, userName: userName.trim() });
}, [socket, isConnected, roomId, router, room, currentUser, isJoining]);
The hook ensures users leave the room when navigating away:
hooks/use-room.ts:410-418
useEffect(() => {
  return () => {
    const { socket, isConnected, roomId, room, currentUser } = cleanupDataRef.current;
    if (socket && isConnected && room && currentUser) {
      console.log('Component unmounting, leaving room...');
      socket.emit('leave-room', { roomId });
    }
  };
}, []);

useVideoSync

The most complex hook - handles video synchronization between host and guests:
hooks/use-video-sync.ts:199-235
const startSyncCheck = useCallback(() => {
  if (syncCheckIntervalRef.current) {
    clearInterval(syncCheckIntervalRef.current);
  }

  syncCheckIntervalRef.current = setInterval(() => {
    // Only proceed if all conditions are met
    if (!room || !currentUser?.isHost || !socket || !isConnected || !currentUser?.id) {
      return;
    }

    const player = getCurrentPlayer();
    if (!player) {
      return;
    }

    const currentTime = player.getCurrentTime();
    const isPlaying = youtubePlayerRef.current?.getPlayerState() === YT_STATES.PLAYING;

    console.log(`Periodic sync check: ${currentTime.toFixed(2)}s, playing: ${isPlaying}`);
    socket.emit('sync-check', {
      roomId,
      currentTime,
      isPlaying,
      timestamp: Date.now(),
    });
  }, 5000); // Every 5 seconds
}, [room, currentUser, socket, isConnected, roomId, getCurrentPlayer, youtubePlayerRef]);
This hook is covered in detail in the Real-Time Sync documentation.

Component Organization

Components are organized by feature:
src/components/
├── chat/                # Chat components
│   ├── chat.tsx         # Main chat container
│   ├── chat-input.tsx   # Message input
│   ├── chat-message.tsx # Message display
│   └── chat-overlay.tsx # Fullscreen overlay
├── room/                # Room-specific components
│   ├── room-header.tsx
│   ├── user-list.tsx
│   └── video-player-container.tsx
├── video/               # Video player components
│   ├── youtube-player.tsx
│   ├── video-controls.tsx
│   └── video-setup.tsx
├── landing/             # Landing page components
│   ├── hero.tsx
│   ├── features.tsx
│   └── cta.tsx
├── global/              # Shared components
│   ├── container.tsx
│   └── wrapper.tsx
└── ui/                  # shadcn/ui components
    ├── button.tsx
    ├── dialog.tsx
    └── ...

Room Page Structure

The main room page (room/[roomId]/page.tsx) orchestrates all functionality:
room/[roomId]/page.tsx:20-65
export default function RoomPage() {
  const params = useParams();
  const roomId = params.roomId as string;
  
  // Player refs
  const youtubePlayerRef = useRef<YouTubePlayerRef>(null);

  // Use room hook for state and basic room operations
  const {
    room,
    currentUser,
    messages,
    typingUsers,
    error,
    syncError,
    showGuestInfoBanner,
    showHostDialog,
    setShowGuestInfoBanner,
    setShowHostDialog,
    handlePromoteUser,
    handleSendMessage,
    handleTypingStart,
    handleTypingStop,
    markMessagesAsRead,
  } = useRoom({ roomId });

  // Use video sync hook for video synchronization
  const {
    syncVideo,
    startSyncCheck,
    stopSyncCheck,
    handleVideoPlay,
    handleVideoPause,
    handleVideoSeek,
    handleYouTubeStateChange,
    handleSetVideo,
  } = useVideoSync({
    room,
    currentUser,
    roomId,
    youtubePlayerRef,
  });

  // ... component logic
}

State Management Strategy

Socket.IO Events:
  • Room state, users, video state
  • Received via Socket.IO events
  • Stored in React state (via hooks)
  • Single source of truth: Redis on server
Flow:
Server (Redis) → Socket.IO Event → useRoom Hook → React State → UI

Video Time Calculation

The frontend calculates the current video time from the server’s last known state:
lib/video-utils.ts:40-51
export function calculateCurrentTime(videoState: {
  currentTime: number;
  isPlaying: boolean;
  lastUpdateTime: number;
}): number {
  if (!videoState.isPlaying) {
    return videoState.currentTime;
  }

  const timeDiff = (Date.now() - videoState.lastUpdateTime) / 1000;
  return videoState.currentTime + timeDiff;
}
This approach avoids constant server updates - the server only sends the time when play/pause/seek occurs, and clients calculate the current time locally.

Error Handling

contexts/socket-provider.tsx:64-67
const handleConnectError = (error: Error) => {
  console.error('Socket connection error:', error);
  setIsConnected(false);
};
Components can check isConnected to show offline UI or retry logic.
hooks/use-room.ts:193-224
const handleRoomError = ({ error }: { error: string }) => {
  console.error('Room error:', error);

  if (error.includes('All hosts have left') || error.includes('Redirecting to home page')) {
    toast.error('Room Closed', {
      description: 'All hosts have left the room.',
      duration: 4000,
    });

    setTimeout(() => {
      router.push('/');
    }, 1500);
    return;
  }

  setError(error);
  setIsJoining(false);
};
Client-side validation uses the same Zod schemas as the server:
hooks/use-create-room.ts:72-78
} catch (error) {
  if (error instanceof z.ZodError) {
    setError(error.issues[0].message);
  } else {
    setError('Invalid input. Please check your name.');
  }
}

Next Steps

Real-Time Sync

Deep dive into the video synchronization mechanism

Backend Architecture

Understand the server-side implementation

Build docs developers (and LLMs) love