Sooner or later every Rails app needs a place for people to talk about a record. A note on an order. A back-and-forth on a support ticket. A running discussion pinned to a project. The feature request always sounds the same ("can we add comments here?") and the tutorials always answer it the same way: a comments table, a polymorphic commentable, a list rendered under the record. That answer is correct, and for a plain guestbook it is all you need.
It stops being enough the moment someone expects the thing to behave like a conversation. They want their comment to show up for the other person without a refresh. They want it to read like a chat, grouped by author and by day, not like a wall of identical rows. And a few weeks later someone asks to see "who changed the status" in the same feed, and the tidy comments table you built has no room for it.
This guide builds the version that holds up. We add comments to any model, render them as a live conversation with a chat box, and push new messages to everyone watching in real time with Turbo Streams. We will make one data-model decision up front that costs nothing today and saves a rewrite later. This is the same pattern behind the Collaboration add-on we build for Avo, so along the way I will point out the one gotcha that only shows up once real users are on it.
Why a timeline, not a comments table
Here is the decision that separates a conversation feature from a comment box, and it is the first line of code, not the last.
The default tutorial gives every commentable model a has_many :comments. It works until the feature grows. The day product wants "Adrian changed the status from Draft to Published" to appear inline with the comments, you are stuck: activity isn't a comment, so it doesn't fit the table, and now you are rendering two separate lists and trying to merge and paginate them by created_at in Ruby. The day after that, someone wants emoji reactions on individual comments, and you are adding a third table and a third render path.
We hit exactly this when we built collaboration for Avo. The clean way out is a pattern Rails ships for precisely this shape of problem: delegated types. It is how Basecamp models everything in a thread. You have one concrete table, entries, that owns the shared columns every item in the feed needs (who, when, on what). Each entry then delegates to a small specialized record for its own fields: a Comment has a body, an Action has an old and new value, a Reaction has an emoji. One ordered, paginated feed; many kinds of thing inside it.
We are only building comments today, so we will define just the Comment type. But because the feed is a list of Entry records rather than a list of Comment records, adding an activity log or reactions later is a new entryable and a new partial, not a rewrite of everything that reads the timeline. That is the whole payoff, and it costs one extra table now.
If you have reached for single table inheritance before and been burned by a wide table full of mostly-null columns, delegated types is the escape hatch: the specialized fields live in their own tables, so nothing is null and nothing is shared that shouldn't be.
The migrations
Two tables. One for the entries (the timeline), one for the comment bodies.
# db/migrate/xxxx_create_entries.rb
class CreateEntries < ActiveRecord::Migration[7.2]
def change
create_table :entries do |t|
t.references :entryable, polymorphic: true, null: false
t.references :target, polymorphic: true, null: false
t.references :author, polymorphic: true
t.timestamps
end
end
end
# db/migrate/xxxx_create_comments.rb
class CreateComments < ActiveRecord::Migration[7.2]
def change
create_table :comments do |t|
t.text :body, null: false
t.timestamps
end
end
end
Three polymorphic references on entries, each doing a distinct job:
-
entryableis the delegated type. It points at theComment(and, later, theActionorReaction) that holds this entry's own data. -
targetis the record being discussed: aProject, anOrder, aTicket. This is what makes the feature work on any model without a migration per model. -
authoris who wrote it, left nullable so a system-generated activity entry can have no author.
t.references ... polymorphic: true gives you the _type and _id column pair that both delegated types and polymorphic associations need.
The models
The Entry is the heart of it. delegated_type wires the entryable side; two polymorphic belongs_tos wire the target and author.
# app/models/entry.rb
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[ Comment ], dependent: :destroy
delegate :body, to: :entryable
belongs_to :target, polymorphic: true
belongs_to :author, polymorphic: true, optional: true
end
That delegated_type :entryable, types: %w[ Comment ] line does a lot. It gives you entry.comment? to ask what kind of entry this is, entry.comment to reach the underlying record, and Entry.comments as a scope. When you add the next type, you extend the types array and get the same helpers for free. The delegate :body, to: :entryable lets the timeline call entry.body without caring whether the body lives on a comment or somewhere else.
The Comment is deliberately boring. It holds a body and knows it belongs to an entry. We put the entry wiring in a small concern so every future entryable (the activity action, the reaction) can include it and get the same relationship:
# app/models/concerns/entryable.rb
module Entryable
extend ActiveSupport::Concern
included do
has_one :entry, as: :entryable, touch: true
delegate :author, to: :entry
end
end
# app/models/comment.rb
class Comment < ApplicationRecord
include Entryable
end
The touch: true is a small quality-of-life touch: editing a comment bumps its entry's updated_at, which is handy for cache keys and "recently active" sorting.
Make any model commentable
The point of the polymorphic target is that turning a model into something people can discuss is one line. Add a concern:
# app/models/concerns/commentable.rb
module Commentable
extend ActiveSupport::Concern
included do
has_many :entries, as: :target, dependent: :destroy
end
end
# app/models/project.rb
class Project < ApplicationRecord
include Commentable
end
Now project.entries is the conversation on that project, and Order, Ticket, or anything else becomes commentable by including the same concern. No new table, no new foreign key. Creating a comment is creating an entry with a comment inside it:
project.entries.create!(
author: Current.user,
entryable: Comment.new(body: "Shipping this on Friday.")
)
Show the conversation
A controller to load the record and its feed. The one thing to get right here is the query: because every entry delegates to an entryable, rendering the feed naively runs a query per entry to load each body. includes(:entryable) loads them in one pass and keeps the N+1 away.
# app/controllers/projects_controller.rb
def show
@project = Project.find(params[:id])
@entries = @project.entries.includes(:entryable).order(:created_at)
end
The view has two parts: the stream of entries and, below it, the box to add one. We render each entry through a partial so the same markup is reused when we broadcast later.
<%# app/views/projects/show.html.erb %>
<div class="conversation">
<div id="<%= dom_id(@project, :entries) %>" class="entries">
<%= render @entries %>
</div>
<%= render "entries/form", target: @project %>
</div>
<%# app/views/entries/_entry.html.erb %>
<div id="<%= dom_id(entry) %>" class="entry">
<div class="entry__author"><%= entry.author&.name || "Unknown" %></div>
<div class="entry__body"><%= entry.body %></div>
<time class="entry__time"><%= entry.created_at.strftime("%-l:%M %p") %></time>
</div>
render @entries uses the entries/_entry partial for each one. The dom_id helpers give us stable ids we will reuse for real-time updates in a moment.
The comment box
The form posts to an entries controller, carrying which record it belongs to. We pass the target as a type and id pair so the same form works on a project, an order, or a ticket.
<%# app/views/entries/_form.html.erb %>
<%= form_with url: entries_path, class: "chat-input" do |form| %>
<%= form.hidden_field :target_type, value: target.class.name %>
<%= form.hidden_field :target_id, value: target.id %>
<%= form.text_area :body, placeholder: "Write a message...", rows: 1,
data: { action: "keydown.enter->chat#submit" } %>
<%= form.submit "Send" %>
<% end %>
# app/controllers/entries_controller.rb
class EntriesController < ApplicationController
def create
target = params[:target_type].constantize.find(params[:target_id])
target.entries.create!(
author: Current.user,
entryable: Comment.new(body: params[:body])
)
redirect_back fallback_location: root_path
end
end
Constantizing a class name from a form parameter deserves a word of caution: only ever do it against an allowlist of models you expect. A bare params[:target_type].constantize will happily try to load any class name an attacker sends. In a real app, check it against your set of commentable models before calling find.
At this point you have working comments on any model. Post a message, the page reloads, the message is there. Now we make it feel alive.
Make it live with Turbo Streams
Two moving parts turn this into real time, and Rails ships both. The page subscribes to a stream; the model broadcasts to that stream when a new entry is created. Everyone subscribed sees the new message appear, no refresh, no custom JavaScript.
First, subscribe the page. turbo_stream_from opens an Action Cable connection scoped to a stream name. We scope it to the record so each conversation is its own channel:
<%# app/views/projects/show.html.erb, at the top %>
<%= turbo_stream_from @project, :entries %>
Then broadcast when an entry is created. The instinct is to reach for broadcast_append_to with the rendered partial, and this is where the one real trap lives, so read the next section before you ship it.
# app/models/entry.rb
class Entry < ApplicationRecord
# ...
after_create_commit :broadcast_to_timeline
private
def broadcast_to_timeline
broadcast_append_to(
[ target, :entries ],
target: ActionView::RecordIdentifier.dom_id(target, :entries),
partial: "entries/entry",
locals: { entry: self }
)
end
end
We broadcast on after_create_commit, not after_create, on purpose. The commit callback fires after the database transaction has actually committed, so the record you are broadcasting is guaranteed to exist when a subscriber's browser turns around and asks for it. Broadcasting inside the transaction is a classic source of "the comment flashed in and then 404ed."
The first argument, [ target, :entries ], has to match the stream you subscribed to with turbo_stream_from @project, :entries, or the message goes to a channel nobody is listening on. The target: is the DOM id of the container to append into. broadcast_append_to uses the append Turbo Stream action, one of the set (append, prepend, replace, update, remove) that describe how the pushed HTML changes the page.
The gotcha: broadcast a frame, not a person's view
The version above broadcasts the rendered partial. It works in a demo and breaks in production, and it is worth understanding exactly why, because no comment tutorial mentions it.
When you broadcast rendered HTML, Rails renders that partial once, on the server, as whoever's action triggered the broadcast. Then it ships those identical bytes to every subscriber. As long as the partial looks the same to everyone, fine. The moment it contains anything viewer-specific, it breaks: a delete button shown only on your own comments, a reaction highlighted because you reacted, a timestamp in the viewer's time zone. Whatever the triggering user saw, everyone now sees. We watched this happen with reactions in Avo: the person who added the reaction rendered the "active" highlight, and that highlighted-for-them HTML got pushed to everyone, so the whole team looked like they had reacted.
The fix is to not render the final HTML at broadcast time at all. Broadcast a lazy turbo_frame_tag whose src points back at the entry. Each client receives the same tiny frame, and each client's browser then fetches and renders the entry as itself, with its own session and its own Current.user:
def broadcast_to_timeline
broadcast_append_to(
[ target, :entries ],
target: ActionView::RecordIdentifier.dom_id(target, :entries),
html: ActionController::Base.helpers.turbo_frame_tag(
ActionView::RecordIdentifier.dom_id(self),
src: entry_path(self),
loading: "lazy"
)
)
end
That entry_path renders the same entries/_entry partial inside a matching turbo_frame_tag, but now in a real per-user request. Everyone gets the message instantly; everyone gets their own version of it. The cost is one extra lightweight request per client, which is a fair price for not leaking one user's interface state to the whole room. If your entry partial genuinely has zero per-viewer state, the simpler rendered-partial broadcast is fine. The instant it grows a "delete" button or a "you reacted" state, reach for the frame.
Make it feel like a conversation
A live list of messages is not yet a chat. Two small touches close most of the gap, and both live in how you group the entries before rendering, not in more models.
Group consecutive messages from the same author. In a chat, ten messages you send in a row show your name and avatar once, not ten times. Walk the feed and mark an entry as "grouped" when it shares an author with the one before it and lands within a few minutes:
# a view helper or presenter
GROUP_WINDOW = 5.minutes
def grouped?(entry, previous)
previous &&
previous.author_id == entry.author_id &&
entry.created_at - previous.created_at <= GROUP_WINDOW
end
A grouped entry hides the author header and renders tight against the one above it. It is the single change that most makes a list read like a conversation.
Break the feed by day. Insert a "Today", "Yesterday", or dated separator whenever the calendar date changes between two entries. Both of these are pure presentation over the same ordered @entries, which is exactly why the timeline being a flat, ordered list of entries pays off.
For the input itself, the expectation from every chat app is Enter to send and Shift+Enter for a newline. That is the keydown.enter->chat#submit action we wired on the textarea earlier, backed by a one-method Stimulus controller that calls requestSubmit() unless Shift is held. Small detail, but its absence is the first thing that makes a comment box feel like a form and not a chat.
Summary and best practices
You now have comments on any Rails model that behave like a live conversation:
-
Delegated types over a comments table. One
Entrytimeline that you can extend to an activity log or reactions later without rewriting a thing. This is the decision that ages well. -
Polymorphic
targetandauthor. Any model becomes commentable with a one-line concern; any user model can author. -
includes(:entryable)on the feed query so rendering the timeline stays one query, not one per message. -
turbo_stream_fromplusafter_create_commitfor real time. Commit callbacks, not create callbacks, so you never broadcast a record that isn't saved yet. - Broadcast a lazy frame, not rendered HTML, the moment an entry has any per-viewer state. This is the bug you would otherwise ship and only find in a team demo.
-
Guard
constantize. Never turn a raw request parameter into a class without an allowlist.
And a few things we deliberately left out that a production version wants: authorization (who is allowed to read this conversation, and who can delete a message that isn't theirs), notifications to people watching the record, pagination once a thread runs long, and rich text or attachments in the body. None are hard on their own. Together they are the difference between a demo and a feature you can hand to real users.
Or don't build it: the Collaboration add-on
Everything above is a good afternoon's work, and if you are on Rails you can absolutely build it. The honest question is whether you want to own it after today.
Because the list of "things we left out" is where the real time goes. Authorization on every read and write. Deciding who can delete whose message. Emoji reactions, with the exact broadcast-identity bug we just walked through waiting for whoever adds them. The activity log that started this whole design. Editing, notifications, the empty state, the awkward inputs. That is not an afternoon; that is the feature owning a slice of your roadmap for as long as the app lives, and every hour of it is an hour off the product your users actually pay for.
That is precisely why we built it once, properly, as an Avo add-on. If your admin runs on Avo, the Collaboration add-on drops this conversation onto any resource: comments, the activity log, and emoji reactions in one real-time timeline, wired into Avo's authorization so who-can-see and who-can-delete are already handled the way the rest of your admin handles them. The broadcast gotcha, the grouping, the date separators, the send-on-enter chat box: all of it is done, reviewed by humans, and used across many apps rather than debugged in yours. It is our code in our back yard, so when something needs fixing, we fix it for everyone. See it and the rest of the add-ons if you would rather ship the conversation than maintain it.
Build it to understand it. Buy it so you never have to think about the broadcast identity bug again.
Have a good one and happy coding!