How Spring pre-rendering works
Spring Boot can use a highest-precedence servlet filter to return crawler-ready HTML before controllers, security filters, and SPA fallbacks.
The servlet filter handles eligible crawler HTML requests and writes successful rendered HTML. Browsers and render failures continue down the filter chain.
Prerequisites
- A Spring Boot servlet application with Java HTTP client support.
- Access to register a component at the highest filter precedence.
- LOVABLEHTML_API_KEY configured in the application environment.
Set up pre-rendering with Spring
Add the servlet filter
Save EncitedPrerenderFilter.java in your application source tree. The highest-precedence annotation runs it before controllers.
Provide the API key
Set LOVABLEHTML_API_KEY in the production environment. The filter reads it without storing the key in source control.
Build and deploy Spring
Run your normal Maven or Gradle production deployment.
./mvnw package// EncitedPrerenderFilter.javaimport java.io.IOException;import java.net.URI;import java.net.URLEncoder;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.nio.charset.StandardCharsets;import java.time.Duration;import java.util.List;import java.util.regex.Pattern;import jakarta.servlet.FilterChain;import jakarta.servlet.ServletException;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import org.springframework.core.Ordered;import org.springframework.core.annotation.Order;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;@Component@Order(Ordered.HIGHEST_PRECEDENCE)public class EncitedPrerenderFilter extends OncePerRequestFilter {private static final Pattern CRAWLER = Pattern.compile("(?:(OAI-SearchBot|ChatGPT-User(?:\\/\\d+\\.\\d+)?|GPTBot|ChatGPT|OpenAI-))|(?:(anthropic-ai|ClaudeBot|claude-web|Claude-Web|Claude-User|Claude-SearchBot))|(?:(GrokBot|xAI-Grok))|(?:(PerplexityBot|Perplexity-User))|(?:(Google-InspectionTool|GoogleOther(?:-Image|-Video)?|Googlebot|Google-CloudVertexBot|Storebot-Google))|(?:(Microsoft-Copilot|CopilotBot|Copilot-Chat|Microsoft-CopilotPlugin))|(?:(BingBot|bingbot|BingPreview))|(?:facebookexternalhit.*Twitterbot|Twitterbot.*facebookexternalhit)|(?:(Meta-ExternalAgent|meta-externalagent|meta-externalfetcher|MetaBot|FacebookBot|facebookexternalhit))|(?:LinkedInBot)|(?:Amazonbot)|(?:(Applebot-Extended|Applebot))|(?:Bytespider)|(?:(DuckAssistBot|DuckDuckBot))|(?:(cohere-training-data-crawler|cohere-ai|CohereAI))|(?:MistralAI-User)|(?:AI2Bot)|(?:CCBot)|(?:Diffbot)|(?:omgili)|(?:TimpiBot)|(?:YouBot)|(?:(Bravebot|Brave-Search))|(?:Yandex)|(?:Twitterbot)|(?:Discordbot)|(?:(Slackbot|Slack-ImgProxy))|(?:TelegramBot)|(?:(Pinterest|Pinterestbot))|(?:Snapchat)|(?:Redditbot)|(?:Tumblr)|(?:(Mastodon|http\\.rb))|(?:Viber)|(?:^Line\\/)|(?:WhatsApp)|(?:(SkypeUriPreview|Skype))|(?:(Teams|MicrosoftPreview))|(?:Zoom)|(?:Notion)|(?:(Quora|QuoraBot|Quora-Bot|Poe-Bot))|(?:Medium)|(?:(Pocket|PocketParser|PocketImageCache))|(?:(Flipboard|FlipboardProxy))|(?:(Embedly|embed\\.ly))|(?:(Baiduspider|Baidu))|(?:(Sogou|sogou))|(?:(Yeti|NaverBot|Naver))|(?:SeznamBot)|(?:(Qwantify|Qwant))|(?:Ecosia)|(?:MojeekBot)|(?:Mail\\.RU_Bot)|(?:Yahoo! Slurp)|(?:(SemrushBot|SEMrush))|(?:(AhrefsBot|AhrefsSiteAudit))|(?:(DotBot|Rogerbot|moz\\.com))|(?:MJ12bot)|(?:Screaming Frog)|(?:SISTRIX)|(?:SEOkicks)|(?:serpstatbot)|(?:RSiteAuditor)|(?:LvHTML-SEOAuditBot)|(?:Barkrowler)|(?:HubSpot Crawler)|(?:(AwarioSmartBot|AwarioBot))", Pattern.CASE_INSENSITIVE);private static final List<String> FORWARD_HEADERS = List.of("accept-language", "sec-fetch-mode", "sec-fetch-site", "sec-fetch-dest","sec-fetch-user", "upgrade-insecure-requests", "referer", "user-agent");private static final List<String> RESPONSE_HEADERS = List.of("x-lovablehtml-render-cache", "x-lovablehtml-snapshot-key", "cache-control","etag", "content-digest", "signature-input", "signature");private static final String API_KEY = System.getenv("LOVABLEHTML_API_KEY");private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();@Overrideprotected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)throws ServletException, IOException {String accept = req.getHeader("accept") == null ? "" : req.getHeader("accept");String userAgent = req.getHeader("user-agent") == null ? "" : req.getHeader("user-agent");boolean html = accept.isEmpty() || accept.equals("*/*") || accept.contains("text/html");if (API_KEY == null || API_KEY.isBlank() || !req.getMethod().equals("GET")|| !html || !CRAWLER.matcher(userAgent).find()) {chain.doFilter(req, res);return;}String publicUrl = req.getRequestURL().toString();if (req.getQueryString() != null) publicUrl += "?" + req.getQueryString();String endpoint = "https://encited.com/api/prerender/render?url="+ URLEncoder.encode(publicUrl, StandardCharsets.UTF_8);try {HttpRequest.Builder renderedRequest = HttpRequest.newBuilder(URI.create(endpoint)).timeout(Duration.ofSeconds(30)).header("x-lovablehtml-api-key", API_KEY).header("accept", "text/html").GET();for (String name : FORWARD_HEADERS) {String value = req.getHeader(name);if (value != null && !value.isBlank()) renderedRequest.header(name, value);}HttpResponse<byte[]> rendered = http.send(renderedRequest.build(), HttpResponse.BodyHandlers.ofByteArray());if (rendered.statusCode() == 301 && rendered.headers().firstValue("location").isPresent()) {res.setStatus(301);res.setHeader("location", rendered.headers().firstValue("location").get());return;}if (rendered.statusCode() == 200&& rendered.headers().firstValue("content-type").orElse("").contains("text/html")) {res.setStatus(200);res.setContentType("text/html; charset=utf-8");for (String name : RESPONSE_HEADERS) {rendered.headers().firstValue(name).ifPresent(value -> res.setHeader(name, value));}res.getOutputStream().write(rendered.body());return;}} catch (InterruptedException error) {Thread.currentThread().interrupt();} catch (Exception error) {// Fail open.}chain.doFilter(req, res);}}
Test the integration
1. Send a crawler request
curl -sS -D - -o /dev/null \
-A "Googlebot" \
-H "Accept: text/html" \
"https://your-domain.com/"Look for a successful HTML response and the x-lovablehtml-render-cache header.
2. Confirm browser passthrough
curl -sS -D - -o /dev/null \
-A "Mozilla/5.0" \
-H "Accept: text/html" \
-H "Accept-Language: en-US" \
-H "Sec-Fetch-Mode: navigate" \
-H "Sec-Fetch-Dest: document" \
-H "Upgrade-Insecure-Requests: 1" \
"https://your-domain.com/"This request should be served by your existing website without an Encited render-cache header.
Common errors
Spring Security handles crawler requests first
Keep the Encited filter at the highest precedence and confirm component scanning includes the filter package.
The filter is present but no render request is made
Confirm LOVABLEHTML_API_KEY is available to the running JVM and test a public GET request with Accept: text/html.
Request and security behavior
- Only eligible GET requests for public HTML pages are considered for pre-rendering.
- Along with the public page URL, only the API key and crawler-classification headers are sent to Encited.
- Cookie and Authorization headers are never forwarded to Encited.
- If Encited is unavailable or does not return rendered HTML, the request falls through to your website.
Common questions
How does pre-rendering work with Spring Boot?
A servlet filter detects eligible crawler HTML requests, returns Encited rendered HTML, and delegates all other requests to the normal filter chain.
Where should the Spring Boot pre-rendering filter run?
Run it at highest precedence so crawler requests are evaluated before Spring Security, controllers, and SPA fallback handling.
Does Spring Boot pre-rendering affect API requests?
No. Non-GET and non-HTML requests continue through the normal Spring application without calling Encited.
