Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

October 1, 2023

Last updated November 5, 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

  1. Choose a CSS framework
  2. Choose a theme
  3. 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 Newsletter table.

rails db:migrate

Update routing

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

Create a 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 true

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>HotwireNewsletterSubscription</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
    <%= stylesheet_link_tag "application", "https://unpkg.com/[email protected]/dist/trix.css", "data-turbo-track": "reload" %>

    <%= render "shared/fonts" %>
    <%= render "shared/meta" %>
    <%= yield :head %>
  </head>

  <body class="rui">
    <%= render "shared/flash" %>
    <% if content_for(:header).present? %>
      <%= yield :header %>
    <% else %>
      <%= render "shared/nav" unless @hide_nav %>
    <% end %>
    <%= yield %>
    <%= railsui_launcher if Rails.env.development? %>
  </body>
</html>

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 newsletter.

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.

<!-- 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 app/views/subscribers

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 create and 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 new.html.erb template.

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 create.html.erb in 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 email when the form is submitted. If not, we'll display errors.

class Subscriber < ApplicationRecord
  validates :email, presence: true
end

error state for subscriber form validation

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.

Useful links

Tags: turbo
Link this article
Est. reading time: 8 minutes
Stats: 730 views

Categories

Collection

Part of the Hotwire and Rails collection