Add a feedback widget to a Django app

Add the widget script to base.html with defer. Build the signed-in user as a dictionary in a context processor, render it with json_script, and read it in a small static JavaScript file. Allow the widget host in script-src and connect-src with django-csp, so no inline script is needed.

By · Last updated

Add the widget to base.html

Most Django projects keep one base template that every page extends. Put the widget script in its head with defer, so it loads once per page and never blocks the render:

{% load static %}
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}My app{% endblock %}</title>
    <script src="https://escutaproduto.com/widget.js" data-key="pk_your_product_key" defer></script>
    <script src="{% static 'js/escuta-identify.js' %}" defer></script>
  </head>
  <body>
    {% if escuta_user %}
      {{ escuta_user|json_script:"escuta-user" }}
    {% endif %}
    {% block content %}{% endblock %}
  </body>
</html>

The data-key is your public product key, which can only create feedback. The second script is your own identify file, served from your origin. Both are defer, so they run in order after the HTML is parsed: the widget first, then your identify file.

The json_script output sits in the body and is not executed, because its type is application/json. It only carries data for the script to read.

Build the user dictionary in a context processor

A context processor adds escuta_user to every template, so you do not repeat the code in each view. Put this function in myproject/context_processors.py and return a plain dictionary with only the fields the widget needs:

def escuta_user(request):
    user = getattr(request, "user", None)
    if user is None or not user.is_authenticated:
        return {"escuta_user": None}

    return {
        "escuta_user": {
            "id": str(user.pk),
            "email": user.email,
            "name": user.get_full_name() or user.username,
        }
    }

Register the function in TEMPLATES in myproject/settings.py, under the context_processors option of your template backend:

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                # ...the defaults your project already has...
                "myproject.context_processors.escuta_user",
            ],
        },
    },
]

Returning str(user.pk) keeps the id a string, which is how the widget expects it. A user object itself cannot be converted to JSON, so the dictionary is what the template receives. Keep the values small, because they travel in every page response.

Read the data in a static JavaScript file

The identify code lives in static/js/escuta-identify.js, not in the template. The file reads the JSON from the json_script element and pushes the call onto the widget queue:

(function () {
  var el = document.getElementById("escuta-user");
  if (!el) return;

  var user = JSON.parse(el.textContent);
  window.EscutaProduto = window.EscutaProduto || { q: [] };
  (window.EscutaProduto.q ||= []).push(["identify", [user]]);
})();

Reading textContent returns the JSON exactly as the server built it, because json_script escapes the characters that matter for HTML, such as angle brackets and ampersands. The file has no inline code, so a strict policy only needs to allow your own origin for it.

Avoid writing {{ request.user.email }} inside a <script> block. Django's autoescaping produces HTML entities, which are not valid inside JavaScript, and an unusual value can still break the page. The filter and the static file avoid both problems.

Set the Content Security Policy with django-csp

The widget needs its host in two directives. The django-csp package sends the header from settings in myproject/settings.py. Version 4.0 and later read a single dictionary, while older releases used separate CSP_* settings, so check the version you have installed:

MIDDLEWARE = [
    # ...the middleware you already have...
    "csp.middleware.CSPMiddleware",
]

CONTENT_SECURITY_POLICY = {
    "DIRECTIVES": {
        "default-src": ["'self'"],
        "script-src": ["'self'", "https://escutaproduto.com"],
        "connect-src": ["'self'", "https://escutaproduto.com"],
    }
}

Because the identify code is in a static file, this policy has no nonce and no 'unsafe-inline'. If you already have inline scripts elsewhere in the project, move them to static files or add a nonce for them, rather than loosening the policy for the whole site. The CSP guide for widgets explains how to read a blocked request in the browser console.

Send feedback from a Django view

When feedback starts on the server, for example from a contact form that a view handles, post it to the REST API. Put this helper in myproject/feedback.py. The standard library is enough for one request:

import json
import urllib.request

ENDPOINT = "https://escutaproduto.com/api/v1/feedback"

def send_feedback(message, kind="other", email=None):
    payload = {"key": "pk_your_product_key", "kind": kind, "message": message}
    if email:
        payload["email"] = email

    request = urllib.request.Request(
        ENDPOINT,
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=5) as response:
        return json.load(response)

The API returns 201 with an id on success. It returns 400 for invalid data, 413 for a body over 16 KB and 429 above 10 requests per minute per IP and product. urlopen raises an error for those responses, so catch urllib.error.HTTPError in the view and show a retry message on 429. Server requests carry no Origin header, so your allowed origins list does not apply.

Keep the widget working with htmx partials

If your project uses htmx, partial swaps replace fragments of the page and leave the head alone. The widget script and the identify file were loaded with the page, so they keep running after a swap. Put the json_script element in the base template, as shown above, rather than inside a partial, so a swap that removes the element cannot change the identity the widget already holds.

Open the form from a Django template

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

<button type="button" data-escuta-open="idea">Suggest a feature</button>

Valid types are bug, idea, praise and other. To hide the floating button and use only your own, add data-trigger="none" to the widget script tag in base.html. Templates that extend the base get the attribute behavior without any extra script.

Check the Django install in your Escuta Produto inbox

  1. Add http://localhost:8000 (the default Django dev server URL) to the allowed origins of your product.
  2. Sign in, open the Feedback button and send a test idea from a page that uses the base template.
  3. In the inbox, confirm the item shows your name and email, plus the page URL.
  4. Sign out, reload the page and send another item. That item should have no name, which confirms the signed-out path.

If the name is missing, check the page source for the escuta-user element. If it is absent, the context processor returned None, which usually means the setting was not registered.

For the complete option list, read the widget reference. The Rails guide covers the same layout pattern in Ruby, and the Laravel guide covers it in PHP.

Frequently asked questions

Where do I add the feedback widget script in a Django project?

In the head of your base template, usually templates/base.html, with the defer attribute. Every template that extends the base gets the script, so the Feedback button appears on each page.

How do I pass the logged-in user to the widget in Django?

Return a dictionary with the id, email and name from a context processor, then render it with the json_script filter. The filter escapes the JSON for a script tag, and a small static file reads it and calls identify.

Why not put request.user directly into a script block in Django?

A user object cannot be converted to JSON, and writing values straight into a script block risks breaking out of it. Build a plain dictionary with only the fields you need, and let json_script handle the escaping.

Which django-csp settings does the feedback widget need?

Add the widget host to script-src and connect-src in your CONTENT_SECURITY_POLICY setting, using django-csp 4.0 or later. Because the identify code lives in a static file, you do not need a nonce for it.