Building a Chat: Chapter 4 — Admin Dashboard and Push Notifications
Tony Duong
May 31, 2026 ・ 6 min
💬 This is the live chat widget you can see in the bottom-right corner of this very site. This series walks through exactly how I built it — click the chat bubble to try it, then read on to see how it works under the hood.
The visitor-side chat from chapters 2 and 3 is half the story. This chapter covers the other half: how the admin authenticates, how AdminChat.tsx multiplexes many conversations over one WebSocket, and how Web Push notifications reach the admin even after they close the browser tab.
Admin authentication flow
The admin pages live at src/app/admin/ -- outside the [locale] route tree. They need their own root layout (src/app/admin/layout.tsx) because Next.js App Router requires every route tree to provide <html> and <body> tags. The blog gets those from src/app/[locale]/layout.tsx, so /admin/* is a separate tree.
The login sequence:
1. Admin visits /admin/login
2. AdminLoginPage renders a form (src/app/admin/login/page.tsx)
3. Form submits -> adminLogin(user, pass) (src/lib/chat-client.ts)
4. fetch POST /auth/login (credentials: "include")
5. AuthController checks ENV vars (chat-server/app/controllers/auth_controller.rb)
6. Match -> session[:admin] = true, returns { ok: true }
7. Browser stores _chat_server_session cookie
8. Router pushes to /admin/chats
9. AdminChat connects WebSocket -- Connection#connect reads session[:admin]
The controller (chat-server/app/controllers/auth_controller.rb) compares against two env vars:
if username == ENV["ADMIN_USER"] && password == ENV["ADMIN_PASSWORD"]
session[:admin] = true
render json: { ok: true }
end
No users table, no password hashing, no recovery flow. For a single-admin personal blog: zero flexibility, but zero attack surface beyond the env vars. The WebSocket connection reads that same session to set self.admin = true (chapter 2).
AdminChat.tsx -- managing many conversations on one WebSocket
The visitor ChatWidget from chapter 3 handles one conversation. AdminChat handles all of them over a single AdminChannel subscription, with two levels of state:
// src/components/AdminChat.tsx
const [conversations, setConversations] = useState<ChatConversation[]>([]); // list
const [selectedId, setSelectedId] = useState<number | null>(null); // detail
const [messages, setMessages] = useState<ChatMessage[]>([]); // detail
The received callback switches on four event types:
| Event type | Effect |
|---|---|
conversations |
Replaces the full conversation list (sent on connect) |
new_message |
Updates last_message / unread_count; appends message if selected |
history |
Replaces message list for the selected conversation |
conversation_deleted |
Removes from list; clears selection if it was the deleted one |
The new_message handler has a subtle edge case. When a message arrives for a conversation not yet in the list (brand-new visitor), the component asks the server to refresh rather than constructing a partial object:
const exists = prev.some((c) => c.id === data.conversation_id);
if (!exists) {
subscriptionRef.current?.perform("list_conversations");
return prev;
}
The selectedIdRef pattern from chapter 3 appears here too -- the callback reads selectedIdRef.current to decide whether to append a message, without re-creating the subscription on every conversation switch.
Selecting a conversation fires two server actions through the same WebSocket:
subscriptionRef.current?.perform("get_history", { conversation_id: id });
subscriptionRef.current?.perform("mark_read", { conversation_id: id });
The unread count is optimistically zeroed client-side while the server persists the change.
The Web Push pipeline
Push notifications involve four parties: the admin browser, the Next.js frontend, the Rails backend, and a push service (Google FCM, Mozilla autopush). You never register with these services -- VAPID keys handle authentication.
Registration
When AdminChat connects, it registers for push. registerPushNotifications in src/components/AdminChat.tsx does four things:
- Registers the service worker (
/sw-chat.js) - Requests notification permission
- Subscribes to the push service with the VAPID public key
- POSTs
{ endpoint, p256dh, auth }to Rails
// src/components/AdminChat.tsx
const registration = await navigator.serviceWorker.register("/sw-chat.js");
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey) as BufferSource,
});
const json = subscription.toJSON();
await fetch(`${getChatHttpUrl()}/push_subscriptions`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
endpoint: json.endpoint,
p256dh: json.keys?.p256dh,
auth: json.keys?.auth,
}),
});
Rails saves these three fields to the push_subscriptions table -- everything it needs to send encrypted payloads later.
Sending a notification
The trigger is in VisitorChannel#send_message (chapter 2). After broadcasting to both streams, it checks for an active admin WebSocket:
# chat-server/app/channels/visitor_channel.rb
unless admin_connected?
PushNotificationService.notify(conversation.visitor_name, content) rescue nil
end
PushNotificationService (chat-server/app/services/push_notification_service.rb) iterates all saved subscriptions and calls WebPush.payload_send with the VAPID credentials. If a subscription returns 410 Gone, it catches WebPush::ExpiredSubscription and destroys the stale record.
VAPID keys
A public/private keypair identifying your server to the push service. Generate with:
cd chat-server
bundle exec rails runner "k = WebPush.generate_key; puts k.public_key; puts k.private_key"
The public key goes in .env.local (NEXT_PUBLIC_VAPID_PUBLIC_KEY) and chat-server/.env (VAPID_PUBLIC_KEY). The private key stays backend-only.
The service worker
public/sw-chat.js is 30 lines with two event listeners. push parses the encrypted payload and calls showNotification. notificationclick closes the notification, tries to focus an existing /admin/chats tab, or opens a new one.
The critical property: a service worker runs in a separate thread and persists after the tab closes. That is the entire reason push notifications work when the admin is not on the page.
What's next
The feature works locally. Chapter 5 covers deployment -- how Kamal 2 packages the Rails app into a Docker container, ships it to a Hetzner VPS, and wires up SSL, environment secrets, and GitHub Actions CI.
Try it out
1. Inspect the push subscription
Open /admin/chats with devtools Application tab. Check Service Workers for sw-chat.js and Push Messaging for the subscription details.
In Chrome: DevTools > Application > Service Workers shows sw-chat.js with status "activated and is running". Under Application > Storage > Push Messaging, the subscription displays the endpoint URL (pointing to fcm.googleapis.com on Chrome), plus p256dh and auth keys matching what was POSTed to /push_subscriptions.
2. Rotate VAPID keys
Generate fresh keys with the Rails runner command. Replace them in both .env.local and chat-server/.env. Restart both servers and verify push still works. (If you only update one side, the subscription fails with a key mismatch.)
cd chat-server
bundle exec rails runner "k = WebPush.generate_key; puts k.public_key; puts k.private_key"
Copy line 1 into NEXT_PUBLIC_VAPID_PUBLIC_KEY (.env.local) and VAPID_PUBLIC_KEY (chat-server/.env). Copy line 2 into VAPID_PRIVATE_KEY (chat-server/.env). Restart both servers. The browser creates a new push subscription on connect because the applicationServerKey changed.
3. Include message count in notifications
Modify PushNotificationService so the body shows how many messages the conversation has.
In chat-server/app/channels/visitor_channel.rb, pass the conversation object:
PushNotificationService.notify(conversation, content) rescue nil
In chat-server/app/services/push_notification_service.rb:
def self.notify(conversation, message_content)
payload = {
title: "New message from #{conversation.visitor_name}",
body: "#{message_content.truncate(100)} (#{conversation.messages.count} messages)",
url: "/admin/chats"
}.to_json
# ... rest unchanged
end
4. Add a notification sound
Modify public/sw-chat.js so the push notification plays the system sound.
Add silent: false to the notification options in the push listener:
self.registration.showNotification(data.title || "New message", {
body: data.body || "",
icon: "/icon-192.png",
badge: "/icon-192.png",
data: { url: data.url || "/admin/chats" },
silent: false,
})
silent: false is the default on most platforms -- notifications already play the system sound unless silent: true is set. Custom sounds via the sound property have inconsistent browser support.