A Real-Time Chat App on Firebase
A multi-room chat app that runs entirely in the browser, using Firebase Realtime Database as the live channel and Google sign-in for identity.
- React 18
- Firebase
- Tailwind CSS
- Vite
Chat Chit is a room-based messaging app built with React 18 and Vite. The whole thing runs in the browser — there is no server of my own; data and authentication both go through Firebase.
Why Realtime Database#
The problem has exactly one real requirement: what one person types has to appear on someone else's screen immediately. I considered standing up my own WebSocket server, but that would have meant writing three things a chat app gives me no room to be clever about: message history, replay for whoever opens the room later, and authentication. Realtime Database bundles all three into one SDK — every write to a path lands on every client listening to that path, and the data stays put for the next visit.
I picked Realtime Database over Firestore because the data here genuinely is a JSON tree: rooms contain messages, and no query gets more complicated than "read this branch". In exchange, the app needs no backend at all — just a static bundle.
import { initializeApp } from 'firebase/app';
import { getDatabase } from 'firebase/database';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
/* Everything comes from Vite env vars; .env never enters git. */
const app = initializeApp({
apiKey: import.meta.env.VITE_API_KEY,
authDomain: import.meta.env.VITE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_DATABASE_URL,
projectId: import.meta.env.VITE_PROJECT_ID,
});
export const db = getDatabase(app);
export const auth = getAuth(app);
export const provider = new GoogleAuthProvider();Data shape and listener scope#
The tree stays deliberately shallow: conversations/{roomId} holds the room metadata, and
messages live at conversations/{roomId}/messages/{pushId} with four fields each — name,
avatar, content, timestamp.
The decision I spent most time on was how wide each listener reaches. The easy path is
to subscribe to the whole conversations branch and filter on the client, but then a
message in any room pulls the entire dataset down again. Instead there are two separate
listeners: one for the room list, and one bound to the currently open room, torn down every
time the selection changes.
useEffect(() => {
if (!selectedRoom) return;
/* Stream only the room actually on screen; drop it when the room changes. */
const messagesRef = ref(db, `conversations/${selectedRoom.id}/messages`);
const unsubscribe = onValue(messagesRef, (snapshot) => {
const data = snapshot.val();
setMessages(data ? Object.keys(data).map((key) => ({ id: key, ...data[key] })) : []);
});
return () => unsubscribe();
}, [selectedRoom]);
const handleSendMessage = () => {
if (!message.trim() || !selectedRoom) return;
/* push() mints a time-ordered key on the client: send and you are done. */
push(ref(db, `conversations/${selectedRoom.id}/messages`), {
name: user.displayName,
avatar: user.avatar,
content: message,
date: Date.now(),
});
setMessage('');
};Using push() has a pleasant side effect: the key is generated client-side and increases
with time, so nothing waits on the server for an ID, and the key order in the snapshot is
already the display order. The UI never sorts anything.
Joining a room: one read before the stream#
Joining does not open a listener — it issues a single get(). That is deliberate, because
a subscription blurs two states I need to tell apart: "this room code does not exist",
which should fail loudly, and "the room exists but nobody has spoken yet", which should
still open. The room code is simply the Date.now() value from when it was created, which
is enough to paste into a message without adding a code-generation service.
Rooms you have joined stay as tabs across the top, so following several conversations does not mean leaving the page. That tab list lives in React state, which means a refresh loses it and you re-enter the code — a trade I took rather than writing a user-to-room relationship into the database.
Google sign-in and the session boundary#
Sign-in goes through signInWithPopup, and onAuthStateChanged is the source of truth:
whenever the state changes, the email and avatar are mirrored into localStorage. The
protected route reads localStorage directly instead of waiting for Firebase to
initialise, so routing resolves synchronously and nobody sees a flash of the sign-in screen
before being sent back.
Outcome#
- Real-time chat across multiple rooms without a single line of server code
- Listeners scoped to the open room, so no client pulls data it will not render
- The security boundary written down plainly rather than assumed to be safe