Encited logo
Pricing

How to Integrate Pre-rendering with Spring Boot

Integrate pre-rendering with Spring Boot using a high-priority servlet filter that serves crawler-ready HTML before application controllers.

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.

2

Provide the API key

Set LOVABLEHTML_API_KEY in the production environment. The filter reads it without storing the key in source control.

3

Build and deploy Spring

Run your normal Maven or Gradle production deployment.

./mvnw package
EncitedPrerenderFilter.java
CopyDownload
// 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))|(?:(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();
@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);
}
}

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.

Avatar
How can we help?
Get instant answers to your questions or leave a message for an engineer will reach out
Ask AI about Encited
See our docs
Contact support
Leave a message
We'll get back to you soon
Avatar
Ask AI about Encited
Team is also here to help
Thinking
Preview
Drop an image to attach
Powered by ReplyMaven