Create the channel inside the effect and remove it in the cleanup. StrictMode mounts, unmounts and mounts again in dev on purpose, to surface exactly this missing cleanup.
useEffect(() => {
const channel = supabase
.channel('room-1')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' },
(p) => setMessages((m) => [...m, p.new as Message]))
.subscribe()
return () => { supabase.removeChannel(channel) }
}, [])Put StrictMode back. The duplicated messages were two live subscriptions from the double mount. In production without cleanup you'd leak a channel every time someone navigates away and back.