Article
In modern application architecture, separating content management from business logic is crucial for scalability and maintainability. Strapi is a leading open-source headless CMS that provides powerful content management capabilities, while Java remains the enterprise standard for robust backend services. Integrating Strapi with Java applications creates a powerful combination where Strapi handles content management and Java handles complex business logic, authentication, and data processing.
What is Strapi?
Strapi is a headless CMS that provides:
- Content Management: RESTful and GraphQL APIs for content
- Content Types: Dynamic content structure definition
- Media Library: File upload and management
- Role-Based Access Control: Fine-grained permissions
- Plugin System: Extensible functionality
- Admin Panel: User-friendly content management interface
Why Strapi with Java?
- Separation of Concerns: Content management vs. business logic
- Developer Experience: Strapi for content editors, Java for developers
- Performance: Java handles heavy processing, Strapi serves content
- Scalability: Independent scaling of CMS and application layers
- Flexibility: Use Strapi's admin UI with Java's enterprise capabilities
Architecture Overview
Content Editors → Strapi Admin → Strapi CMS (REST/GraphQL API) ↓ Java Backend → Strapi Client → Content Consumption → Frontend Applications ↓ Database (PostgreSQL/MySQL)
Setting Up Strapi for Java Integration
1. Strapi Configuration:
// config/api.js module.exports = { rest: { defaultLimit: 25, maxLimit: 100, withCount: true, }, }; // config/database.js module.exports = ({ env }) => ({ connection: { client: 'postgres', connection: { host: env('DATABASE_HOST', 'localhost'), port: env.int('DATABASE_PORT', 5432), database: env('DATABASE_NAME', 'strapi'), user: env('DATABASE_USERNAME', 'strapi'), password: env('DATABASE_PASSWORD', 'strapi'), schema: env('DATABASE_SCHEMA', 'public'), }, debug: false, }, }); // config/middlewares.js module.exports = [ 'strapi::errors', 'strapi::security', 'strapi::cors', 'strapi::poweredBy', 'strapi::logger', 'strapi::query', 'strapi::body', 'strapi::session', 'strapi::favicon', 'strapi::public', ]; 2. Content-Type Example (Article):
// src/api/article/content-types/article/schema.json { "kind": "collectionType", "collectionName": "articles", "info": { "singularName": "article", "pluralName": "articles", "displayName": "Article", "description": "Content articles for the application" }, "options": { "draftAndPublish": true }, "pluginOptions": {}, "attributes": { "title": { "type": "string", "required": true, "unique": true }, "content": { "type": "richtext", "required": true }, "slug": { "type": "uid", "targetField": "title" }, "publishedAt": { "type": "datetime" }, "author": { "type": "relation", "relation": "manyToOne", "target": "plugin::users-permissions.user", "inversedBy": "articles" }, "category": { "type": "relation", "relation": "manyToOne", "target": "api::category.category", "inversedBy": "articles" }, "featuredImage": { "type": "media", "multiple": false, "required": false, "allowedTypes": ["images"] }, "metadata": { "type": "json" } } } Java Strapi Client Implementation
1. Dependencies:
<!-- pom.xml --> <dependencies> <!-- Spring Boot Starters --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <!-- Jackson for JSON --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.datatype</groupId> <artifactId>jackson-datatype-jsr310</artifactId> </dependency> <!-- Caching --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <!-- Resilience4j for circuit breaker --> <dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-spring-boot2</artifactId> <version>2.1.0</version> </dependency> </dependencies>
2. Configuration Properties:
# application.yml strapi: base-url: ${STRAPI_URL:http://localhost:1337} api-token: ${STRAPI_API_TOKEN:your-api-token} timeout: 5000 cache: enabled: true ttl: 300000 # 5 minutes app: content: cache-enabled: true default-page-size: 25 max-page-size: 100 3. Strapi Client Service:
@Service @Slf4j public class StrapiClientService { private final WebClient webClient; private final StrapiConfig config; private final ObjectMapper objectMapper; public StrapiClientService(StrapiConfig config, ObjectMapper objectMapper) { this.config = config; this.objectMapper = objectMapper; this.webClient = WebClient.builder() .baseUrl(config.getBaseUrl()) .defaultHeader("Authorization", "Bearer " + config.getApiToken()) .defaultHeader("Content-Type", "application/json") .build(); } @Retry(name = "strapi-api", fallbackMethod = "fallbackFindAll") @CircuitBreaker(name = "strapi-api", fallbackMethod = "fallbackFindAll") @TimeLimiter(name = "strapi-api") public CompletableFuture<StrapiResponse<Article>> findAllArticles( Map<String, String> filters, String sort, Integer page, Integer pageSize) { return webClient.get() .uri(uriBuilder -> { uriBuilder.path("/api/articles"); addQueryParams(uriBuilder, filters, sort, page, pageSize); return uriBuilder.build(); }) .retrieve() .bodyToMono(new ParameterizedTypeReference<StrapiResponse<Article>>() {}) .toFuture(); } public CompletableFuture<StrapiResponse<Article>> fallbackFindAll( Map<String, String> filters, String sort, Integer page, Integer pageSize, Throwable throwable) { log.warn("Fallback called for findAllArticles due to: {}", throwable.getMessage()); return CompletableFuture.completedFuture(new StrapiResponse<>()); } @Cacheable(value = "articles", key = "#id") public CompletableFuture<StrapiSingleResponse<Article>> findArticleById(Long id) { return webClient.get() .uri("/api/articles/{id}", id) .retrieve() .bodyToMono(new ParameterizedTypeReference<StrapiSingleResponse<Article>>() {}) .toFuture(); } public CompletableFuture<StrapiSingleResponse<Article>> createArticle(Article article) { return webClient.post() .uri("/api/articles") .bodyValue(createStrapiData(article)) .retrieve() .bodyToMono(new ParameterizedTypeReference<StrapiSingleResponse<Article>>() {}) .toFuture(); } public CompletableFuture<StrapiSingleResponse<Article>> updateArticle(Long id, Article article) { return webClient.put() .uri("/api/articles/{id}", id) .bodyValue(createStrapiData(article)) .retrieve() .bodyToMono(new ParameterizedTypeReference<StrapiSingleResponse<Article>>() {}) .toFuture(); } public CompletableFuture<Void> deleteArticle(Long id) { return webClient.delete() .uri("/api/articles/{id}", id) .retrieve() .bodyToMono(Void.class) .toFuture(); } // Media operations public CompletableFuture<StrapiResponse<StrapiMedia>> uploadMedia(MultipartFile file) { return webClient.post() .uri("/api/upload") .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData("files", file.getResource())) .retrieve() .bodyToMono(new ParameterizedTypeReference<StrapiResponse<StrapiMedia>>() {}) .toFuture(); } private void addQueryParams(UriComponentsBuilder uriBuilder, Map<String, String> filters, String sort, Integer page, Integer pageSize) { if (filters != null) { filters.forEach((key, value) -> uriBuilder.queryParam("filters[{0}]", key, value)); } if (sort != null) { uriBuilder.queryParam("sort", sort); } if (page != null) { uriBuilder.queryParam("pagination[page]", page); } if (pageSize != null) { uriBuilder.queryParam("pagination[pageSize]", pageSize); } // Always populate fields uriBuilder.queryParam("populate", "*"); } private Map<String, Object> createStrapiData(Object data) { return Collections.singletonMap("data", data); } } 4. Data Models:
@Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class StrapiResponse<T> { private List<T> data; private StrapiMeta meta; @Data public static class StrapiMeta { private StrapiPagination pagination; @Data public static class StrapiPagination { private Integer page; private Integer pageSize; private Integer pageCount; private Long total; } } } @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class StrapiSingleResponse<T> { private T data; private StrapiMeta meta; } @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class Article { private Long id; private ArticleAttributes attributes; @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public static class ArticleAttributes { private String title; private String content; private String slug; private LocalDateTime publishedAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; private StrapiMedia featuredImage; private Map<String, Object> metadata; private StrapiUser author; private StrapiCategory category; } } @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class StrapiMedia { private Long id; private String name; private String alternativeText; private String caption; private Integer width; private Integer height; private String url; private String previewUrl; private String provider; private Map<String, Object> provider_metadata; private LocalDateTime createdAt; private LocalDateTime updatedAt; } @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class StrapiUser { private Long id; private String username; private String email; private String provider; private Boolean confirmed; private Boolean blocked; private LocalDateTime createdAt; private LocalDateTime updatedAt; } @Data @Builder @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) public class StrapiCategory { private Long id; private String name; private String slug; private String description; private LocalDateTime createdAt; private LocalDateTime updatedAt; } Content Service Layer
1. Article Service:
@Service @Slf4j public class ArticleService { private final StrapiClientService strapiClient; private final CacheManager cacheManager; public ArticleService(StrapiClientService strapiClient, CacheManager cacheManager) { this.strapiClient = strapiClient; this.cacheManager = cacheManager; } @Cacheable(value = "articles", key = "#page + '-' + #size") public Page<Article> getPublishedArticles(int page, int size, String sort) { try { Map<String, String> filters = new HashMap<>(); filters.put("publishedAt", "$notNull"); CompletableFuture<StrapiResponse<Article>> future = strapiClient.findAllArticles(filters, sort, page, size); StrapiResponse<Article> response = future.get(5, TimeUnit.SECONDS); return new PageImpl<>( response.getData(), PageRequest.of(page, size), response.getMeta().getPagination().getTotal() ); } catch (Exception e) { log.error("Failed to fetch articles from Strapi", e); throw new ContentServiceException("Failed to fetch articles", e); } } @Cacheable(value = "articles", key = "#slug") public Article getArticleBySlug(String slug) { try { Map<String, String> filters = new HashMap<>(); filters.put("slug", slug); filters.put("publishedAt", "$notNull"); CompletableFuture<StrapiResponse<Article>> future = strapiClient.findAllArticles(filters, null, 0, 1); StrapiResponse<Article> response = future.get(5, TimeUnit.SECONDS); return response.getData().stream() .findFirst() .orElseThrow(() -> new ArticleNotFoundException("Article not found: " + slug)); } catch (Exception e) { log.error("Failed to fetch article by slug: {}", slug, e); throw new ContentServiceException("Failed to fetch article", e); } } @CacheEvict(value = "articles", allEntries = true) public Article createArticle(CreateArticleRequest request) { try { Article article = Article.builder() .attributes(Article.ArticleAttributes.builder() .title(request.getTitle()) .content(request.getContent()) .publishedAt(request.isPublished() ? LocalDateTime.now() : null) .metadata(request.getMetadata()) .build()) .build(); CompletableFuture<StrapiSingleResponse<Article>> future = strapiClient.createArticle(article); StrapiSingleResponse<Article> response = future.get(5, TimeUnit.SECONDS); return response.getData(); } catch (Exception e) { log.error("Failed to create article", e); throw new ContentServiceException("Failed to create article", e); } } @CacheEvict(value = "articles", allEntries = true) public Article updateArticle(Long id, UpdateArticleRequest request) { try { Article article = Article.builder() .attributes(Article.ArticleAttributes.builder() .title(request.getTitle()) .content(request.getContent()) .publishedAt(request.isPublished() ? LocalDateTime.now() : null) .metadata(request.getMetadata()) .build()) .build(); CompletableFuture<StrapiSingleResponse<Article>> future = strapiClient.updateArticle(id, article); StrapiSingleResponse<Article> response = future.get(5, TimeUnit.SECONDS); // Clear specific cache entry clearArticleCache(id); return response.getData(); } catch (Exception e) { log.error("Failed to update article: {}", id, e); throw new ContentServiceException("Failed to update article", e); } } @CacheEvict(value = "articles", allEntries = true) public void deleteArticle(Long id) { try { CompletableFuture<Void> future = strapiClient.deleteArticle(id); future.get(5, TimeUnit.SECONDS); // Clear specific cache entry clearArticleCache(id); } catch (Exception e) { log.error("Failed to delete article: {}", id, e); throw new ContentServiceException("Failed to delete article", e); } } private void clearArticleCache(Long id) { Cache cache = cacheManager.getCache("articles"); if (cache != null) { cache.evict(id); } } } REST Controller
1. Article Controller:
@RestController @RequestMapping("/api/content/articles") @Slf4j @Validated public class ArticleController { private final ArticleService articleService; public ArticleController(ArticleService articleService) { this.articleService = articleService; } @GetMapping public ResponseEntity<Page<Article>> getArticles( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "publishedAt:desc") String sort) { try { Page<Article> articles = articleService.getPublishedArticles(page, size, sort); return ResponseEntity.ok(articles); } catch (ContentServiceException e) { log.error("Failed to fetch articles", e); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); } } @GetMapping("/{slug}") public ResponseEntity<Article> getArticleBySlug(@PathVariable String slug) { try { Article article = articleService.getArticleBySlug(slug); return ResponseEntity.ok(article); } catch (ArticleNotFoundException e) { return ResponseEntity.notFound().build(); } catch (ContentServiceException e) { log.error("Failed to fetch article: {}", slug, e); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); } } @PostMapping @PreAuthorize("hasRole('EDITOR')") public ResponseEntity<Article> createArticle(@Valid @RequestBody CreateArticleRequest request) { try { Article article = articleService.createArticle(request); return ResponseEntity.status(HttpStatus.CREATED).body(article); } catch (ContentServiceException e) { log.error("Failed to create article", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @PutMapping("/{id}") @PreAuthorize("hasRole('EDITOR')") public ResponseEntity<Article> updateArticle(@PathVariable Long id, @Valid @RequestBody UpdateArticleRequest request) { try { Article article = articleService.updateArticle(id, request); return ResponseEntity.ok(article); } catch (ContentServiceException e) { log.error("Failed to update article: {}", id, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") public ResponseEntity<Void> deleteArticle(@PathVariable Long id) { try { articleService.deleteArticle(id); return ResponseEntity.noContent().build(); } catch (ContentServiceException e) { log.error("Failed to delete article: {}", id, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } } 2. Media Controller:
@RestController @RequestMapping("/api/content/media") @Slf4j public class MediaController { private final StrapiClientService strapiClient; public MediaController(StrapiClientService strapiClient) { this.strapiClient = strapiClient; } @PostMapping("/upload") @PreAuthorize("hasRole('EDITOR')") public ResponseEntity<List<StrapiMedia>> uploadMedia(@RequestParam("files") MultipartFile[] files) { try { List<CompletableFuture<StrapiResponse<StrapiMedia>>> futures = new ArrayList<>(); for (MultipartFile file : files) { futures.add(strapiClient.uploadMedia(file)); } CompletableFuture<Void> allFutures = CompletableFuture.allOf( futures.toArray(new CompletableFuture[0]) ); allFutures.get(30, TimeUnit.SECONDS); // Wait for all uploads List<StrapiMedia> uploadedMedia = futures.stream() .map(future -> { try { return future.get().getData(); } catch (Exception e) { throw new RuntimeException("Upload failed", e); } }) .flatMap(List::stream) .collect(Collectors.toList()); return ResponseEntity.ok(uploadedMedia); } catch (Exception e) { log.error("Failed to upload media", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } } Advanced Features
1. GraphQL Client:
@Service @Slf4j public class StrapiGraphQLService { private final WebClient webClient; public StrapiGraphQLService(StrapiConfig config) { this.webClient = WebClient.builder() .baseUrl(config.getBaseUrl() + "/graphql") .defaultHeader("Authorization", "Bearer " + config.getApiToken()) .build(); } public CompletableFuture<Map<String, Object>> executeQuery(String query, Map<String, Object> variables) { Map<String, Object> requestBody = new HashMap<>(); requestBody.put("query", query); requestBody.put("variables", variables); return webClient.post() .bodyValue(requestBody) .retrieve() .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {}) .toFuture(); } public CompletableFuture<List<Article>> getArticlesWithGraphQL() { String query = """ query GetArticles($page: Int, $pageSize: Int) { articles( pagination: { page: $page, pageSize: $pageSize } sort: ["publishedAt:desc"] publicationState: LIVE ) { data { id attributes { title content slug publishedAt featuredImage { data { attributes { url name } } } author { data { attributes { username email } } } } } } } """; Map<String, Object> variables = Map.of( "page", 0, "pageSize", 10 ); return executeQuery(query, variables) .thenApply(response -> { Map<String, Object> data = (Map<String, Object>) response.get("data"); Map<String, Object> articlesData = (Map<String, Object>) data.get("articles"); List<Map<String, Object>> articlesList = (List<Map<String, Object>>) articlesData.get("data"); return articlesList.stream() .map(this::mapGraphQLToArticle) .collect(Collectors.toList()); }); } private Article mapGraphQLToArticle(Map<String, Object> graphQLData) { // Implementation to map GraphQL response to Article object return Article.builder() .id(Long.valueOf(graphQLData.get("id").toString())) .attributes(Article.ArticleAttributes.builder() .title((String) ((Map<String, Object>) graphQLData.get("attributes")).get("title")) .build()) .build(); } } 2. Webhook Handler:
@RestController @RequestMapping("/webhooks/strapi") @Slf4j public class StrapiWebhookController { private final ArticleService articleService; private final CacheManager cacheManager; public StrapiWebhookController(ArticleService articleService, CacheManager cacheManager) { this.articleService = articleService; this.cacheManager = cacheManager; } @PostMapping("/content-update") public ResponseEntity<Void> handleContentUpdate(@RequestBody StrapiWebhookPayload payload) { log.info("Received webhook from Strapi: {}", payload.getEvent()); try { switch (payload.getEvent()) { case "entry.create": case "entry.update": case "entry.delete": handleEntryWebhook(payload); break; case "media.create": case "media.update": case "media.delete": handleMediaWebhook(payload); break; default: log.warn("Unhandled webhook event: {}", payload.getEvent()); } return ResponseEntity.ok().build(); } catch (Exception e) { log.error("Failed to process webhook", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } private void handleEntryWebhook(StrapiWebhookPayload payload) { String model = payload.getModel(); // Clear relevant caches if ("article".equals(model)) { Cache cache = cacheManager.getCache("articles"); if (cache != null) { cache.clear(); } log.info("Cleared article cache due to webhook event: {}", payload.getEvent()); } } private void handleMediaWebhook(StrapiWebhookPayload payload) { // Handle media-related cache invalidation log.info("Media webhook received: {}", payload.getEvent()); } } @Data @JsonIgnoreProperties(ignoreUnknown = true) class StrapiWebhookPayload { private String event; private String model; private Map<String, Object> entry; private LocalDateTime createdAt; } Configuration and Caching
1. Cache Configuration:
@Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager(); cacheManager.setCaffeine(caffeineCacheBuilder()); return cacheManager; } private Caffeine<Object, Object> caffeineCacheBuilder() { return Caffeine.newBuilder() .initialCapacity(100) .maximumSize(500) .expireAfterWrite(10, TimeUnit.MINUTES) .recordStats(); } } 2. Resilience4j Configuration:
# application.yml resilience4j: circuitbreaker: instances: strapi-api: register-health-indicator: true sliding-window-type: COUNT_BASED sliding-window-size: 10 failure-rate-threshold: 50 wait-duration-in-open-state: 10s permitted-number-of-calls-in-half-open-state: 3 retry: instances: strapi-api: max-attempts: 3 wait-duration: 2s timelimiter: instances: strapi-api: timeout-duration: 5s
Best Practices
1. Error Handling:
@RestControllerAdvice @Slf4j public class ContentExceptionHandler { @ExceptionHandler(ContentServiceException.class) public ResponseEntity<ErrorResponse> handleContentServiceException(ContentServiceException e) { log.error("Content service error", e); ErrorResponse error = ErrorResponse.builder() .code("CONTENT_SERVICE_ERROR") .message("Content service temporarily unavailable") .timestamp(Instant.now()) .build(); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error); } @ExceptionHandler(ArticleNotFoundException.class) public ResponseEntity<ErrorResponse> handleArticleNotFound(ArticleNotFoundException e) { ErrorResponse error = ErrorResponse.builder() .code("ARTICLE_NOT_FOUND") .message(e.getMessage()) .timestamp(Instant.now()) .build(); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); } } 2. Health Check:
@Component public class StrapiHealthIndicator implements HealthIndicator { private final StrapiClientService strapiClient; public StrapiHealthIndicator(StrapiClientService strapiClient) { this.strapiClient = strapiClient; } @Override public Health health() { try { // Try to fetch a single article to test connectivity CompletableFuture<StrapiResponse<Article>> future = strapiClient.findAllArticles(Map.of(), "id", 0, 1); StrapiResponse<Article> response = future.get(5, TimeUnit.SECONDS); return Health.up() .withDetail("strapi", "connected") .withDetail("articlesCount", response.getData().size()) .build(); } catch (Exception e) { return Health.down() .withDetail("strapi", "disconnected") .withDetail("error", e.getMessage()) .build(); } } } Conclusion
Integrating Strapi with Java applications creates a powerful content management architecture that leverages the strengths of both platforms. Strapi provides an excellent content management experience with its admin UI and flexible content types, while Java brings enterprise-grade reliability, performance, and complex business logic capabilities.
This architecture enables teams to deliver content-rich applications quickly while maintaining the robustness and scalability required for enterprise use cases. The combination of Strapi's headless CMS with Java's backend services creates a modern, scalable foundation for content-driven applications.