Add a feedback widget to a Spring Boot app
Add the widget script to a Thymeleaf layout fragment in the head with defer. Build the signed-in principal into JSON in a @ControllerAdvice, render it into a data attribute on the body with th:attr, and call identify from a static file. Post server-side feedback with RestClient to the Escuta Produto REST API.
By Rafael Thayto 路 Published 路 Last updated
Where does the widget script go in a Thymeleaf layout?
Spring Boot apps that render HTML usually use Thymeleaf, with one shared layout fragment that every page includes. Put the widget script in the head of that fragment with defer, so the browser downloads it alongside the page and runs it after the HTML is parsed. Every page that uses the fragment then shows the Feedback button.
Create src/main/resources/templates/fragments/layout.html:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head(title)">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${title}">My app</title>
<script th:if="${escutaKey != ''}"
src="https://escutaproduto.com/widget.js"
th:attr="data-key=${escutaKey}"
defer></script>
<script th:if="${escutaKey != ''}"
th:src="@{/js/escuta-identify.js}"
defer></script>
</head>
</html>
A page then includes the head and sets the body attribute in its own template:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('Dashboard')}"></head>
<body th:attr="data-escuta-user=${escutaUserJson}">
<h1>Dashboard</h1>
</body>
</html>
The th:if guard keeps the widget out of the page when no key is configured, so a local run without the key does not show a dead button. The th:attr attribute escapes its value for HTML, so the key cannot break out of the attribute.
How do I expose the signed-in principal to every template?
A @ControllerAdvice class runs for every controller, so one method can add the widget values to every model. Use @AuthenticationPrincipal to read the principal that Spring Security stored for the request. This example assumes OpenID Connect login, which provides an OidcUser:
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
@ControllerAdvice
public class EscutaModelAdvice {
private final ObjectMapper mapper;
private final String key;
public EscutaModelAdvice(ObjectMapper mapper, @Value("${escuta.key:}") String key) {
this.mapper = mapper;
this.key = key;
}
@ModelAttribute("escutaKey")
public String escutaKey() {
return key;
}
@ModelAttribute("escutaUserJson")
public String escutaUserJson(@AuthenticationPrincipal OidcUser principal) throws JsonProcessingException {
if (principal == null) {
return null;
}
return mapper.writeValueAsString(new EscutaUser(
principal.getSubject(),
principal.getEmail(),
principal.getFullName()));
}
public record EscutaUser(String id, String email, String name) {}
}
The record holds only the three fields the widget uses. Using ObjectMapper produces valid JSON, with quotes and special characters escaped by Jackson. If your app uses form login with its own UserDetails, replace OidcUser with your user class and read the same three values from it. Keep the id as a string, which is what the widget expects.
A signed-out visitor gets null, so the body attribute is omitted and the identify file does nothing.
How do I render the user JSON into a data attribute?
Each page writes escutaUserJson into data-escuta-user on its body. Thymeleaf escapes the attribute value, so the JSON quotes become character references in the HTML source, and the browser turns them back into quotes when it reads the attribute. That is the safe route. Writing the JSON straight into a <script> block with [[${...}]] would also work with Thymeleaf's JavaScript inlining, but the attribute keeps the server output and the browser code cleanly separated.
Do not use th:utext for this value, because it skips escaping. Do not build the JSON with string concatenation in the template either, since a name containing a quote would break the output.
How do I read the attribute and call identify?
Put the identify code in src/main/resources/static/js/escuta-identify.js, which Spring Boot serves at /js/escuta-identify.js:
(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]]);
})();
The file is external, so your Content Security Policy only needs the widget host, with no inline script exception. The script tag in the head is defer, so the widget code loads before this file runs. Even if the order changed, the queue form would still deliver the identify call once the widget starts.
How do I add a Content Security Policy with Spring Security?
Spring Security 6 sends the policy through its headers configuration. Add it to your existing SecurityFilterChain bean. The widget needs its host in script-src and connect-src:
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SecurityConfig {
private static final String CSP =
"default-src 'self'; "
+ "script-src 'self' https://escutaproduto.com; "
+ "connect-src 'self' https://escutaproduto.com; "
+ "style-src 'self' 'unsafe-inline'; "
+ "img-src 'self' data:";
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives(CSP)));
return http.build();
}
}
Add your own authorization and login rules to the same chain. The 'unsafe-inline' in style-src allows the inline styles that some components set. Because the identify code is a static file, the policy needs no nonce for scripts. The CSP guide for widgets explains how to find a blocked request in the browser console.
How do I post feedback with RestClient?
For feedback that starts on the server, for example from a support form, post it to the REST API. Spring Framework 6.1 added RestClient, which is the synchronous client to use in new code. Define one client bean with the base URL and a request factory that has timeouts, so a slow request cannot hang a thread for long:
import java.net.http.HttpClient;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
@Configuration
public class EscutaClientConfig {
@Bean
RestClient escutaRestClient() {
var factory = new JdkClientHttpRequestFactory(
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build());
factory.setReadTimeout(Duration.ofSeconds(5));
return RestClient.builder()
.baseUrl("https://escutaproduto.com/api/v1")
.requestFactory(factory)
.build();
}
}
The service sends the key, kind and message, and it reads the id from the 201 response:
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
@Service
public class EscutaFeedbackService {
private final RestClient client;
private final String key;
public EscutaFeedbackService(RestClient escutaRestClient, @Value("${escuta.key:}") String key) {
this.client = escutaRestClient;
this.key = key;
}
public String send(String kind, String message, String email) {
Map<String, Object> body = new HashMap<>();
body.put("key", key);
body.put("kind", kind);
body.put("message", message);
if (email != null && !email.isBlank()) {
body.put("email", email);
}
try {
Map<String, Object> response = client.post()
.uri("/feedback")
.body(body)
.retrieve()
.body(new ParameterizedTypeReference<Map<String, Object>>() {});
return response == null ? null : (String) response.get("id");
} catch (HttpClientErrorException e) {
if (e.getStatusCode().value() == 429) {
throw new IllegalStateException("Too many messages, try again in a minute", e);
}
throw e;
}
}
}
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 turns the rate limit into a clear message for your controller to show. Server requests carry no Origin header, so the allowed origins list does not apply to them. Jackson serializes the map, so you do not build JSON by hand.
How do I keep the product key per environment?
Read the key from the environment, so each deployment uses its own Escuta Produto product. In application.properties, map an environment variable to the property the code reads:
escuta.key=${ESCUTA_KEY:}
Set ESCUTA_KEY in your production environment, and use a separate product with a different key for local work. Each product has its own allowed origins, so add http://localhost:8080, the default port of Spring Boot, to the development product. Requests from any other origin receive a 403. The widget reference lists the data attributes you can add next to data-key.
How do I open the form from a Thymeleaf button?
To place your own button, add data-trigger="none" to the script tag in fragments/layout.html. 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="praise">Tell us what you like</button>
Static HTML attributes like this one need no Thymeleaf expression, so the button can sit in any template that includes the layout. Keep the floating button if you want both entry points. The widget reference lists every option.
What to check when the widget loads but shows no name?
If the Feedback button appears but items have no name, look at the body. The data-escuta-user attribute should be present for a signed-in visitor. If it is missing, the @ControllerAdvice method received no principal, which usually means the page was rendered by a path that Spring Security does not cover, such as an error page. Check the security configuration for that path.
If the script tag is absent, the key was empty when the application started. Confirm that ESCUTA_KEY is set in the process environment, then restart. If sending fails with a 403, add the exact origin of the page, including the port, to the allowed origins. The best practices guide covers where the button belongs on a page.
Checking the Spring Boot install in the Escuta Produto inbox
Start the application, sign in, and open the Feedback button from a Thymeleaf page. Send one bug, then send one idea from a second page. In the inbox, both items should show your name and email, and each should record the page it came from. Sign out, reload and send a third message to confirm the anonymous path.
For the same layout pattern in other server frameworks, read the Django guide for the Python version or the ASP.NET Core guide for the C# version. Escuta Produto keeps feedback from all your products in one inbox, so a Java service sits next to your other apps.
Frequently asked questions
Where should the feedback widget script go in a Spring Boot app?
In the head of a shared Thymeleaf layout fragment, with the defer attribute. Every page that includes the fragment gets the script, so the Feedback button appears on each page that uses the layout.
How do I pass the signed-in user to the widget in Spring Boot?
Use a ControllerAdvice method that reads the authenticated principal, serialize a record with id, email and name using Jackson, and write it into a data attribute on the body with th:attr. A static script reads the attribute and calls identify.
Can I post feedback from a Spring Boot service without a browser?
Yes. Use RestClient to post the key, kind and message as JSON to the feedback endpoint. Set connect and read timeouts, and map the 429 rate limit response to a message your controller can show.
How do I allow the feedback widget through Spring Security's Content Security Policy?
Set a policy with the widget host in script-src and connect-src through the headers configuration of your SecurityFilterChain. Keep the identify code in a static file so scripts need no inline exception.
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.