Build a Team Chat
This guide walks through building a channel-based team chat from scratch. You'll set up typed events, configure per-team pipe permissions, display messages with sender identity, add typing indicators, and handle connection drops gracefully.
Start by defining the events your chat needs. A chat message carries the full display data — ID, text, user info, and timestamp. Typing indicators are lightweight: just the user ID and name for start, and only the user ID for stop.
// lib/realtime-events.ts
import { z } from 'zod';
export const realtimeEvents = {
'message.created': z.object({
id: z.string(),
text: z.string(),
userId: z.string(),
userName: z.string(),
createdAt: z.number(),
}),
'typing.start': z.object({
userId: z.string(),
userName: z.string(),
}),
'typing.stop': z.object({
userId: z.string(),
}),
} as const;Each team gets its own pipe. The authorize function looks up which team the user belongs to and grants both subscribe and publish permissions on that team's pipe. Users can only access the pipe for their own team — there's no way to subscribe to or publish on another team's pipe.
// app/api/realtime/[...all]/route.ts
import { createPipeHandler } from 'hotpipe/server';
export const { POST } = createPipeHandler({
secret: process.env.HOTPIPE_SECRET!,
authorize: async (req) => {
const { session } = await getAuth(req);
if (!session) return null;
const teamId = await getTeamId(session.userId);
return {
userId: session.userId,
pipes: {
[`team-${teamId}`]: { subscribe: true, publish: true },
},
};
},
});Subscribe to the team pipe and accumulate messages into state. The event data includes the sender's userName for display. If you need a verified sender identity that clients can't spoof, use the metadata.sender field — see the Subscribing to events concept page for details.
import { useState } from 'react';
import { usePipe } from '@/lib/realtime-client';
type Message = {
id: string;
text: string;
userId: string;
userName: string;
createdAt: number;
};
function ChatMessages({ teamId }: { teamId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
usePipe(`team-${teamId}`, {
'message.created': (data) => {
setMessages((prev) => [...prev, data]);
},
});
return (
<ul>
{messages.map((msg) => (
<li key={msg.id}>
<strong>{msg.userName}</strong>: {msg.text}
</li>
))}
</ul>
);
}The publish function from usePipe sends events to the pipe. Save the message to your database first, then broadcast — this way every message has a permanent record and the real-time event is just the notification layer.
function ChatInput({ teamId }: { teamId: string }) {
const [text, setText] = useState('');
const { publish } = usePipe(`team-${teamId}`);
async function send() {
const message = await createMessage({ text, teamId });
publish('message.created', {
id: message.id,
text: message.text,
userId: message.userId,
userName: message.userName,
createdAt: message.createdAt,
});
setText('');
}
return (
<form onSubmit={(e) => { e.preventDefault(); send(); }}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">Send</button>
</form>
);
}Typing indicators use two events: typing.start fires when a user begins typing, and typing.stop fires when they stop. Track active typers in a map and render the indicator.
function TypingIndicator({ teamId }: { teamId: string }) {
const [typers, setTypers] = useState<Map<string, string>>(new Map());
usePipe(`team-${teamId}`, {
'typing.start': (data) => {
setTypers((prev) => new Map(prev).set(data.userId, data.userName));
},
'typing.stop': (data) => {
setTypers((prev) => {
const next = new Map(prev);
next.delete(data.userId);
return next;
});
},
});
if (typers.size === 0) return null;
const names = [...typers.values()];
const label =
names.length === 1
? `${names[0]} is typing...`
: `${names.join(', ')} are typing...`;
return <p className="text-sm text-gray-500">{label}</p>;
}On the sending side, fire typing.start on each keystroke and debounce typing.stop with a timeout. If the user stops typing for 2 seconds, the stop event fires automatically.
function ChatInput({ teamId }: { teamId: string }) {
const { publish } = usePipe(`team-${teamId}`);
const typingTimeout = useRef<NodeJS.Timeout>();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setText(e.target.value);
publish('typing.start', { userId: currentUser.id, userName: currentUser.name });
clearTimeout(typingTimeout.current);
typingTimeout.current = setTimeout(() => {
publish('typing.stop', { userId: currentUser.id });
}, 2000);
}
// ... rest of the input component
}The status field from usePipe is reactive — use it to show a banner when the connection drops and disable the input while offline. Hotpipe reconnects automatically, so most blips resolve in seconds.
function ChatRoom({ teamId }: { teamId: string }) {
const { status } = usePipe(`team-${teamId}`, {
'message.created': (data) => {
setMessages((prev) => [...prev, data]);
},
});
return (
<div>
{status === 'reconnecting' && (
<div className="bg-yellow-100 p-2 text-sm">
Reconnecting — messages may be delayed
</div>
)}
{status === 'disconnected' && (
<div className="bg-red-100 p-2 text-sm">
Disconnected — trying to reconnect
</div>
)}
<ChatMessages teamId={teamId} />
<ChatInput teamId={teamId} />
</div>
);
}When the connection comes back, fetch any messages that arrived while the user was offline. Compare the last message timestamp and merge the results to avoid duplicates.
const prevStatus = useRef(status);
useEffect(() => {
if (prevStatus.current === 'reconnecting' && status === 'connected') {
fetchMessages({ teamId, since: messages.at(-1)?.createdAt }).then((recent) => {
setMessages((prev) => deduplicateAndMerge(prev, recent));
});
}
prevStatus.current = status;
}, [status]);
