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
- Mailstify #1 - Let's Build A Zero-Dependency Mailchimp Clone in with Ruby on Rails 8
- Mailstify #2 - User Identity and Multi-Tenancy (Built-in Auth)
- Mailstify #3 - Go Dynamic! Building Real-Time Audience Lists (The Hotwire Way) 👈 (You are here)
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.
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>
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
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.
And when you hit Subscribe, the list updates automatically. The same goes with the Remove button. (The New Campaign link is still not working, wait for Part 4!)
B. Understanding the Turbo Stream Response
When a new subscriber is successfully created via the form:
turbo_stream.prepend(...)finds the table body (<tbody id="list_1_subscribers">) and injects the new subscriber row at the top.turbo_stream.replace("new_subscriber_form", ...)replaces the content of the form area with a fresh, empty form, ready for the next entry.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.

