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 ;
};
Connection States
Transport Strategy
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. transports : [ 'websocket' , 'polling' ]
Socket.IO attempts WebSocket first, then falls back to long-polling if WebSocket fails (e.g., behind restrictive proxies).
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:
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 ]);
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
Server State
Client State
Derived State
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
Local UI State:
Dialog open/closed
Loading indicators
Form input values
Managed with useState in components
Session Storage:
Room creator credentials
Join data (userName, roomId)
Used for navigation between pages
Computed Values:
Current video time (calculated from timestamp)
Unread message count
User permissions (isHost)
Example: const unreadCount = messages . filter ( m => ! m . isRead && m . userId !== currentUser ?. id ). length ;
Video Time Calculation
The frontend calculates the current video time from the server’s last known state:
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