Add a feedback widget to a FastAPI app

Add the widget script to a Jinja2Templates base layout with defer. Render the signed-in user into a JSON script block with Jinja's built-in tojson filter, which escapes angle brackets and ampersands, and call identify from a static file. Send server-side feedback with an async httpx client to the Escuta Produto REST API.

By 路 Published 路 Last updated

Where does the widget script go in a FastAPI template?

FastAPI does not render HTML by itself. Most apps use fastapi.templating.Jinja2Templates with a base layout, usually templates/base.html, that every page extends. Put the widget script in the head with defer. The script downloads in parallel with the page and runs after the HTML is parsed, so the first paint is not delayed:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}My API site{% endblock %}</title>
    {% if escuta_key %}
    <script src="https://escutaproduto.com/widget.js" data-key="{{ escuta_key }}" defer></script>
    <script src="{{ url_for('static', path='escuta-identify.js') }}" defer></script>
    {% endif %}
  </head>
  <body>
    {% if escuta_user %}
    <script id="escuta-user" type="application/json">{{ escuta_user|tojson }}</script>
    {% endif %}
    {% block content %}{% endblock %}
  </body>
</html>

The escuta_key variable is a template global, which you set once in the next section. The url_for call works because Jinja2Templates adds it when the template receives the request object. The identify file lives in the static folder, which you mount with StaticFiles.

Does FastAPI need its own tojson filter?

No. Jinja2 ships a built-in tojson filter, and Jinja2Templates from Starlette uses a standard Jinja2 environment, so the filter is already available in your templates. It serializes the value as JSON and replaces the characters that could close a script element, namely <, >, & and ', with Unicode escapes. The layout line from the first section works as written, with no registration step.

Keep the template object in its own module so every route imports the same configured instance:

from fastapi.templating import Jinja2Templates

templates = Jinja2Templates(directory="templates")

Use tojson only inside a type="application/json" block, as the layout does. The browser does not execute that block, so the data is read by your identify file and never interpreted as code.

How do I load the logged-in user from a dependency?

FastAPI handles authentication with dependencies, so the user usually arrives as a parameter. Convert it to a small dictionary in one helper and pass that dictionary to every template. The helper below works with any auth dependency that returns a user or None:

def escuta_payload(user):
    if user is None:
        return None
    return {
        "id": str(user.id),
        "email": user.email,
        "name": user.name,
    }

In a route, render the template with the payload. Use request as the first argument, which is the signature that current Starlette versions expect:

from fastapi import Depends, FastAPI, Request
from fastapi.responses import HTMLResponse

app = FastAPI()

@app.get("/", response_class=HTMLResponse)
async def home(request: Request, user=Depends(get_optional_user)):
    return templates.TemplateResponse(
        request,
        "home.html",
        {"escuta_user": escuta_payload(user)},
    )

get_optional_user stands for your own dependency, which reads the session cookie or token. Returning None for anonymous visitors keeps those pages working. Sending only the id, email and name means the browser never receives the rest of your user record.

How do I keep the product key in settings?

Use pydantic-settings to read the key from the environment, with an optional .env file for local work. This keeps the key out of your code and lets each environment use its own Escuta Produto product:

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env")

    escuta_key: str = ""

settings = Settings()
templates.env.globals["escuta_key"] = settings.escuta_key

The key is public, so exposing it in the page is expected. Still, use one product for production and another for local work, so test items from your laptop do not reach the live inbox. Give each product its own allowed origins. For uvicorn on your machine, add http://127.0.0.1:8000 to the development product. A request from any origin not on the list gets a 403. The widget reference explains the data attributes you can add next to data-key.

How do I send feedback from an async route with httpx?

When feedback starts on the server, post it to the REST API with httpx. Create one AsyncClient for the whole application through the lifespan handler, so connections are reused across requests instead of opened for each one:

from contextlib import asynccontextmanager
from typing import Literal

import httpx
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, Field

@asynccontextmanager
async def lifespan(app: FastAPI):
    async with httpx.AsyncClient(timeout=5) as client:
        app.state.http = client
        yield

app = FastAPI(lifespan=lifespan)

class FeedbackIn(BaseModel):
    message: str = Field(min_length=2, max_length=4000)
    kind: Literal["bug", "idea", "praise", "other"] = "other"

@app.post("/feedback", status_code=201)
async def send_feedback(item: FeedbackIn, request: Request):
    response = await request.app.state.http.post(
        "https://escutaproduto.com/api/v1/feedback",
        json={
            "key": settings.escuta_key,
            "kind": item.kind,
            "message": item.message,
        },
    )
    if response.status_code == 429:
        raise HTTPException(status_code=429, detail="Too many messages, try again in a minute")
    if response.status_code != 201:
        raise HTTPException(status_code=502, detail="The feedback service rejected the message")
    return {"ok": True, "id": response.json()["id"]}

Pydantic checks the length and the type before any network call, so a short or oversized message never reaches the API. The API itself returns 400 for invalid data, 413 for a body over 16 KB and 429 above 10 requests per minute per IP and product. The route passes the 429 through, so your frontend can show a retry message. Server requests carry no Origin header, which means the allowed origins list does not apply to them.

How do I add a Content Security Policy middleware?

FastAPI has no built-in policy header, so add a small HTTP middleware. The widget needs its host in script-src and connect-src, and the identify code lives in a static file, so no inline script exception is needed:

from fastapi import Request

@app.middleware("http")
async def content_security_policy(request: Request, call_next):
    response = await call_next(request)
    response.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:"
    )
    return response

Apply the header to API responses as well as pages, because a JSON error page can still carry a policy with no effect on scripts. If your API docs at /docs load Swagger UI from a CDN, that page will need its own sources. The CSP guide for widgets explains how to find a blocked request in the browser console.

How do I open the form from a Jinja template?

The floating button is one entry point. To place your own, add data-trigger="none" to the widget.js script tag in the base layout, which hides the floating button. Any element with a data-escuta-open attribute then opens the form. The value preselects the type, and valid values are bug, idea, praise and other:

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

Put that button in any template that extends the base layout. Hiding the floating button is optional, so you can keep both if you prefer. The widget reference lists the complete set of attributes.

What should I check when the widget is missing?

Start with the page source. If the widget.js tag is absent, the template did not receive escuta_key, which usually means the settings class could not read .env from the working directory. Start uvicorn from the project root, or pass an absolute path to env_file.

If the button shows but sending fails, the browser console shows a 403 for an origin that is not on the allowed list. Add the exact origin, including the port. If the name is missing from items, the escuta-user element is absent, so check that your dependency returns a user for that request. The best practices guide covers where a feedback button works best on a page.

Checking the first FastAPI message in Escuta Produto

Run uvicorn app.main:app --reload, sign in and send a bug from a page that extends the base layout. Then post an idea through your /feedback route with a test client. In the inbox, the widget item should show your name and email, while the server item arrives with no page address because it came from your API.

For the same pattern in other stacks, read the Flask guide for the Jinja approach or the Django guide for server-side context. Escuta Produto keeps items from every product in one inbox, so a FastAPI service and a mobile app can share one list.

Frequently asked questions

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

In the head of your base template rendered by Jinja2Templates, with the defer attribute. Every template that extends the base gets the script, so the Feedback button appears on each page that uses the layout.

Does FastAPI need a custom tojson filter for templates?

No. Jinja2's built-in tojson filter is available in Jinja2Templates and escapes angle brackets, ampersands and single quotes, so it is safe inside a script element of type application/json.

How do I send feedback from a FastAPI route to Escuta Produto?

Create one httpx AsyncClient in the lifespan handler, post the key, kind and message as JSON to the feedback endpoint, and map the 429 and error responses to your own HTTP errors. Server requests carry no Origin header.

How do I set a Content Security Policy in FastAPI for the widget?

Add an HTTP middleware that sets the Content-Security-Policy header, with the widget host in script-src and connect-src. Keep the identify code in a static file so you do not need inline script permissions.