Add a feedback widget to a Ruby on Rails app

Add the widget script to the head of your application layout with defer. Render the signed-in user into a meta tag with to_json, which ERB escapes, then call identify from a nonced inline script. Allow the widget host in the Content Security Policy initializer, and Turbo visits will keep the widget working.

By · Last updated

Add the widget to the application layout

Rails renders every page through app/views/layouts/application.html.erb. Put the widget script in its head, with defer. The script then loads once per full page load, and the widget is present on every page that uses the layout:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title><%= content_for(:title) || "My app" %></title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <script src="https://escutaproduto.com/widget.js" data-key="pk_your_product_key" defer></script>
  </head>
  <body>
    <meta name="escuta-user" content="<%= escuta_user_json %>">
    <%= yield %>
  </body>
</html>

The data-key is your public product key, which can only create feedback. Rails 8 apps may use Propshaft and the stylesheet_link_tag :app helper in the same head, so keep your existing asset tags in place. The widget script does not depend on them.

Build the user data safely in a helper

Put the user data in a helper, in app/helpers/application_helper.rb, not in the view. Only the fields the widget needs leave the server, and the helper returns "null" for signed-out visitors so the page still renders:

module ApplicationHelper
  def escuta_user_json
    return "null" unless current_user

    {
      id: current_user.id.to_s,
      email: current_user.email,
      name: current_user.name
    }.to_json
  end
end

Rendering the result with <%= %> is the important part. ERB escapes the JSON for an HTML attribute, so a quote or angle bracket in a name becomes an entity, and the browser decodes it back to the same text. The value can never close the attribute or start a tag. Do not wrap it in raw or html_safe, because that would remove the protection.

Use current_user from your authentication, whether that is Devise, the Rails 8 authentication generator or your own helper. The helper only needs the method to exist in the view context.

Identify the user from a nonced inline script

A small inline script reads the meta tag and calls identify. Rails adds a nonce to inline scripts when you ask for it with javascript_tag nonce: true, which lets the policy allow this one script without allowing all inline code:

<%= javascript_tag nonce: true do %>
  (function () {
    var el = document.querySelector('meta[name="escuta-user"]');
    var user = el ? JSON.parse(el.content) : null;
    if (!user) return;
    window.EscutaProduto = window.EscutaProduto || { q: [] };
    (window.EscutaProduto.q ||= []).push(["identify", [user]]);
  })();
<% end %>

Place this block in the body, after the meta tag. The JSON.parse call works because ERB already escaped the attribute, so the content is valid JSON again. Send only the fields you would show the person, because the browser can read them with developer tools.

Turbo visits and identity

Turbo Drive replaces the body on each link click instead of loading a new document. The head stays, so the widget script keeps running. Turbo does re-evaluate script elements inside the new body, and it copies the nonce attribute, so the identify block runs again after each visit.

That repeat is harmless. It pushes the same user onto the queue again. When someone signs in or out, the next full page load or the next Turbo visit reads the new meta tag and identifies the current person. If your sign-out button uses a form submission that redirects, the browser does a full load and the state resets cleanly.

Content Security Policy initializer for Rails

New Rails apps ship a commented-out policy initializer. Uncomment it in config/initializers/content_security_policy.rb and add the widget host to script-src and connect-src, then enable nonces for scripts:

Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.script_src :self, "https://escutaproduto.com"
    policy.connect_src :self, "https://escutaproduto.com"
  end

  config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
  config.content_security_policy_nonce_directives = %w[script-src]
end

The nonce generator gives each request a fresh value, and the javascript_tag nonce: true block receives it automatically. The widget script is allowed by its host, and the identify block is allowed by its nonce. Anything else that tries to run inline is blocked.

If a blocked request shows up after a deploy, check the browser console for the directive name. A missing connect-src entry blocks feedback submissions from the widget, while a missing script-src entry blocks the script itself. The CSP guide for widgets explains each case in more detail.

Send feedback from a Rails controller

Sometimes feedback starts on the server, for example from a support form or a background job. Wrap the call in a small service object in app/services/escuta_feedback.rb, which sends it to the REST API with the standard library and checks the response:

require "net/http"
require "json"

class EscutaFeedback
  ENDPOINT = URI("https://escutaproduto.com/api/v1/feedback")

  def self.send!(message:, kind: "other", email: nil)
    request = Net::HTTP::Post.new(ENDPOINT, "Content-Type" => "application/json")
    payload = { key: "pk_your_product_key", kind: kind, message: message }
    payload[:email] = email if email
    request.body = payload.to_json

    http = Net::HTTP.new(ENDPOINT.host, ENDPOINT.port)
    http.use_ssl = true
    http.open_timeout = 3
    http.read_timeout = 5

    response = http.request(request)
    raise "Escuta feedback failed: #{response.code}" unless response.code == "201"

    JSON.parse(response.body)
  end
end

Server requests carry no Origin header, so your allowed origins list does not apply to them. The API still rejects a body over 16 KB with 413 and limits each IP to 10 requests a minute per product with 429. Validate the message length in your controller before calling the service, and retry 429 later rather than failing the user's request.

Open the form from an ERB view

A button with data-escuta-open opens the form, with the type preselected:

<button type="button" data-escuta-open="bug">Report a problem</button>

Valid types are bug, idea, praise and other. For a floating button you do not want, add data-trigger="none" to the script tag in the layout. The widget handles focus and the Escape key, so the attribute is all the view needs.

Check the Rails install in your Escuta Produto inbox

  1. Add http://localhost:3000 (the default Rails dev URL) to the allowed origins of your product.
  2. Sign in, open the Feedback button and send a test bug from a page that renders the layout.
  3. In the inbox, confirm the item shows your name and email from the meta tag, plus the page URL.
  4. Sign out, reload, and send another item. The item should show no name, which proves the signed-out state.

If the name never appears, view the page source and check the meta tag's content. An empty value or null means the helper ran without a current user.

For every option, read the widget reference. The Django guide uses a similar template pattern, and the Laravel guide covers the same job in PHP.

Frequently asked questions

Where should the feedback widget script go in a Rails app?

In the head of app/views/layouts/application.html.erb, with the defer attribute. Every page rendered with that layout gets the script, and Turbo Drive does not reload it when people move between pages.

How do I pass current_user to the feedback widget safely in Rails?

Build a small hash of the id, email and name, convert it with to_json, and put it in a meta tag with ERB output. ERB escapes the attribute, and the script parses it with JSON.parse, so no value can break out of the markup.

Does the feedback widget work with Turbo in Rails?

Yes. Turbo Drive swaps the page body and keeps the head, so the widget script stays loaded. Inline scripts in the body run again on each visit, which only pushes the same user again.

What does a Rails Content Security Policy need for the widget?

Add the widget host to script-src and connect-src in config/initializers/content_security_policy.rb. If you use nonces for your inline identify script, set a nonce generator and list script-src as a nonce directive.