A nearly-universal need in web applications is user notifications. An event happens in the application that the user cares about, and you inform the user of the event. A common example is in applications that have a commenting system — when a user mentions another user in a comment, the application notifies the mentioned user of that comment through email.
The channel(s) used to notify users of new important events depends on the application but the path very often includes an in-app notification widget that shows the user their latest notifications. Other common options include emails, text messages, Slack, and Discord.
Rails developers that need to add a notification system to their application often turn to Noticed. Noticed is a gem that makes it easy to add new, multi-channel notifications to Rails applications.
Today, we are going to take a look at how Noticed works by using Noticed to implement in-app user notifications. We will send those notifications to logged in users in real-time with Turbo Streams and, for extra fun, we will load user notifications inside of a Turbo Frame.
When we are finished, our application will work like this:
Before we begin, this tutorial assumes that you are comfortable building simple Ruby on Rails applications independently. No prior knowledge of Turbo or Noticed is required.
Let’s dive in!
Application setup
To follow along with this tutorial, start by cloning this repository from Github and then set it up:
cd user-notices
bin/setup
The starter repo contains a Rails 7 application with Turbo, Tailwind, and Devise ready to go. The starter repo uses Ruby 3.0.2
, but everything in this tutorial will work fine with Ruby 2.7
and 3.1
, if you prefer.
If you want to work from your own application instead of cloning the starter, you will need a Rails 7 application with Turbo installed and an authentication system built around a User
model.
When you’re ready to start building, start the server and build Tailwind’s css with bin/dev
.
Noticed setup
Our starter application comes with a Devise-powered user model and the root path set to the Dashboard#show
action which just contains links to sign in or sign out for now. Before diving in to the code, create at least one user through the form at http://localhost:3000/users/sign_up so that you can login and test notifications later in this tutorial.
Eventually, our users will be able to create Messages
for other users in the application. Each time a message is a created, a new Notification
will be created, and the user the message is for will see that notification on their dashboard.
Before any of that can happen, we need to add Noticed to our application and scaffold a Message resource. Start by adding Noticed, from your terminal:
bundle add noticed
rails generate noticed:model
rails db:migrate
These commands are straight from the Noticed installation docs. If your Rails app is running, be sure to restart it after adding the Noticed gem to your Gemfile with bundle add
.
Next, update app/models/user.rb
to associate notifications with users:
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_many :notifications, as: :recipient
end
Here, we added the notifications
has_many
association, as directed by the Noticed setup script.
Now our users can receive notifications, but we don’t have anything useful to notify them about. We will fix that by scaffolding a Message
resource. From your terminal:
rails g scaffold message content:text user:references
rails db:migrate
Thanks to the magic of Rails, the scaffold generator gives us almost everything we need to start creating messages and associating them with users. Because we are using Tailwind via the tailwindcss-rails gem, the scaffold generator also includes some nice looking base styles too.
After the generator runs, update the User
model again at app/models/user.rb
:
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_many :messages
has_many :notifications, as: :recipient
end
Here, we added has_many :messages
to set up the other side of the messages relationship.
For convenience, we can also add a link to the messages index page in app/views/dashboard/show.html.erb
:
<div>
<% if user_signed_in? %>
Signed in as <%= current_user.email %>. <%= button_to "Sign out", destroy_user_session_path, method: :delete, class: "text-blue-500" %>
<%= link_to "All messages", messages_path, class: "text-blue-500" %>
<% else %>
<%= link_to "Sign in", new_user_session_path, class: "text-blue-500" %>
<% end %>
</div>
And then make a small adjustment to the messages form so we don’t have to memorize user ids:
<%= form_with(model: message, class: "contents") do |form| %>
<% if message.errors.any? %>
<div id="error_explanation" class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= pluralize(message.errors.count, "error") %> prohibited this message from being saved:</h2>
<ul>
<% message.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="my-5">
<%= form.label :content %>
<%= form.text_area :content, rows: 4, class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.label :user_id %>
<%= form.select :user_id, options_for_select(User.all.pluck(:email, :id)), class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 mt-2 w-full" %>
</div>
<div class="inline">
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
With these changes in place, head to http://localhost:3000/messages and create a couple of messages to ensure that creating messages works as expected.
Now that we have Noticed installed and messages ready to go, next up we will send users notifications when a new message is created.
Message notifications
Our goal in this section is to create and display notifications to logged in users on the Dashboard#show
page.
Step one is to add a new notification, using the the generator built-in to Noticed. From your terminal:
rails generate noticed:notification MessageNotification
This generator creates a new MessageNotification
class at app/notifications/message_notification.rb
. Head there next and make a few small updates:
class MessageNotification < Noticed::Base
deliver_by :database
param :message
def message
params[:message].content
end
def url
message_path(params[:message])
end
end
Here, deliver_by :database
stores the newly created notification in the database, which will be important for Turbo Stream broadcasts later. If we wanted to send the notification by email too, we could add deliver_by :email, mailer: SomeMailer
, as described in the Noticed docs.
param :message
serializes the message
object and stores it with the notification record in the database. We use that serialized message
in the message
and url
methods. Serializing objects into params in this manner makes it easy to access records related to the notification without managing complex references — just dump the record(s) you need into params
and profit.
We can now use our MessageNotification
to send notifications to users when a new message is created. Next we need to put the MessageNotification
class into use. Head to app/models/message.rb
and update it:
class Message < ApplicationRecord
has_noticed_notifications
belongs_to :user
after_create_commit :notify_user
def notify_user
MessageNotification.with(message: self).deliver_later(user)
end
end
Each time a message is created (after_create_commit
), notify_user
runs and creates a new MessageNotification
, serializing the message
object and delivering the message to the message’s user. We also add has_noticed_notifications to ensure that when a message is destroyed, any related notifications are destroyed too.
With those small changes, we now have a database-backed notification system up and running. Neat!
We have notifications in the database now but there is no way for users to see those notifications anywhere, which is not very useful. Next up, we will create a Notifications
controller to display notifications to users.
From your terminal, generate the controller and a partial to render each notification:
rails g controller Notifications index
touch app/views/notifications/_notification.html.erb
Head to config/routes.rb
and add a notifications path helper:
Rails.application.routes.draw do
resources :notifications, only: [:index]
resources :messages
devise_for :users
get 'dashboard/show'
root "dashboard#show"
end
Update the NotificationsController
at app/controllers/notifications_controller.rb
:
class NotificationsController < ApplicationController
def index
@notifications = Notification.where(recipient: current_user)
end
end
Here, we scope notifications
to the current_user
, so users only see their own notifications when logged in to the application.
Update the new Notifications index view at app/views/notifications/index.html.erb
:
<%= turbo_frame_tag "notifications" do %>
<h1 class="font-bold text-4xl">Notifications</h1>
<ul>
<%= render @notifications %>
</ul>
<% end %>
Note the turbo_frame_tag
wrapping the list of notifications. Our plan is to render the list of notifications on the dashboard show page — we will use Turbo Frame’s eager-loading functionality to load the content of the notifications index page on the dashboard show page.
render @notifications
relies on Rails’ collection rendering to render each notification. Before this will work, we need to fill in app/views/notifications/_notification.html.erb
:
<li>
<div>
<p class="text-gray-700">
<%= notification.to_notification.message %>
</p>
<div class="flex justify-between mt-1 text-gray-500 text-sm space-x-4">
<p>
Received on <%= notification.created_at.to_date %>
</p>
<p>
Status: <%= notification.read? ? "Read" : "Unread" %>
</p>
</div>
</div>
</li>
Here, we use to_notification
from Noticed to access the message
method we added in MessageNotification
earlier, and use the built-in read?
method from Noticed to check if the user has read the notification or not.
One last step here before users can see notifications on the dashboard. Head to app/views/dashboard/show.html.erb
and update it to add an eager-loaded Turbo Frame for logged in users:
<div class="flex justify-between">
<% if user_signed_in? %>
<div>
Signed in as <%= current_user.email %>. <%= button_to "Sign out", destroy_user_session_path, method: :delete, class: "text-blue-500" %>
<%= link_to "All messages", messages_path, class: "text-blue-500" %>
</div>
<%= turbo_frame_tag "notifications", src: notifications_path %>
<% else %>
<%= link_to "Sign in", new_user_session_path, class: "text-blue-500" %>
<% end %>
</div>
The turbo_frame_tag
has an id of notifications
, which matches the Turbo Frame rendered by Notifications#index
. When Turbo eager-loads content for a Turbo Frame, it expects the url passed to src
to return a response that includes a Turbo Frame with a matching id.
The sequence of events for our eager-loaded notifications index page is this:
- A logged in user visits
Dashboard#show
-
Dashboard#show
is loaded - Turbo sees the
turbo_frame_tag
with ansrc
attribute and initiates a new request to/notifications
-
Notifications#index
returns HTML that includes aturbo_frame_tag
that matches the tag that initiated the request - Turbo extracts the contents of the
turbo_frame_tag
and uses that content to replace the content of the existing Turbo Frame
At this point, logged in users can see their notifications on the dashboard. Test it out by logging in as a user and then creating a new messages for that user. Refresh the dashboard as that user and see that your notifications are listed on the dashboard:
While our users can see notifications, they don’t see those notifications in real-time — they have to manually refresh the dashboard before they see new notifications. Let’s wrap up this tutorial by making notifications real-time with Turbo Stream broadcasts.
Real-time notifications with Turbo Streams
Turbo model broadcasts, powered by turbo-rails, make it easy to send real-time updates to users with ActionCable. When we finish this section, each time a new notification is created, a Turbo Stream broadcast will be sent over an ActionCable channel that will automatically insert new notifications for a user into the user's dashboard notifications list.
To start, update app/models/notification.rb
to trigger a Turbo Stream broadcast when a new notification is created:
class Notification < ApplicationRecord
include Noticed::Model
belongs_to :recipient, polymorphic: true
after_create_commit :broadcast_to_recipient
def broadcast_to_recipient
broadcast_append_later_to(
recipient,
:notifications,
target: 'notifications-list',
partial: 'notifications/notification',
locals: {
notification: self
}
)
end
end
Here, the broadcast_to_recipient
method appends
a new notification to the list of notifications. To ensure that only the user the notification is for receives the broadcast, we set the broadcast channel to recipient, :notifications
, as described in the turbo-rails
source.
The model update handles sending Turbo Stream broadcasts, but before the broadcast will be picked up by the front end we need to subscribe users to a Turbo Stream channel. We also must ensure the markup includes a notifications-list
id (matching the target
passed to broadcast_append_later_to
) for the Turbo Stream to update.
Starting in app/views/notifications/index.html.erb
, add the notifications-list
id to the ul
containing the list of notifications:
<%= turbo_frame_tag "notifications" do %>
<h1 class="font-bold text-4xl">Notifications</h1>
<ul id="notifications-list">
<%= render @notifications %>
</ul>
<% end %>
And then in app/views/dashboard/show.html.erb
:
<div class="flex justify-between">
<% if user_signed_in? %>
<div>
Signed in as <%= current_user.email %>. <%= button_to "Sign out", destroy_user_session_path, method: :delete, class: "text-blue-500" %>
<%= link_to "All messages", messages_path, class: "text-blue-500" %>
</div>
<%= turbo_stream_from current_user, :notifications %>
<%= turbo_frame_tag "notifications", src: notifications_path %>
<% else %>
<%= link_to "Sign in", new_user_session_path, class: "text-blue-500" %>
<% end %>
</div>
Here, the turbo_stream_from
helper from turbo-rails subscribes the user to a channel that matches the channel our model is broadcasting from. current_user, :notifications
in the view == recipient, :notifications
in the model).
When a user visits the dashboard, the turbo_stream_from
helper opens an ActionCable subscription to the matching channel. Broadcasts to that channel are picked up by Turbo and used to update the page when new broadcasts are received. The scoping of the channel to the current_user
ensures that users do not receive broadcasts intended for another user so that our new message notifications are only sent to the user the message is for.
With these changes in place, login as a user in one browser and head to the dashboard. Confirm the ActionCable channel subscription is created by checking the server logs for a line that looks like this:
Turbo::StreamsChannel is streaming from Z2lkOi8vdXNlci1ub3RpY2VzL1VzZXIvMQ:notifications
In another browser, head to the new messages form and create a message, setting the user on the form to the user that you are logged in as in the first browser. If all has gone well, the new notification will be added to the list instantly, with no page updates required.
Great work following along with this tutorial, that is all of the code for the day!
Wrapping up & further reading
Today we built a simple notification system with Rails and Noticed, and we used Turbo to display new notifications for users in real-time. Noticed is an extremely powerful gem that makes the work of building and expanding a multi-channel notification system in a Rails app much simpler, and the easy integration with Turbo Streams makes it a great match for any modern Rails application.
In our tutorial application, we displayed notifications as a static list of every notification the user has ever received, with no way to interact with them or remove them from the list. We also required users to be on their dashboard to see the new notification.
In a production application, we might expand our Notifications controller to include an update
method that allows users to mark notifications as read and clear those notifications from the list.
We would would also likely build a notification indicator in the main navigation that is rendered on every page of our application with an icon indicating unread notifications (think the ubiquitous bell icon seen across thousands of web applications).
The neat thing about this tutorial’s approach is that the basic approach remains the same even for a more sophisticated implementation. Use Turbo Streams to broadcast new notifications to users and update the UI in real-time. Use built-in Noticed methods to act on notifications (like mark_as_read!
to read a notification). Use a Turbo Frame to fetch notifications for a user and load those notifications into a section of a larger page.
For further learning on Noticed, Turbo Streams, and Turbo Frames:
- Check out the GoRails tutorial on Noticed (Chris from GoRails wrote Noticed, thanks Chris!)
- Read the simple but effective Noticed docs, starting in the repo’s readme and digging in to specific delivery methods as needed
- Solidify Turbo concepts with my Turbo 101 article, and my Turbo Frames and Turbo Streams on Rails articles, for more on how to use Turbo with Rails
Finally, if you want to dig deeper into implementing notifications in a Rails application, a chapter of my book is dedicated to building a very light notification system from scratch, with inspiration from Noticed. In the book, we add real-time updates in a more realistic way, with the standard bell icon, pop-out notification list, and the ability to mark notifications as read with a click. The book is written in this same, step-by-step tutorial style, and covers building a modern Rails application from scratch with StimulusReflex, CableReady, Hotwire, and friends.
Building your own notification system is a great exercise if you want to use Noticed in a real production application later, since you will have a much greater understanding of, and appreciation for, what Noticed offers you.
That’s all for today. As always, thanks for reading!
Top comments (3)
Hi David!!
I think it's an absolutely brilliant post. I love how you present everything, how easy everything seems, but I know that behind all this there are many work's hour 🙌
Thank you for sharing excellent quality content 🙏
Thanks, Sergio, that's very kind of you to say!
If you enjoyed this post, you might enjoy my monthly newsletter on all things modern Rails: getrevue.co/profile/hotwiringrails