User notifications with Rails, Noticed, and Hotwire

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:

A screen recording of two web browsers open side by side. In one, a user fills out a message into a web form and submits it. In the other, the message the user wrote appears under a Notifications heading after the form is submitted.

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 an src attribute and initiates a new request to /notifications
  • Notifications#index returns HTML that includes a turbo_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:

A screenshot of a web page open to a list of notifications under a Notifications heading.

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.

A screen recording of two web browsers open side by side. In one, a user fills out a message into a web form and submits it. In the other, the message the user wrote appears under a Notifications heading after the form is submitted.

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:

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!