October 1, 2023
Build a real-time newsletter signup form with Rails and Turbo
For nearly every meaningful project I've taken on, there's always been a landing page first to help me market and semi-validate an idea.
Tons of tools exist to capture email and market to that audience, but I've always been bothered by the lack of control of that data and how we blindly hand it over to services like Mailchimp, Mailerlite, ConvertKit, etc...
Those tools save you time and enable you to focus on your project, but I commonly like to keep that stuff in-house. That way, as my project scales, so can my marketing tactics.
Collecting a simple email address for the purpose of a newsletter seems super downright and straightforward overkill for Rails. Still, in this guide, I’ll show you some techniques to make the process snappy and more delightful than the legacy ways involving page refreshes and redirects.
Create a vanilla rails app
rails new hotwire_newsletters
Install Rails UI - Optional
I made Rails UI to help save myself and your time. You are more than welcome to skip this step.
bundle add railsui --github getrailsui/railsui --branch main
With Rails UI installed, you’ll need to boot your server
bin/dev and visit
localhost:3000. From there, you may configure your Rails UI installation in a couple of clicks. Don't worry about installing any pages for now.
The next simplified steps are
- Choose a CSS framework
- Choose a theme
- Click “Save changes”
Generate a Subscriber resource
It's worth noting that in many apps I’ve built, the model
Subscriber is used for other things like billing, so to keep that from being an issue, you might name your resource
NewsletterSubscriber or something similar.
A scaffold is overkill for what we need, but I’m more focused on speed. We’ll clean up the cruft this generates in a later step.
rails g scaffold Subscriber email
Migrate the database and create the new
The default routing at this stage points to the Rails UI start page. Let’s change that to be a new page on a new
pages_controller.rb. If you don't have a
pages_controller.rb, you can make one manually or run
rails g controller pages home.
Rails.application.routes.draw do resources :newsletters if Rails.env.development? || Rails.env.test? mount Railsui::Engine, at: "/railsui" end # Inherits from Railsui::PageController#index # To override, add your own page#index view or change to a new root # Visit the start page for Rails UI any time at /railsui/start # root action: :index, controller: "railsui/page" devise_for :users # Defines the root path route ("/") root "pages#home" end
If using Rails UI, comment out the existing root path and add the new one at the bottom.
Update pages_controller file
page_controller.rb file in
app/controllers and a
home action within that controller. Add the corresponding
app/views/pages directory with a
home.html.erb template as well.
class PagesController < ApplicationController def home @hide_nav = true end end
For the purposes of this guide, I’ve added an instance variable called
@hide_nav. The nav feels like a distraction since we want to focus more on the subscriber form. We'll use this in the application layout file to not render the nav if it's set to
You should now see the home page as the root page in your app without a navigation in sight.
Dialing in the subscribe page
I’m going to treat the “home” page as a simple subscribe page for the purposes of this guide.
In the view, we’ll render a primary container with a form. Notice the
turbo_frame_tag with the ID of
Also, notice the
src attribute, which dynamically renders the view on the other end of the path you include.
<!-- app/views/page/home.html.erb --> <div class="flex flex-col justify-center items-center h-screen bg-slate-50 border-t"> <div class="w-[460px] mx-auto rounded-xl bg-white p-8 shadow border border-slate-300"> <div class="mb-6"> <h4 class="tracking-tight">Subscribe to our newsletter</h4> <p class="my-6 text-slate-700">Tips based on proven scientific research.</p> </div> <%= turbo_frame_tag "newsletter", src: new_subscriber_path %> </div> </div>
new_subscriber_path points to
app/views/subscribers/new.html.erb. So, the contents of that page essentially render in place if there's another turbo_frame_tag in that file. Pretty slick!
An important thing to note is that the
new.html.ere template only contains the form but is surrounded by a familiar
turbo_frame_tag with the same ID as we used on the
home.html.erb template. Without this, the view won't render correctly.
<!-- app/views/newsletter_subscribers/new.html.erb--> <%= turbo_frame_tag "newsletter" do %> <%= render "form", subscriber: @subscriber %> <% end %>
I updated the design of the
_form.html.erb partial slightly from what gets generated with Rails UI. You can find this in
<!-- app/views/subscribers/_form.html.erb--> <%= form_with(model: subscriber) do |form| %> <%= render "shared/error_messages", resource: form.object %> <div class="form-group"> <%= form.label :email, class: "form-label" %> <%= form.text_field :email, class: "form-input", placeholder: "My email address is" %> </div> <div class="flex items-center justify-between flex-wrap"> <div class="sm:flex-1 sm:mb-0 mb-6"> <%= form.submit class: "btn btn-primary w-full" %> </div> </div> <% end %>
Remove the cruft
The scaffold we ran generated a lot of fluff that we didn’t need. I removed every view file in
app/views/subscribers except for the
_form.html.erb partial and the
new.html.erb template within
Simplifying the Subscribers Controller
The scaffold generator gives all the CRUD actions you might use in a typical Rails controller, but we're not using most of them. Much like the view files, I simplified the controller for our purposes and reduced everything to
new actions. That leaves us with only a few lines of ruby.
class SubscribersController < ApplicationController def new @subscriber = Subscriber.new end def create @subscriber = Subscriber.new(subscriber_params) unless @subscriber.save render :new end end private def subscriber_params params.require(:subscriber).permit(:email) end end
Pay attention to the
create action, which is where the magic lies.
Unless there’s an issue saved successfully, we’ll render the
If the newsletter subscriber does save, Rails knows to check for a
create.html.erb file in a last-ditched effort to render something as a response. We can use turbo frames to create a new view showing a proper success state that effectively re-renders the view with updated content in real time.
Create a new view called
app/views/subscribers . I modified an alert component inside this template that ships with Rails UI to display a success banner and message.
<!-- app/views/subscribers/create.html.erb--> <%= turbo_frame_tag "newsletter" do %> <div class="bg-green-50/90 text-green-700 p-4 rounded text-sm sm:flex items-center justify-between"> <div class="flex items-start justify-between space-x-3"> <%= icon "check", classes: "text-green-600 w-5 h-5 flex-shrink-0" %> <div class="flex-1"> <p class="text-green-800 font-semibold">Successfully subscribed</p> <p class="leading-snug my-1">Look for a confirmation email from us in your inbox soon.</p> </div> </div> </div> <% end %>
Notice we’re leveraging the same
turbo_frame_tag with the
newsletter ID being passed. This is important.
When you add a valid email address and submit the form, you should see the success state instantly.
What about error states and validations?
This is relatively easy, assuming you’re already rendering proper form errors in your form.
I added a basic validation to the
Subscriber model to ensure a value for
class Subscriber < ApplicationRecord validates :email, presence: true end
Rails Ul comes pre-styled with error handling, but feel free to modify this in
app/views/shared/_error_messages.html.erb. You can also customize what error messages return on the validation itself.
What about spam?
Good question. There are a lot of bots that will game your forms. One quick win that has helped me is using a CAPTCHA for all publicly accessible forms. A great one that integrates with Rails is called invisible_captcha. It's a simple gem and is simple to use. Perhaps I'll do a quick tutorial on it in the future.