Add a feedback widget to an ASP.NET Core app
Add the widget script to the head of Views/Shared/_Layout.cshtml with defer. Read the signed-in user from the User claims, serialize the three fields with System.Text.Json, and write the result into a data attribute on the body, where Razor encodes it. A static file calls identify, and HttpClient posts server-side feedback to the Escuta Produto API.
By Rafael Thayto 路 Published 路 Last updated
Where does the script go in _Layout.cshtml?
ASP.NET Core MVC apps render every page into one shared layout, Views/Shared/_Layout.cshtml, that calls @RenderBody(). Put the widget script in the head of that file with defer. Every view rendered through the layout then gets the Feedback button, and the browser runs the script after the HTML is parsed:
@inject Microsoft.Extensions.Configuration.IConfiguration Configuration
@using System.Security.Claims
@using System.Text.Json
@{
var escutaKey = Configuration["Escuta:Key"];
var escutaUserJson = User.Identity?.IsAuthenticated == true
? JsonSerializer.Serialize(new
{
id = User.FindFirst(ClaimTypes.NameIdentifier)?.Value,
email = User.FindFirst(ClaimTypes.Email)?.Value,
name = User.FindFirst(ClaimTypes.Name)?.Value
})
: null;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - My app</title>
@if (!string.IsNullOrEmpty(escutaKey))
{
<script src="https://escutaproduto.com/widget.js" data-key="@escutaKey" defer></script>
<script src="~/js/escuta-identify.js" defer></script>
}
</head>
<body data-escuta-user="@escutaUserJson">
@RenderBody()
</body>
</html>
The if block skips the widget when no key is configured, so a local run without the setting renders no dead button. The data-key attribute is your public product key, which can only create feedback. The identify script is served from wwwroot, and the ~/ prefix resolves it to the right path under any virtual directory.
Razor encodes every @ expression, so data-key cannot break out of its attribute even if the key contains unexpected characters.
Which claims become the identify payload?
The User property is a ClaimsPrincipal, which holds the claims that the authentication handler stored for the request. The layout reads three of them. The name identifier becomes the id, the email claim becomes the email, and the name claim becomes the name. Each claim is optional, so a missing value becomes null, which the serializer writes as JSON null.
Use the claim types your login provider actually sends. With ASP.NET Core Identity, the email and name claims are standard. With OpenID Connect, check the claims in a debug view, because providers differ on which claim carries the display name. If your name claim is empty, fall back to the email or the username. The widget accepts whatever the browser sends, so a sensible fallback makes the inbox easier to read.
The anonymous type keeps the payload to exactly those three fields. Passing the whole ClaimsPrincipal or your entity would expose every claim to the browser, which is not needed for feedback.
How do I encode the user JSON for an attribute?
The layout serializes the anonymous object with System.Text.Json and writes the string into data-escuta-user. That is the important detail. The output of JsonSerializer.Serialize is a plain string, so Razor HTML-encodes it inside the attribute: the JSON quotes become ", and a name containing a quote or angle bracket stays inside the value. The browser decodes the attribute back to the original JSON text.
A signed-out visitor gets null. Razor omits an attribute whose value is null, so the body has no data-escuta-user and the identify script does nothing.
Avoid two shortcuts. Writing the user into an inline script with Html.Raw would skip encoding, and a name such as </script> could end the element early. Building the JSON with string interpolation has the same problem and also breaks on quotes. The serializer and the attribute together handle both cases.
How do I read the attribute and call identify?
Create wwwroot/js/escuta-identify.js with the code below. It reads the attribute, parses it and pushes the identify call onto the widget queue:
(function () {
var raw = document.body.dataset.escutaUser;
if (!raw) return;
var user = JSON.parse(raw);
window.EscutaProduto = window.EscutaProduto || { q: [] };
(window.EscutaProduto.q ||= []).push(["identify", [user]]);
})();
Because the script is a static file, the policy in the next section needs no inline exception. The defer attribute on both script tags keeps their order, so the widget code runs before this file. The queue form also works if the order changes, because the widget reads the queue once it starts.
Identify runs on every full page load. ASP.NET Core MVC pages are full loads by default, so the identity always matches the cookie the visitor sent with that request.
How do I add the Content Security Policy header?
ASP.NET Core does not send a Content Security Policy by default. Add a small middleware in Program.cs before the endpoints. The widget needs its host in script-src and connect-src, and the identify code is a static file, so no inline script exception is required:
app.Use(async (context, next) =>
{
context.Response.Headers.Append(
"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:");
await next();
});
Register the middleware before app.MapControllerRoute so every response, including error pages, carries the header. The 'unsafe-inline' in style-src covers the inline styles that some Razor components set. If you add a Razor script block later, keep it free of user data or give it a nonce rather than loosening script-src. The CSP guide for widgets explains how to read a blocked request in the browser console.
How do I send feedback with HttpClient?
When feedback starts on the server, post it to the REST API. Register a named client through IHttpClientFactory, so connections are pooled and the timeout applies to every call:
builder.Services.AddHttpClient("escuta", client =>
{
client.BaseAddress = new Uri("https://escutaproduto.com/api/v1/");
client.Timeout = TimeSpan.FromSeconds(5);
});
builder.Services.AddScoped<EscutaFeedback>();
The service builds the payload, posts it and reads the id from the 201 response:
using System.Net;
using System.Net.Http.Json;
public sealed class EscutaFeedback(IHttpClientFactory factory, IConfiguration config)
{
public async Task<string> SendAsync(string kind, string message, string? email, CancellationToken ct = default)
{
var client = factory.CreateClient("escuta");
var payload = new Dictionary<string, object?>
{
["key"] = config["Escuta:Key"],
["kind"] = kind,
["message"] = message
};
if (!string.IsNullOrWhiteSpace(email))
{
payload["email"] = email;
}
using var response = await client.PostAsJsonAsync("feedback", payload, ct);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
throw new InvalidOperationException("Too many messages, try again in a minute.");
}
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<FeedbackCreated>(ct);
return created?.Id ?? string.Empty;
}
}
public sealed record FeedbackCreated(bool Ok, string Id);
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. The service throws a clear exception for the rate limit, which a controller can catch and turn into a message. Server requests carry no Origin header, so the allowed origins list does not apply to them.
How do I supply the key with user secrets?
The configuration key Escuta:Key can come from any configuration source. In production, set the environment variable Escuta__Key, since the double underscore maps to the colon in the key name. Keep the production key out of appsettings.json in source control.
For local development, store the key with the user secrets tool, which keeps it outside the project folder:
dotnet user-secrets init
dotnet user-secrets set "Escuta:Key" "pk_your_dev_product_key"
Use a separate Escuta Produto product for local work, with its own allowed origins. Add https://localhost:5001, the default HTTPS address of dotnet run, to that product. Each product has its own list, so a copied key cannot send feedback from a site that is not on it. The widget reference lists every option you can add next to data-key.
How do I open the form from a Razor button?
To place your own button, add data-trigger="none" to the script tag in _Layout.cshtml. That hides the floating button. Any element with a data-escuta-open attribute opens the form, and the value preselects the type. Valid values are bug, idea, praise and other:
<button type="button" data-escuta-open="idea">Suggest a feature</button>
A static attribute like this needs no Razor expression, so the button can appear in any view. Keep the floating button if you want both. The widget reference lists every option.
What breaks when the name stays empty?
If items arrive without a name, look at the body in the page source. The data-escuta-user attribute should be present for a signed-in visitor. If it is missing, the request had no authenticated user, which can happen after a cookie expires. If the attribute exists but the name is empty, the name claim is not in the principal. Inspect User.Claims in a debug view and choose the claim your provider really sends.
If the script tag is missing, the key was empty when the app started. Confirm the user secret or the environment variable, then restart. If sending fails with a 403, the page origin is not on the allowed list. Add the exact origin, including the port. The best practices guide explains where a feedback button fits best on a page.
Using the ASP.NET Core install with Escuta Produto
Run the app, sign in and open the Feedback button from a page that uses the layout. Send one bug, then go to another page and send an idea. In the inbox, both items should show the name and email from your claims, and both should record the page they came from. Sign out, reload and send a third message to confirm the anonymous path.
For the same pattern in other server frameworks, the Spring Boot guide covers the Java version, and the Rails guide covers Ruby. Escuta Produto keeps the feedback for every product in one inbox, so an ASP.NET Core app sits alongside the rest of your products.
Frequently asked questions
Where should the feedback widget script go in an ASP.NET Core app?
In the head of Views/Shared/_Layout.cshtml, with the defer attribute. Every view rendered through the layout gets the script, so the Feedback button appears on each page that uses it.
How do I pass the signed-in user to the widget in ASP.NET Core?
Read the id, email and name from the User claims, serialize them with System.Text.Json, and write the string into a data attribute on the body. Razor encodes the attribute value, and a static script reads it and calls identify.
Why serialize the user into an attribute instead of an inline script in Razor?
Writing raw JSON into a script element risks a value that closes the element early, and Html.Raw skips encoding entirely. A data attribute is encoded by Razor, so quotes and angle brackets in a name stay inside the value.
How do I send feedback from an ASP.NET Core app to Escuta Produto?
Register a named HttpClient with the API base address and a timeout, post the key, kind and message as JSON, and read the id from the 201 response. Handle the 429 rate limit with a message for the user.
Related
- Add a feedback widget to a React appInstall the Escuta Produto feedback widget in a Vite React app: the script in index.html, identify from your auth context, and how routes behave.
- Add a feedback widget to a Vue appAdd the Escuta Produto feedback widget to a Vue 3 app with Vite: script in index.html, identify in a composable after login, and Vue Router notes.
- Add a feedback widget to a Nuxt appAdd the Escuta Produto feedback widget to a Nuxt app: app.head script config, a client-only plugin for identify, and server rendering caveats.
- Add a feedback widget to a SvelteKit appAdd the Escuta Produto feedback widget to a SvelteKit app: the app.html script, identify from layout data, and Content Security Policy in svelte.config.js.