Building a Chat: Chapter 1 — Architecture and Setup
Tony Duong
May 31, 2026 ・ 7 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.
This tutorial walks through a real-time chat feature built for a Next.js blog. Visitors open a chat widget and talk to the site admin. The admin sees every conversation in a dashboard. Messages arrive instantly in both directions.
The interesting part: the frontend is a Next.js 16 app, the backend is a Rails 8 API. They live in separate processes, on separate ports, connected by WebSockets. This chapter explains how the pieces fit together and gets both servers running on your machine.
Two separate apps
The project has two codebases:
| Frontend | Backend | |
|---|---|---|
| Framework | Next.js 16 (App Router) | Rails 8 (API-only) |
| Path | / (repo root) |
chat-server/ (git submodule) |
| Port | 3000 | 3100 |
| Deploys to | Vercel | Kamal 2 (Docker) |
| Talks via | @rails/actioncable (WebSocket) + fetch (HTTP) |
ActionCable + REST controllers |
The frontend handles everything the visitor sees: the blog, the floating chat bubble, the admin dashboard. The backend handles everything real-time: WebSocket connections, message persistence, push notifications.
Four concepts you need
1. WebSocket vs HTTP
HTTP is request-response: the client asks, the server answers, the connection closes. WebSocket is a persistent bidirectional pipe -- either side can send data at any time without the other asking.
This project uses WebSocket for all messaging in both directions. HTTP is only used for three things: admin login, listing/deleting conversations, and registering push subscriptions.
2. ActionCable
ActionCable is Rails' built-in WebSocket framework. It has three layers:
- Connection -- authenticates the WebSocket upgrade request. Lives in
chat-server/app/channels/application_cable/connection.rb. This codebase identifies two kinds of connections usingidentified_by:
identified_by :visitor_token, :admin
- Channel -- scopes what a connection can do. This codebase has two:
VisitorChannel(one per browser session) andAdminChannel(one for all conversations). - Consumer -- the client-side counterpart, provided by the
@rails/actioncablenpm package. The functioncreateConsumer(url)opens the WebSocket and returns an object you callsubscriptions.create()on.
3. Solid Cable
Traditional ActionCable deployments need Redis for pub/sub. Rails 8 ships Solid Cable as the default adapter -- it stores pub/sub messages in SQLite instead.
The config is minimal. From chat-server/config/cable.yml:
development:
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.day
The trade-off: no Redis process to manage, but no horizontal scaling either. One Puma process, one SQLite file. For a personal blog this is the right call.
4. Cross-origin authentication
The frontend runs on localhost:3000, the backend on localhost:3100. Browsers block cross-origin cookies by default, so two things are configured:
Server side -- chat-server/config/initializers/cors.rb allows the frontend origin with credentials: true:
origins(*ENV.fetch("ALLOWED_ORIGINS", "http://localhost:3000").split(",").map(&:strip))
resource "*", headers: :any, methods: [...], credentials: true
Client side -- every fetch call in src/lib/chat-client.ts sets credentials: "include" so the browser sends cookies cross-origin.
Admin auth uses Rails session cookies. Visitor auth is simpler -- a UUID token passed as a query parameter on the WebSocket URL:
visitorConsumer = createConsumer(`${WS_URL}?token=${sessionToken}`);
Repo layout
src/
components/
ChatWidget.tsx # Floating bubble for visitors
ChatPanel.tsx # Shared message display component
AdminChat.tsx # Admin dashboard with conversation list
lib/
chat-client.ts # ActionCable consumer factory + HTTP helpers
chat-types.ts # TypeScript discriminated unions for channel data
types/
actioncable.d.ts # Hand-written type declarations for @rails/actioncable
app/
admin/
login/page.tsx # Credential form -> POST /auth/login
chats/page.tsx # Renders AdminChat component
chat-server/
app/
channels/
application_cable/
connection.rb # Dual auth: visitor token OR admin session
visitor_channel.rb # One channel per browser session
admin_channel.rb # One channel for all conversations
controllers/
auth_controller.rb # ENV-based login, sets session[:admin]
conversations_controller.rb
push_subscriptions_controller.rb
models/
conversation.rb # session_token + visitor_name + unread_count
message.rb # sender (visitor|admin) + content
push_subscription.rb # Web Push subscription data
services/
push_notification_service.rb
config/
cable.yml # Solid Cable adapter config
puma.rb # Port 3100, single mode
routes.rb # ActionCable mounted at /cable
The admin section at src/app/admin/ sits outside the i18n [locale] route group. It has its own layout.tsx with <html> and <body> tags -- it is a completely separate page shell from the blog.
What happens when the app runs
Terminal 1 -- start the frontend:
npm run dev
Next.js starts on port 3000. The two environment variables that matter are in .env.local:
NEXT_PUBLIC_CHAT_WS_URL=ws://localhost:3100/cable
NEXT_PUBLIC_CHAT_HTTP_URL=http://localhost:3100
Terminal 2 -- start the backend:
cd chat-server
ADMIN_USER=tony ADMIN_PASSWORD=secret bin/rails server -p 3100
Puma starts on port 3100 (configured in config/puma.rb via port ENV.fetch("PORT", 3100)). ActionCable is mounted at /cable in config/routes.rb:
mount ActionCable.server => "/cable"
Rails is configured as API-only (config.api_only = true in config/application.rb), which strips session middleware. But admin auth needs sessions, so two lines add it back:
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore, key: "_chat_server_session"
When a visitor opens the chat widget, the frontend calls getVisitorConsumer(sessionToken) in src/lib/chat-client.ts, which calls createConsumer() with the WebSocket URL plus a ?token= query parameter. The browser opens a WebSocket to ws://localhost:3100/cable?token=<uuid>. Rails' Connection#connect method checks request.params[:token], finds it present, and accepts the connection.
Setup
Prerequisites: Node.js 18+, Ruby 3.2+, SQLite3.
# Frontend dependencies
npm install
# Backend dependencies
cd chat-server
bundle install
bin/rails db:prepare
# Environment files
cd ..
cp .env.example .env
cp .env.local.example .env.local
Edit .env.local to set NEXT_PUBLIC_CHAT_WS_URL and NEXT_PUBLIC_CHAT_HTTP_URL if the defaults don't match your setup. Then start both servers in separate terminals.
Now that you can see the two halves running, chapter 2 dives into the Rails backend -- how ActionCable authenticates connections, what happens inside VisitorChannel and AdminChannel, and how messages flow from one browser to another through Solid Cable.
Try it out
Exercise 1. Start both servers. Open your browser's DevTools, go to the Network tab, and filter by "WS". Open the chat widget on the blog. You should see a WebSocket connection to ws://localhost:3100/cable?token=.... Inspect the frames -- what is the first message the server sends after the handshake?
The first message is a JSON object with {"type":"welcome"}. This is ActionCable's handshake confirmation. After that, the client sends a subscribe command for VisitorChannel, and the server responds with {"type":"confirm_subscription",...}.
Exercise 2. In chat-server/config/puma.rb, change the default port from 3100 to 3200. Restart the Rails server. What breaks, and how do you fix it?
The chat widget stops connecting. The frontend is still pointing at ws://localhost:3100/cable. To fix it, update both env vars in .env.local:
NEXT_PUBLIC_CHAT_WS_URL=ws://localhost:3200/cable
NEXT_PUBLIC_CHAT_HTTP_URL=http://localhost:3200
You also need to update ALLOWED_ORIGINS on the Rails side (or its default of http://localhost:3000 still works since the frontend origin hasn't changed -- only the backend port moved). Restart both servers.
Exercise 3. In chat-server/config/application.rb, comment out the two config.middleware.use lines that add Cookies and Session::CookieStore. Restart Rails. Try logging in as admin at /admin/login. What happens and why?
The login POST to /auth/login succeeds (returns { "ok": true }), but the session cookie is never set because the cookie middleware is gone. When the frontend then opens a WebSocket for the admin, Connection#connect checks request.session[:admin] -- which is nil because no session exists. The connection is rejected. The admin dashboard shows a disconnected state. Uncomment the lines and restart to fix it.
Exercise 4. Read the VisitorChannelData type in src/lib/chat-types.ts. It is a discriminated union on the type field. Add a hypothetical fourth variant { type: "typing"; is_typing: boolean } to the union. Where in the frontend code would you need to handle this new variant?
The type is consumed in the received callback passed to subscribeVisitorChannel() in src/lib/chat-client.ts. Every component that calls this function handles the data in its received callback -- primarily ChatWidget.tsx. You would add a case "typing": branch (or if (data.type === "typing")) in the switch/conditional that processes incoming messages, and update the UI to show a typing indicator.