Automate SEO Audits in Spring Boot with the SEOPeek API
Spring Boot powers thousands of production web applications—from e-commerce storefronts rendered with Thymeleaf to headless CMS backends serving content to React frontends. Every new controller, every template change, every dynamic route is an opportunity for SEO regressions to slip through unnoticed. Missing meta descriptions, broken canonical tags, empty Open Graph images—these problems accumulate silently until your organic traffic drops. This guide shows you how to wire the SEOPeek API into your Spring Boot project with RestTemplate, WebClient, @Scheduled tasks, a custom Actuator health indicator, a Thymeleaf dashboard, CI/CD test integration, and parallel batch scanning with CompletableFuture—so every page is audited automatically and every regression is caught before it reaches production.
- Why Spring Boot apps need automated SEO checks
- Setting up the SEOPeek API client with RestTemplate and WebClient
- Building a @Scheduled task for periodic SEO audits
- Creating a custom Actuator health indicator for SEO score
- Building a Thymeleaf dashboard for SEO results
- CI/CD integration with Maven and Gradle
- Batch URL scanning with CompletableFuture
- FAQ
1. Why Spring Boot Apps Need Automated SEO Checks
Most Java teams treat SEO as a one-time concern. The marketing team writes meta tags during the initial launch, and nobody touches them again. But Spring Boot applications are dynamic. Thymeleaf templates get refactored. New @RequestMapping endpoints appear weekly. Content management systems push new pages daily. Each change can silently break on-page SEO signals that Google relies on to rank your pages.
The Java ecosystem has historically lacked good SEO tooling. PHP developers have Yoast and Laravel middleware. Node.js developers have Lighthouse wrappers. Java developers get—nothing. Running Lighthouse from Java means spawning a Node.js process with ProcessBuilder, managing headless Chrome, and parsing stdout. It is fragile, slow, and painful to maintain.
The SEOPeek API changes this. It is a single HTTP GET endpoint that returns a structured JSON response with an SEO score, grade, and detailed check results. If your Spring Boot app can make an HTTP request—and of course it can—it can audit any URL for SEO in under two seconds.
Here is what an automated Spring Boot SEO audit pipeline gives you:
- Instant regression detection — catch missing titles, broken canonical URLs, and empty meta descriptions before they reach production
- Actuator integration — expose SEO health alongside your database, disk, and Redis health indicators
- CI/CD enforcement — fail Maven or Gradle builds if any page scores below your SEO threshold
- Historical tracking — store audit results in your database and visualize trends in a Thymeleaf dashboard
- Parallel scanning — audit hundreds of URLs concurrently using
CompletableFutureand the Java virtual thread pool
2. Setting Up the SEOPeek API Client
First, add the required dependencies. If you are using Spring Boot 3.x, spring-boot-starter-web gives you RestTemplate and spring-boot-starter-webflux gives you WebClient. You only need one.
Maven (pom.xml)
<!-- For RestTemplate (blocking) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- For WebClient (non-blocking, optional) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Gradle (build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-web'
// Optional: for WebClient
implementation 'org.springframework.boot:spring-boot-starter-webflux'
Now create the configuration properties class so your API settings live in application.yml:
// src/main/java/com/example/seo/config/SeopeekProperties.java
package com.example.seo.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "seopeek")
public record SeopeekProperties(
String baseUrl,
String apiKey,
int timeoutSeconds,
int minScore
) {
public SeopeekProperties {
if (baseUrl == null) {
baseUrl = "https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi/api/v1/audit";
}
if (timeoutSeconds <= 0) {
timeoutSeconds = 30;
}
if (minScore <= 0) {
minScore = 70;
}
}
}
# application.yml
seopeek:
base-url: https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi/api/v1/audit
api-key: "" # Leave empty for free tier (50 audits/day)
timeout-seconds: 30
min-score: 70
Now the core service class. This version uses RestTemplate for simplicity—ideal for scheduled tasks and CLI runners where blocking calls are perfectly fine:
// src/main/java/com/example/seo/service/SeopeekService.java
package com.example.seo.service;
import com.example.seo.config.SeopeekProperties;
import com.example.seo.model.SeoAuditResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.time.Duration;
import java.util.Map;
@Service
@EnableConfigurationProperties(SeopeekProperties.class)
public class SeopeekService {
private static final Logger log = LoggerFactory.getLogger(SeopeekService.class);
private final RestTemplate restTemplate;
private final SeopeekProperties properties;
public SeopeekService(RestTemplateBuilder builder, SeopeekProperties properties) {
this.properties = properties;
this.restTemplate = builder
.connectTimeout(Duration.ofSeconds(properties.timeoutSeconds()))
.readTimeout(Duration.ofSeconds(properties.timeoutSeconds()))
.build();
}
public SeoAuditResult audit(String url) {
URI uri = UriComponentsBuilder
.fromUriString(properties.baseUrl())
.queryParam("url", url)
.build()
.toUri();
log.info("Auditing URL: {}", url);
@SuppressWarnings("unchecked")
Map<String, Object> response = restTemplate.getForObject(uri, Map.class);
if (response == null) {
throw new RuntimeException("Empty response from SEOPeek API for: " + url);
}
return SeoAuditResult.fromMap(url, response);
}
public int getMinScore() {
return properties.minScore();
}
}
And the result model that wraps the API response:
// src/main/java/com/example/seo/model/SeoAuditResult.java
package com.example.seo.model;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public record SeoAuditResult(
String url,
int score,
String grade,
Map<String, CheckResult> checks,
Instant auditedAt
) {
public record CheckResult(boolean pass, String message) {}
@SuppressWarnings("unchecked")
public static SeoAuditResult fromMap(String url, Map<String, Object> raw) {
int score = ((Number) raw.getOrDefault("score", 0)).intValue();
String grade = (String) raw.getOrDefault("grade", "F");
Map<String, Object> rawChecks = (Map<String, Object>) raw.getOrDefault("checks", Collections.emptyMap());
Map<String, CheckResult> checks = rawChecks.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> {
Map<String, Object> c = (Map<String, Object>) e.getValue();
return new CheckResult(
Boolean.TRUE.equals(c.get("pass")),
(String) c.getOrDefault("message", "")
);
}
));
return new SeoAuditResult(url, score, grade, checks, Instant.now());
}
public List<Map.Entry<String, CheckResult>> failingChecks() {
return checks.entrySet().stream()
.filter(e -> !e.getValue().pass())
.collect(Collectors.toList());
}
public boolean passes(int minScore) {
return score >= minScore;
}
}
Tip: If you are building a reactive application with Spring WebFlux, swap RestTemplate for WebClient. The API contract stays the same—just return Mono<SeoAuditResult> instead of SeoAuditResult. We will use WebClient in the batch scanning section later.
3. Building a @Scheduled Task for Periodic SEO Audits
Spring Boot's @Scheduled annotation makes it trivial to run your Java SEO monitoring checks on a recurring basis. Create a component that reads URLs from a configuration file and audits them on a cron schedule:
// src/main/java/com/example/seo/scheduler/SeoAuditScheduler.java
package com.example.seo.scheduler;
import com.example.seo.model.SeoAuditResult;
import com.example.seo.service.SeopeekService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@Component
public class SeoAuditScheduler {
private static final Logger log = LoggerFactory.getLogger(SeoAuditScheduler.class);
private final SeopeekService seopeekService;
@Value("${seopeek.urls-file:classpath:seo-urls.txt}")
private String urlsFile;
public SeoAuditScheduler(SeopeekService seopeekService) {
this.seopeekService = seopeekService;
}
@Scheduled(cron = "${seopeek.cron:0 0 6 * * SUN}") // Every Sunday at 6 AM
public void runWeeklyAudit() {
log.info("Starting scheduled SEO audit...");
List<String> urls = loadUrls();
if (urls.isEmpty()) {
log.warn("No URLs found for SEO audit. Check seopeek.urls-file config.");
return;
}
int failures = 0;
int minScore = seopeekService.getMinScore();
for (String url : urls) {
try {
SeoAuditResult result = seopeekService.audit(url);
if (result.passes(minScore)) {
log.info("[PASS] {}/100 ({}) - {}", result.score(), result.grade(), url);
} else {
log.warn("[FAIL] {}/100 ({}) - {}", result.score(), result.grade(), url);
result.failingChecks().forEach(entry ->
log.warn(" ! {}: {}", entry.getKey(), entry.getValue().message())
);
failures++;
}
Thread.sleep(500); // Be polite to the API
} catch (Exception e) {
log.error("Error auditing {}: {}", url, e.getMessage());
failures++;
}
}
log.info("SEO audit complete. {} failure(s) out of {} URL(s).", failures, urls.size());
}
private List<String> loadUrls() {
try {
return Files.readAllLines(Path.of(urlsFile)).stream()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.toList();
} catch (IOException e) {
log.error("Failed to load URLs from {}: {}", urlsFile, e.getMessage());
return List.of();
}
}
}
Enable scheduling in your main application class:
// src/main/java/com/example/seo/Application.java
package com.example.seo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Create your URL list file at src/main/resources/seo-urls.txt:
# URLs to audit weekly
https://yourapp.com
https://yourapp.com/pricing
https://yourapp.com/features
https://yourapp.com/blog
https://yourapp.com/docs
Configure the cron schedule in application.yml:
# Run every Sunday at 6 AM
seopeek:
cron: "0 0 6 * * SUN"
urls-file: "src/main/resources/seo-urls.txt"
# Or run every day at midnight
# seopeek:
# cron: "0 0 0 * * *"
Important: The Thread.sleep(500) between requests is intentional. The free tier allows 50 audits per day, and rate-limiting your own requests prevents hitting API limits and keeps you a good citizen. On paid plans with higher limits, you can reduce or remove this delay.
4. Custom Actuator Health Indicator for SEO Score
Spring Boot Actuator lets you expose custom health checks at /actuator/health. By creating an SEO health indicator, your monitoring tools (Datadog, Prometheus, Grafana, or a simple uptime checker) can track SEO health alongside database connectivity, disk space, and Redis status.
Add the Actuator dependency if you have not already:
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Now create the health indicator:
// src/main/java/com/example/seo/actuator/SeoHealthIndicator.java
package com.example.seo.actuator;
import com.example.seo.model.SeoAuditResult;
import com.example.seo.service.SeopeekService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
@Component
public class SeoHealthIndicator implements HealthIndicator {
private final SeopeekService seopeekService;
@Value("${seopeek.health-check-url:}")
private String healthCheckUrl;
public SeoHealthIndicator(SeopeekService seopeekService) {
this.seopeekService = seopeekService;
}
@Override
public Health health() {
if (healthCheckUrl == null || healthCheckUrl.isBlank()) {
return Health.unknown()
.withDetail("reason", "No seopeek.health-check-url configured")
.build();
}
try {
SeoAuditResult result = seopeekService.audit(healthCheckUrl);
int minScore = seopeekService.getMinScore();
Health.Builder builder = result.passes(minScore)
? Health.up()
: Health.down();
builder.withDetail("url", result.url())
.withDetail("score", result.score())
.withDetail("grade", result.grade())
.withDetail("minScore", minScore)
.withDetail("failingChecks", result.failingChecks().size())
.withDetail("auditedAt", result.auditedAt().toString());
return builder.build();
} catch (Exception e) {
return Health.down()
.withDetail("error", e.getMessage())
.build();
}
}
}
Configure it in application.yml:
seopeek:
health-check-url: https://yourapp.com
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: always
Now when you hit /actuator/health, you will see your SEO score alongside other health indicators:
{
"status": "UP",
"components": {
"db": { "status": "UP" },
"diskSpace": { "status": "UP" },
"seo": {
"status": "UP",
"details": {
"url": "https://yourapp.com",
"score": 87,
"grade": "B+",
"minScore": 70,
"failingChecks": 2,
"auditedAt": "2026-03-29T14:22:01Z"
}
}
}
}
Get 50 Free SEO Audits Per Day
The SEOPeek API is free for up to 50 audits/day. No API key required. Drop it into your Spring Boot app in minutes.
View Pricing & Get Started →5. Building a Thymeleaf Dashboard for SEO Results
To visualize SEO trends over time, store audit results in your database and render them in a Thymeleaf template. Start with a JPA entity:
// src/main/java/com/example/seo/entity/SeoAuditEntity.java
package com.example.seo.entity;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "seo_audits", indexes = {
@Index(name = "idx_seo_url", columnList = "url"),
@Index(name = "idx_seo_created", columnList = "createdAt")
})
public class SeoAuditEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 2048, nullable = false)
private String url;
@Column(nullable = false)
private int score;
@Column(length = 4, nullable = false)
private String grade;
@Column(columnDefinition = "TEXT")
private String checksJson;
private int failingCheckCount;
@Column(nullable = false)
private Instant createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = Instant.now();
}
// Getters and setters
public Long getId() { return id; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public int getScore() { return score; }
public void setScore(int score) { this.score = score; }
public String getGrade() { return grade; }
public void setGrade(String grade) { this.grade = grade; }
public String getChecksJson() { return checksJson; }
public void setChecksJson(String checksJson) { this.checksJson = checksJson; }
public int getFailingCheckCount() { return failingCheckCount; }
public void setFailingCheckCount(int failingCheckCount) { this.failingCheckCount = failingCheckCount; }
public Instant getCreatedAt() { return createdAt; }
}
// src/main/java/com/example/seo/repository/SeoAuditRepository.java
package com.example.seo.repository;
import com.example.seo.entity.SeoAuditEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SeoAuditRepository extends JpaRepository<SeoAuditEntity, Long> {
Page<SeoAuditEntity> findAllByOrderByCreatedAtDesc(Pageable pageable);
Page<SeoAuditEntity> findByScoreLessThanOrderByCreatedAtDesc(int score, Pageable pageable);
}
Now the controller and Thymeleaf template:
// src/main/java/com/example/seo/controller/SeoDashboardController.java
package com.example.seo.controller;
import com.example.seo.entity.SeoAuditEntity;
import com.example.seo.repository.SeoAuditRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class SeoDashboardController {
private final SeoAuditRepository repository;
public SeoDashboardController(SeoAuditRepository repository) {
this.repository = repository;
}
@GetMapping("/seo/dashboard")
public String dashboard(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size,
Model model
) {
Page<SeoAuditEntity> audits = repository
.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size));
long totalAudits = repository.count();
long failingAudits = repository.findByScoreLessThanOrderByCreatedAtDesc(70, PageRequest.of(0, 1))
.getTotalElements();
model.addAttribute("audits", audits);
model.addAttribute("totalAudits", totalAudits);
model.addAttribute("failingAudits", failingAudits);
model.addAttribute("currentPage", page);
return "seo/dashboard";
}
}
<!-- src/main/resources/templates/seo/dashboard.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>SEO Audit Dashboard</title>
</head>
<body>
<div class="container">
<h1>SEO Audit Dashboard</h1>
<div class="stats">
<div>Total Audits: <strong th:text="${totalAudits}">0</strong></div>
<div>Failing: <strong th:text="${failingAudits}">0</strong></div>
</div>
<table>
<thead>
<tr>
<th>URL</th>
<th>Score</th>
<th>Grade</th>
<th>Issues</th>
<th>Audited</th>
</tr>
</thead>
<tbody>
<tr th:each="audit : ${audits.content}"
th:classappend="${audit.score < 70} ? 'failing' : ''">
<td th:text="${audit.url}">https://example.com</td>
<td th:text="${audit.score}">85</td>
<td th:text="${audit.grade}">B+</td>
<td th:text="${audit.failingCheckCount}">2</td>
<td th:text="${#temporals.format(audit.createdAt, 'yyyy-MM-dd HH:mm')}">2026-03-29</td>
</tr>
</tbody>
</table>
<div class="pagination" th:if="${audits.totalPages > 1}">
<a th:if="${currentPage > 0}"
th:href="@{/seo/dashboard(page=${currentPage - 1})}">« Previous</a>
<span th:text="'Page ' + (${currentPage} + 1) + ' of ' + ${audits.totalPages}"></span>
<a th:if="${currentPage < audits.totalPages - 1}"
th:href="@{/seo/dashboard(page=${currentPage + 1})}">Next »</a>
</div>
</div>
</body>
</html>
Tip: Update your SeoAuditScheduler to persist results after each audit by injecting SeoAuditRepository and calling repository.save(). This way, every scheduled run automatically populates your dashboard with historical data.
6. CI/CD Integration with Maven and Gradle
The most impactful place to catch SEO regressions is in your CI/CD pipeline. Write a Spring Boot integration test that calls the SEOPeek API against your staging environment and asserts minimum scores. If any page fails, the build fails.
// src/test/java/com/example/seo/SeoAuditIntegrationTest.java
package com.example.seo;
import com.example.seo.model.SeoAuditResult;
import com.example.seo.service.SeopeekService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
class SeoAuditIntegrationTest {
@Autowired
private SeopeekService seopeekService;
private static final int MIN_SCORE = 70;
@ParameterizedTest
@ValueSource(strings = {
"https://staging.yourapp.com",
"https://staging.yourapp.com/pricing",
"https://staging.yourapp.com/features",
"https://staging.yourapp.com/blog"
})
void pageMeetsMinimumSeoScore(String url) {
SeoAuditResult result = seopeekService.audit(url);
assertThat(result.score())
.as("SEO score for %s (grade: %s)", url, result.grade())
.isGreaterThanOrEqualTo(MIN_SCORE);
}
@Test
void homepageHasNoFailingCriticalChecks() {
SeoAuditResult result = seopeekService.audit("https://staging.yourapp.com");
// These checks should never fail on the homepage
assertThat(result.checks().get("title").pass())
.as("Homepage must have a title tag")
.isTrue();
assertThat(result.checks().get("meta_description").pass())
.as("Homepage must have a meta description")
.isTrue();
assertThat(result.checks().get("h1").pass())
.as("Homepage must have an H1 tag")
.isTrue();
}
}
Run the tests in your CI pipeline:
# Maven
mvn verify -Dspring.profiles.active=test
# Gradle
gradle test --info -Dspring.profiles.active=test
In a GitHub Actions workflow, this looks like:
# .github/workflows/seo-audit.yml
name: SEO Audit
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
seo-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
cache: 'maven'
- name: Run SEO audit tests
run: mvn verify -pl seo-module -Dspring.profiles.active=test
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: seo-test-results
path: target/surefire-reports/
Pro tip: Use @Tag("seo") on your test class and configure Maven Surefire to include/exclude tags. This lets you run SEO tests separately from unit tests: mvn verify -Dgroups=seo.
7. Batch URL Scanning with CompletableFuture
When you need to audit hundreds of URLs—for example, scanning your entire sitemap after a major deploy—sequential requests are too slow. Java's CompletableFuture lets you run multiple audits in parallel while respecting rate limits. With Java 21 virtual threads, this is even more efficient.
// src/main/java/com/example/seo/service/BatchSeoAuditService.java
package com.example.seo.service;
import com.example.seo.model.SeoAuditResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.Collectors;
@Service
public class BatchSeoAuditService {
private static final Logger log = LoggerFactory.getLogger(BatchSeoAuditService.class);
private final SeopeekService seopeekService;
private final Semaphore rateLimiter;
private final ExecutorService executor;
public BatchSeoAuditService(SeopeekService seopeekService) {
this.seopeekService = seopeekService;
// Limit to 5 concurrent requests to stay within API rate limits
this.rateLimiter = new Semaphore(5);
// Use virtual threads (Java 21+) for efficient I/O-bound concurrency
this.executor = Executors.newVirtualThreadPerTaskExecutor();
}
public List<SeoAuditResult> auditAll(List<String> urls) {
log.info("Starting batch audit of {} URLs with parallel processing...", urls.size());
List<CompletableFuture<SeoAuditResult>> futures = urls.stream()
.map(url -> CompletableFuture.supplyAsync(() -> auditWithRateLimit(url), executor))
.toList();
// Wait for all futures and collect results
List<SeoAuditResult> results = futures.stream()
.map(future -> {
try {
return future.get(60, TimeUnit.SECONDS);
} catch (Exception e) {
log.error("Batch audit failed for a URL: {}", e.getMessage());
return null;
}
})
.filter(result -> result != null)
.collect(Collectors.toList());
long passing = results.stream()
.filter(r -> r.passes(seopeekService.getMinScore()))
.count();
log.info("Batch audit complete. {}/{} URLs passing (min score: {}).",
passing, results.size(), seopeekService.getMinScore());
return results;
}
private SeoAuditResult auditWithRateLimit(String url) {
try {
rateLimiter.acquire();
try {
SeoAuditResult result = seopeekService.audit(url);
Thread.sleep(200); // Small delay between requests
return result;
} finally {
rateLimiter.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while auditing: " + url, e);
}
}
}
Use it to scan a sitemap after deployment:
// src/main/java/com/example/seo/runner/SitemapAuditRunner.java
package com.example.seo.runner;
import com.example.seo.model.SeoAuditResult;
import com.example.seo.service.BatchSeoAuditService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@Profile("sitemap-audit")
public class SitemapAuditRunner implements CommandLineRunner {
private final BatchSeoAuditService batchService;
public SitemapAuditRunner(BatchSeoAuditService batchService) {
this.batchService = batchService;
}
@Override
public void run(String... args) {
List<String> urls = List.of(
"https://yourapp.com",
"https://yourapp.com/pricing",
"https://yourapp.com/features",
"https://yourapp.com/blog",
"https://yourapp.com/docs",
"https://yourapp.com/about",
"https://yourapp.com/contact",
"https://yourapp.com/changelog"
);
List<SeoAuditResult> results = batchService.auditAll(urls);
// Print summary report
System.out.println("\n=== SEO AUDIT REPORT ===\n");
results.forEach(r -> {
String status = r.passes(70) ? "PASS" : "FAIL";
System.out.printf(" [%s] %d/100 (%s) - %s%n",
status, r.score(), r.grade(), r.url());
if (!r.passes(70)) {
r.failingChecks().forEach(entry ->
System.out.printf(" ! %s: %s%n",
entry.getKey(), entry.getValue().message())
);
}
});
long failures = results.stream().filter(r -> !r.passes(70)).count();
System.out.printf("%n%d failure(s) out of %d URL(s).%n", failures, results.size());
if (failures > 0) {
System.exit(1); // Non-zero exit for CI/CD pipelines
}
}
}
Run the sitemap audit from the command line:
# Activate the sitemap-audit profile to trigger the runner
java -jar your-app.jar --spring.profiles.active=sitemap-audit
# Or with Maven
mvn spring-boot:run -Dspring-boot.run.profiles=sitemap-audit
With five concurrent requests and virtual threads, you can audit 100 URLs in under a minute. The Semaphore ensures you never overwhelm the SEOPeek API, and each result is collected into a typed SeoAuditResult record for easy processing.
Java 17 compatibility: If you are not on Java 21 yet, replace Executors.newVirtualThreadPerTaskExecutor() with Executors.newFixedThreadPool(5). The rest of the code works identically. Virtual threads just make the I/O-bound concurrency more efficient.
Need More Than 50 Audits Per Day?
The Starter plan ($9/month) gives you 1,000 audits/day. The Pro plan ($29/month) gives you 10,000. Perfect for batch scanning entire sitemaps in CI/CD.
See All Plans →FAQ
Does the SEOPeek API require an API key for Spring Boot integration?
No. The free tier includes 50 audits per day with no API key required. Just call the endpoint with a url query parameter. For higher volumes, the Starter plan provides 1,000 audits/day at $9/month and the Pro plan provides 10,000 audits/day at $29/month.
Should I use RestTemplate or WebClient to call the SEOPeek API?
For blocking calls inside @Scheduled tasks or CLI runners, RestTemplate is simpler and works well. For reactive applications using Spring WebFlux, or when you need non-blocking parallel requests with CompletableFuture, use WebClient. Both are demonstrated in this guide.
Can I use the SEOPeek Actuator health indicator in production?
Yes, but with caution. The health indicator makes an HTTP call to SEOPeek on each health check. Configure your monitoring tool to poll health endpoints at reasonable intervals (every 5–10 minutes) rather than every few seconds. You can also conditionally enable it using @Profile("!production") or Spring Boot's management configuration.
How does SEOPeek compare to running Lighthouse from Java?
Lighthouse requires a headless Chrome instance and Node.js, which is extremely awkward to manage in a JVM stack. SEOPeek is a single HTTP GET request that returns JSON in under 2 seconds. No browser dependencies, no Node.js, no ProcessBuilder hacks required. Just add spring-boot-starter-web and call the API.
Can I run SEOPeek audits in my Maven or Gradle CI pipeline?
Absolutely. Write an integration test with @SpringBootTest that calls the SEOPeek API and asserts minimum scores. Run it with mvn verify or gradle test. If any page falls below your threshold, the test fails and the build breaks. The guide above includes a complete test class you can copy directly.
The Peek Suite
SEOPeek is part of a family of developer-focused audit tools.