Back to Tutorials
Tutorial11/5/2025

Mailstify #3 - Go Dynamic! Building Real-Time Audience Lists (The Hotwire Way)

Mailstify #3 - Go Dynamic! Building Real-Time Audience Lists (The Hotwire Way)

Part 3: Dynamic Audience Management with Hotwire

In Part 2, we secured our Mailstify app with authentication and multi-tenancy, ensuring every list, campaign, and subscriber belongs to the right user.

Now that our foundation is rock solid, it’s time to make things interactive and alive.

In this chapter, we’ll use Hotwire (Turbo Frames and Turbo Streams) to build a real-time audience management system, allowing users to add or remove subscribers instantly without writing any custom JavaScript.

Mailstify Table of Contents

This installment will cover setting up a modern look using a simple CSS framework and implementing a crucial Mailchimp feature: instantly adding a new subscriber to a list using an asynchronous Turbo Stream response.


1. Initial Styling: Setting up Tailwind CSS

To move beyond the default scaffold appearance, we'll use Tailwind CSS via a CDN for fast setup. This is a common practice for quickly styling Rails applications without needing a complex build system.

Updating the Layout

Modify the main application layout file (app/views/layouts/application.html.erb) to include the Tailwind CDN, a basic responsive meta tag, and some initial styling for the page structure.

<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <!-- Tailwind CSS CDN for quick styling -->
    <script src="https://cdn.tailwindcss.com"></script>
  </head>

  <body class="bg-gray-50 min-h-screen">
    <div class="container mx-auto p-4 md:p-8">
      <header class="mb-8 border-b pb-4 flex items-center justify-between">
        <div>
          <h1 class="text-4xl font-extrabold text-blue-800">Mailstify</h1>
          <p class="text-gray-500">A Zero-Dependency Mailchimp Clone</p>
        </div>
        <% if Current.user %>
          <%= link_to "Sign out", session_path, data: { turbo_method: :delete },
              class: "text-sm text-gray-600 hover:text-gray-800" %>
        <% end %>
      </header>

      <main>
        <div class="max-w-7xl mx-auto">
          <%= yield %>
        </div>
      </main>
    </div>
  </body>
</html>

Restart the server (rails s). The app now has a more modern look.

mailstify-3-1.png
mailstify-3-1.png

If any scaffolded forms still look plain, you can style them easily using Tailwind classes, we’ll style a few forms next.


2. Refining the Audience Dashboard (lists#index)

The scaffolded lists#index is functional but basic. Let’s refine it and display a subscriber count to make it look like a real Mailchimp-style audience dashboard.

Notes: When copying and pasting, ensure you remove the surrounding comments (`` or # ...) from the code blocks, otherwise they may cause unexpected rendering issues on your page.

Updating the Index View

We need to display the list name and the count of associated subscribers.

app/views/lists/index.html.erb

<div class="flex justify-between items-center mb-6">
  <h2 class="text-3xl font-bold text-gray-800">Your Audience Lists</h2>
  <%= link_to "New List", new_list_path, class: "bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded shadow transition duration-150" %>
</div>

<div class="bg-white shadow-md rounded-lg overflow-hidden">
  <table class="min-w-full divide-y divide-gray-200">
    <thead class="bg-gray-50">
      <tr>
        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">List Name</th>
        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Subscribers</th>
        <th class="px-6 py-3"></th>
      </tr>
    </thead>
    <tbody class="divide-y divide-gray-200">
      <% @lists.each do |list| %>
        <tr class="hover:bg-gray-50">
          <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= list.name %></td>
          <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= list.subscribers.count %></td>
          <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
            <%= link_to "View", list, class: "text-blue-600 hover:text-blue-900 mr-4" %>
            <%= link_to "Edit", edit_list_path(list), class: "text-yellow-600 hover:text-yellow-900 mr-4" %>
            <%= link_to "Destroy", list, data: { turbo_method: :delete, turbo_confirm: "Are you sure?" }, class: "text-red-600 hover:text-red-900" %>
          </td>
        </tr>
      <% end %>
    </tbody>
  </table>
</div>

mailstify-3-2.png
mailstify-3-2.png


3. The List Detail View (lists#show)

The List Detail View will be the central hub for managing subscribers. We will use Turbo Frames to separate the "New Subscriber Form" area from the "Subscriber List" area. This isolation is key for Hotwire interactions.

A. The List Show View

This view defines the container for our dynamic parts:

app/views/lists/show.html.erb

<h2 class="text-3xl font-bold text-gray-800 mb-6">Audience: <%= @list.name %></h2>

<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
  <!-- Column 1: New Subscriber Form -->
  <div class="lg:col-span-1">
    <div class="bg-white p-6 shadow-md rounded-lg">
      <h3 class="text-xl font-semibold mb-4 text-gray-700">Add New Subscriber</h3>
      <%= turbo_frame_tag "new_subscriber_form" do %>
        <%= render 'subscribers/form', list: @list, subscriber: Subscriber.new %>
      <% end %>
    </div>
  </div>

  <!-- Column 2: Subscriber List -->
  <div class="lg:col-span-2">
    <div class="bg-white p-6 shadow-md rounded-lg">
      <div class="flex justify-between items-center mb-4">
        <h3 class="text-xl font-semibold text-gray-700">
          Subscribers (<span id="subscriber-count"><%= @list.subscribers.count %></span>)
        </h3>
        <%= link_to "New Campaign", new_campaign_path(list_id: @list.id), class: "text-sm text-blue-600 hover:underline" %>
      </div>

      <%= turbo_frame_tag "subscribers_list" do %>
        <%= render 'subscribers/subscribers_table', list: @list, subscribers: @list.subscribers.order(created_at: :desc) %>
      <% end %>
    </div>
  </div>
</div>

<%= link_to "Back to Lists", lists_path, class: "mt-8 inline-block text-blue-600 hover:text-blue-800" %>

B. Subscriber Table Partial

This partial renders the table structure and iterates over the subscribers. Note the use of id: "subscriber_#{@list.id}" on the table body—this is the target for Turbo Stream appends.

Starting by create a new file app/views/subscribers/_subscribers_table.html.erb and paste the below code.

<div class="overflow-x-auto">
  <table class="min-w-full divide-y divide-gray-200">
    <thead class="bg-gray-50">
      <tr>
        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
        <th class="px-6 py-3"></th>
      </tr>
    </thead>
    <!-- CRITICAL ID: The target for turbo_stream.prepend in the controller -->
    <tbody id="<%= dom_id(list, :subscribers) %>" class="divide-y divide-gray-200">
      <% if subscribers.empty? %>
        <%= render "subscribers/empty_state", list: list %>
      <% else %>
	      <!-- Renders each subscriber using the _subscriber.html.erb partial -->
        <%= render subscribers %>
      <% end %>
    </tbody>
  </table>
</div>

C. Subscriber Row Partial

This partial renders a single subscriber row. The turbo_stream format relies on the convention that objects render themselves via a partial named after the object (app/views/subscribers/_subscriber.html.erb).

<tr id="<%= dom_id(subscriber) %>" class="hover:bg-gray-50">
  <td class="px-6 py-4 text-sm font-medium text-gray-900"><%= subscriber.name %></td>
  <td class="px-6 py-4 text-sm text-gray-500"><%= subscriber.email %></td>
  <td class="px-6 py-4 text-right text-sm font-medium">
	  <!-- Turbo Drive handles this action asynchronously -->
    <%= link_to "Remove",
                subscriber,
                data: { turbo_method: :delete, turbo_confirm: "Remove #{subscriber.email}?" },
                class: "text-red-600 hover:text-red-800" %>
  </td>
</tr>

D. Empty State

The partial to render empty list.

app/views/subscribers/_empty_state.html.erb

<tr id="<%= dom_id(list, :empty_state) %>">
  <td colspan="3" class="px-6 py-4 text-center text-sm text-gray-500">
    No subscribers found for this list.
  </td>
</tr>

E. New Subscriber Form Partial

This is the standard Rails form, but crucially, it's rendered inside the new_subscriber_form Turbo Frame and targets the subscribers_list frame on submission.

app/views/subscribers/_form.html.erb

<%= form_with(model: subscriber, url: subscribers_path, class: "space-y-4") do |form| %>
	<!-- Hidden field to link the subscriber back to the current list -->
  <%= form.hidden_field :list_id, value: list.id %>

  <% if subscriber.errors.any? %>
    <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
      <p class="font-bold">Errors preventing submission:</p>
      <ul class="list-disc list-inside mt-1">
        <% subscriber.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
    <%= form.text_field :name, class: "mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" %>
  </div>

  <div>
    <%= form.label :email, class: "block text-sm font-medium text-gray-700" %>
    <%= form.email_field :email, required: true, class: "mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500" %>
  </div>

  <div class="flex justify-end">
    <%= form.submit "Subscribe", class: "bg-green-600 hover:bg-green-700 text-white font-semibold py-2 px-4 rounded shadow transition duration-150 cursor-pointer" %>
  </div>
<% end %>

After you implemented all the forms above, you will notice that in the app/views/lists/show.html.erb. This line <%= link_to "New Campaign", new_campaign_path(list_id: @list.id), class: "text-sm text-blue-600 hover:underline" %> will cause an error. Because we haven't properly set the campaign controller and routes yet.

In /config/routes.rb, add routes for campaigns model.

Rails.application.routes.draw do
  resource :session
  resources :passwords, param: :token
  resources :subscribers
  resources :lists
  resources :users, only: [:new, :create]
  resources :campaigns  # added in Part 1 for data model; not used yet
  root "lists#index"
end

Also, run this command in the terminal

rails g controller Campaigns new

and modify app/controllers/campaigns_controller.rb

class CampaignsController < ApplicationController
	before_action :set_list, only: :new
	
	def new
	@campaign = Campaign.new
	end
	
	private
	
	def set_list
	@list = Current.user.lists.find_by(id: params[:list_id])
	redirect_to lists_path, alert: "List not found or unauthorized." unless @list
	end
end

Refresh your page or server and see the result

mailstify-3-3.png
mailstify-3-3.png


4. Dynamic Creation with Turbo Streams (subscribers#create)

The most important part of this dynamic interaction is updating the SubscribersController to handle the create action using Turbo Streams. Instead of a standard redirect, we will send back a response that tells the browser exactly what HTML to inject and where.

A. Updating the Subscribers Controller

We only need to modify the create and destroy actions. We will use the built-in respond_to block to handle HTML (for non-JS fallback) and turbo_stream (for the dynamic update).

app/controllers/subscribers_controller.rb

class SubscribersController < ApplicationController
before_action :set_subscriber, only: :destroy

	# ... the rest of the code
  def create
    list = Current.user.lists.find(subscriber_params[:list_id])
    @subscriber = list.subscribers.new(subscriber_params.except(:list_id))

    respond_to do |format|
      if @subscriber.save
        streams = [
          turbo_stream.prepend(
            helpers.dom_id(list, :subscribers),
            @subscriber
          ),
          turbo_stream.replace(
            "new_subscriber_form",
            partial: "subscribers/form",
            locals: { list: list, subscriber: Subscriber.new }
          ),
          turbo_stream.update("subscriber-count", list.subscribers.count)
        ]

        # If this is the FIRST subscriber, remove the empty-state row
        if list.subscribers.count == 1
          streams << turbo_stream.remove(helpers.dom_id(list, :empty_state))
        end

        format.turbo_stream { render turbo_stream: streams }
        format.html { redirect_to list_url(list), notice: "Subscriber was successfully created." }
      else
        format.turbo_stream do
          render turbo_stream: turbo_stream.replace(
            "new_subscriber_form",
            partial: "subscribers/form",
            locals: { list: list, subscriber: @subscriber }
          ), status: :unprocessable_entity
        end
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  rescue ActiveRecord::RecordNotFound
    redirect_to lists_url, alert: "List not found or unauthorized."
  end

	# ... the rest of the code
	
	def destroy
    @list = @subscriber.list
    @subscriber.destroy

    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: [
          turbo_stream.remove(@subscriber),
          turbo_stream.update("subscriber-count", @list.subscribers.count),
          (@list.subscribers.empty? ?
            turbo_stream.append(
              dom_id(@list, :subscribers),
              partial: "subscribers/empty_state",
              locals: { list: @list }
            ) : nil)
        ].compact
      end

      format.html { redirect_to list_url(@list), notice: "Subscriber was successfully removed." }
    end
  end
  
  # ... the rest of the code
  
  private
  
  def set_subscriber
    @subscriber = Subscriber.joins(:list)
      .where(lists: { user_id: Current.user.id })
      .find(params[:id])
  rescue ActiveRecord::RecordNotFound
    redirect_to lists_url, alert: "Subscriber not found or unauthorized."
  end

  def subscriber_params
    params.require(:subscriber).permit(:name, :email, :list_id)
  end
end

Now that we have everything in place, let's try to add a new subscriber.

mailstify-3-4
mailstify-3-4

And when you hit Subscribethe list updates automatically. The same goes with the Remove button. (The New Campaign link is still not working, wait for Part 4!)

mailstify-3-5.png
mailstify-3-5.png


B. Understanding the Turbo Stream Response

When a new subscriber is successfully created via the form:

  1. turbo_stream.prepend(...) finds the table body (<tbody id="list_1_subscribers">) and injects the new subscriber row at the top.
  2. turbo_stream.replace("new_subscriber_form", ...) replaces the content of the form area with a fresh, empty form, ready for the next entry.
  3. turbo_stream.update("subscriber-count", ...) updates the visible count next to the "Subscribers" heading.

If validation fails, the server returns an unprocessable_entity status, and turbo_stream.replace renders the form again, this time with the validation error messages displayed.


Conclusion of Part 3

We’ve built dynamic audience management using the Rails native stack.

With Hotwire Turbo Streams, Mailstify now feels responsive and modern, no external libraries, no JavaScript build steps.

In Part 4, we’ll move into the creative side of Mailstify:

Campaign Design and Rich Content, powered by Action Text and Trix Editor.

Comments

Loading comments...

Level Up Your Dev Skills & Income 💰💻

Learn how to sharpen your programming skills, monetize your expertise, and build a future-proof career — through freelancing, SaaS, digital products, or high-paying jobs.

Join 3,000+ developers learning how to earn more, improve their skills, and future-proof their careers.