Add a feedback widget to a Phoenix app

Add the widget script to the head of lib/my_app_web/components/layouts/root.html.heex with defer. The root layout is not patched by LiveView navigation, so the widget stays loaded. Render the signed-in user as JSON in a data attribute on the body, which HEEx escapes, then identify the user from a small JavaScript module.

By 路 Published 路 Last updated

Where does the script go in root.html.heex?

A Phoenix app renders its HTML shell from one root layout, lib/my_app_web/components/layouts/root.html.heex in current generators. Every controller page and every LiveView page renders inside it. Put the widget script in the head with defer, so it loads once per full page load and the browser runs it after the HTML is parsed:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My app</title>
    <script
      :if={escuta_key()}
      src="https://escutaproduto.com/widget.js"
      data-key={escuta_key()}
      defer
    ></script>
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}></script>
  </head>
  <body data-escuta-user={escuta_user_json(@current_user)}>
    <%= @inner_content %>
  </body>
</html>

The :if attribute skips the script when no key is configured, so a local run without the variable renders no broken button. The app.js bundle is also defer, and browsers run deferred scripts in document order. The widget therefore loads first, and your identify module runs after it. Even so, the identify code pushes onto a queue, so the order would not break it.

Why does the root layout keep the widget alive during LiveView navigation?

LiveView navigation patches the content inside the page's <main> container. It swaps the LiveView you are looking at and leaves the rest of the document alone. The root layout, the head and the <body> element belong to the first full page load, so the script and its Feedback button stay in place as the visitor moves between LiveViews.

That is why the script goes in the root layout and not in a LiveView template. Content inside a LiveView changes with every navigation, so a script placed there depends on the patch logic. In the layout, the script loads once per full page load and no patch can remove it. Keep the widget code in the layout, and keep your feedback buttons in LiveView templates where they belong.

The same reasoning applies to the user. Your identity comes from the <body> attribute, which LiveView never patches. If you sign in or out through a regular controller route, Phoenix does a full page load, and the body attribute is re-rendered with the current user. If you change the user inside a LiveView without a full load, reload the page once so the widget starts with the new identity.

How do I put the signed-in user on the body without raw HTML?

Do not write the user into an inline script with raw/1. A name that contains </script> would end the element early, and the HTML escaping HEEx applies to <%= %> values would turn JSON quotes into entities that JavaScript cannot read. Instead, encode the user as JSON and place it in a data- attribute. HEEx escapes attribute values correctly, and the browser decodes them back to the original text.

Add a helper module function in lib/my_app_web/components/layouts.ex, next to your other layout helpers. It returns nil for a signed-out visitor, which makes HEEx omit the attribute entirely:

def escuta_user_json(nil), do: nil

def escuta_user_json(user) do
  Jason.encode!(%{
    id: to_string(user.id),
    email: user.email,
    name: user.name
  })
end

def escuta_key, do: Application.get_env(:my_app, :escuta_key)

The map holds only the three fields the widget uses. Converting the id to a string keeps the type the widget expects. The @current_user assign must always exist in the layout, so set it in a plug such as fetch_current_user even when the value is nil. Otherwise the template raises an error on pages for signed-out visitors.

How do I identify the user from the body data attribute?

Read the attribute in a small JavaScript module, then push the identify call onto the widget queue. Save this as assets/js/escuta.js:

const raw = document.body.dataset.escutaUser;

if (raw) {
  const user = JSON.parse(raw);
  window.EscutaProduto = window.EscutaProduto || { q: [] };
  (window.EscutaProduto.q ||= []).push(["identify", [user]]);
}

Import it from assets/js/app.js, next to the LiveSocket setup that Phoenix generates:

import "./escuta.js";

The dataset property converts data-escuta-user into escutaUser. Because the module reads the attribute once at startup, a visitor who stays on the same full page load keeps the identity from the first render, which is the behavior you want.

Which Elixir config supplies the environment key?

Read the key from an environment variable in config/runtime.exs, so the same release can run in development, staging and production with different keys:

config :my_app, :escuta_key, System.get_env("ESCUTA_KEY")

Place this line outside the if config_env() == :prod block, so development can use it too. Export ESCUTA_KEY in your shell or through your hosting provider. Use one Escuta Produto product for production and another for local work. Each product has its own allowed origins, so add http://localhost:4000, the default address of mix phx.server, to the development product only. A request from an origin that is not on the list receives a 403, so a copied key cannot send feedback from another site. The widget reference lists every option you can set next to data-key.

How do I set secure browser headers for the widget?

Phoenix's :browser pipeline already calls put_secure_browser_headers. Pass it a map so you can set a Content Security Policy in the same place. The widget needs its host in script-src and connect-src. The LiveView WebSocket runs on the same host, and current browsers match it through 'self':

plug :put_secure_browser_headers, %{
  "content-security-policy" =>
    "default-src 'self'; script-src 'self' https://escutaproduto.com; connect-src 'self' https://escutaproduto.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
}

Keep the identify code in a bundled file, as shown above, so no inline script needs an exception. The 'unsafe-inline' in style-src covers the inline styles that some components set. If you add an inline script later, give it a nonce instead of loosening script-src. The CSP guide for widgets explains how to find a blocked request in the browser console.

How do I post feedback from an Elixir module with Req?

For feedback that starts on the server, for example from a contact form that a controller handles, post it to the REST API. The Req library is a common choice and decodes the JSON response for you. Keep the call in a context module so controllers and LiveViews can share it:

defmodule MyApp.Feedback do
  @endpoint "https://escutaproduto.com/api/v1/feedback"

  def send_message(message, kind) do
    body = %{
      key: Application.get_env(:my_app, :escuta_key),
      kind: kind,
      message: message
    }

    case Req.post(@endpoint, json: body, receive_timeout: 5_000) do
      {:ok, %{status: 201, body: %{"id" => id}}} -> {:ok, id}
      {:ok, %{status: 429}} -> {:error, :rate_limited}
      {:ok, %{status: status}} -> {:error, {:http, status}}
      {:error, reason} -> {:error, reason}
    end
  end
end

The API returns 201 with an id on success, 400 for invalid data, 413 for a body over 16 KB and 429 above 10 requests per minute per IP and product. Each case maps to a tuple, so your controller can show a retry message for the rate limit. Server requests carry no Origin header, which means the allowed origins list does not apply to them.

How do I open the form from a LiveView button?

To use your own button, add data-trigger="none" to the script tag in root.html.heex. That hides the floating button. Any element with a data-escuta-open attribute then opens the form, and the value preselects the type. Valid values are bug, idea, praise and other:

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

The attribute is plain HTML, so the same button works inside a LiveView template. If a button appears only after a LiveView update, click it once in the browser to confirm it opens the form. The widget reference lists every option.

What to try when the Feedback button never appears?

Open the page source first. The widget.js script tag should appear in the head, and the body should carry data-escuta-user for a signed-in visitor. If the script tag is missing, ESCUTA_KEY was empty when the server started, so export it and restart mix phx.server.

If the button appears but sending fails with a 403, the origin of the page is not in the allowed origins of that product. Add the exact address, including the port. If the name is missing from items, the body attribute is absent, which usually means @current_user was nil for that request. The best practices guide covers where a feedback button belongs on a page, and the React guide shows the same identify call in a single-page app.

Wiring the Phoenix install into Escuta Produto

Start the server with mix phx.server, sign in and open the Feedback button on a LiveView page. Move to a second LiveView through a live_patch or navigate link and send another item. Both items should record the page you sent them from, and the first item should show your name and email. Sign out, reload and send a third message to confirm the anonymous path.

Escuta Produto keeps each product in its own filter inside one inbox, so a Phoenix product sits alongside your other apps. For the same pattern in another server framework, the Rails guide covers the Turbo version of this layout, and the Django guide covers the Python one.

Frequently asked questions

Where should the feedback widget script go in a Phoenix LiveView app?

In the head of the root layout, root.html.heex, with the defer attribute. LiveView navigation patches only the content inside the page, so the script stays loaded and the Feedback button keeps working between LiveView pages.

How do I pass the signed-in user to the widget in Phoenix safely?

Encode the user as JSON with Jason and put it in a data attribute on the body. HEEx escapes attribute values, so the value cannot break the markup. A small JavaScript module reads the attribute with dataset and calls identify.

Why not write the user into an inline script tag in a Phoenix layout?

Escaping JSON for a script element is easy to get wrong. A name containing a closing script tag could end the element early, and HTML escaping turns quotes into entities that JavaScript cannot parse. A data attribute avoids both problems and needs no inline code.

How do I allow the feedback widget through a Phoenix Content Security Policy?

Pass a map to put_secure_browser_headers in the browser pipeline with the widget host in script-src and connect-src. The LiveView socket on the same host works under 'self', and the identify code should live in a bundled file.