How to Integrate Pre-rendering with Spring Boot
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#
1. Add the servlet filter#
Save EncitedPrerenderFilter.java in your application source tree. The highest-precedence annotation runs it before controllers.
EncitedPrerenderFilter
// EncitedPrerenderFilter.java
import 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))|(?:\\b(GrokBot|xAI-Grok|xAI-Bot|xAI-SearchBot)\\b)|(?:(PerplexityBot|Perplexity-User))|(?:(Google-InspectionTool|GoogleOther(?:-Image|-Video)?|Googlebot|Google-CloudVertexBot|Storebot-Google|Google-NotebookLM|GoogleAgent-URLContext|GoogleAIOverviewRenderer|Google-Lens|Google-Jetski-Antigravity))|(?:(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|AppleNewsBot))|(?:Bytespider)|(?:(DuckAssistBot|DuckDuckBot))|(?:(cohere-training-data-crawler|cohere-ai|CohereAI))|(?:MistralAI-User)|(?:AI2Bot)|(?:CCBot)|(?:Diffbot)|(?:omgili)|(?:TimpiBot)|(?:YouBot)|(?:ExaSearchBot)|(?:(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))|(?:YelpBot)|(?:(Baiduspider|Baidu))|(?:(Sogou|sogou))|(?:(Yeti|NaverBot|Naver))|(?:SeznamBot)|(?:(Qwantify|Qwant))|(?:Ecosia)|(?:MojeekBot)|(?:Mail\\.RU_Bot)|(?:(Yahoo! Slurp|Y!J-))|(?:(SemrushBot|SEMrush))|(?:(AhrefsBot|AhrefsSiteAudit))|(?:(DotBot|Rogerbot|moz\\.com))|(?:MJ12bot)|(?:Screaming Frog)|(?:SISTRIX)|(?:SEOkicks)|(?:serpstatbot)|(?:RSiteAuditor)|(?:(?:Encited|LvHTML)-SEOAuditBot)|(?:Barkrowler)|(?:HubSpot Crawler)|(?:(AwarioSmartBot|AwarioBot))|(?:DataForSeoBot)|(?:SERanking)|(?:Seobility)", 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");
// Set the LOVABLEHTML_API_KEY environment variable on the host.
private static final String API_KEY = System.getenv("LOVABLEHTML_API_KEY");
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2)).build();
@Override
protected 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);
}
}
2. Build and deploy Spring#
Run your normal Maven or Gradle production deployment.
./mvnw package
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.
If your site sits behind a firewall, bot protection, or rate limiting, those rules block us before this filter helps. Set a render token and add one firewall rule so renders and audit crawls get through.
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.
