These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Java?
Java is a general-purpose, object-oriented programming language that runs on the Java Virtual Machine (JVM). It's a mainstay in enterprise backend development, Android applications, and large-scale distributed systems. Current releases highlighted here are Java 25 and Java 21, both of which include java.net.http.HttpClient, a built-in HTTP client standardized in Java 11 that supports synchronous and asynchronous requests, HTTP/2, and CompletableFuture-based async patterns.
Java's ecosystem includes mature HTTP client libraries like OkHttp, Apache HttpClient, and Spring's WebClient, along with JSON processing libraries like Jackson and Gson. This makes it well-suited for consuming REST APIs from external services, including headless CMS platforms like Strapi.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Java with Strapi
Java handles business logic, enterprise integrations, and existing services. Strapi handles content modeling and delivery. Together, they let Java teams consume managed content without building a custom CMS from scratch.
- Auto-generated APIs with no manual wiring. Every content type defined in Strapi's Content-Type Builder automatically gets REST endpoints, and if the GraphQL plugin is installed, corresponding GraphQL queries are automatically added to the schema and exposed through the
/graphqlendpoint. Java clients start consuming content immediately, with no route definitions or controller boilerplate on the CMS side. - Full admin panel without custom build cost. Strapi v5 ships with a Content Manager, Content-Type Builder, Draft & Publish, and Content History. Each represents weeks of development if built custom. Editors work in Strapi's UI; Java services focus on business logic.
- Production-ready access control. API Tokens with scoped permissions (read-only, full-access, custom) give Java services least-privilege access. Role-based access control operates at the content-type and operation level without building a custom permissions engine.
- Flattened v5 response format reduces Java boilerplate. The v5 response format eliminates the nested
data.attributeswrapper from v4, directly reducing the number of intermediate wrapper classes in Java POJO hierarchies. - Media management included. The Media Library exposes
/api/uploadendpoints for programmatic file management. Java applications upload, retrieve, and associate media assets without a separate Digital Asset Manager. - Extensible plugin ecosystem. Plugins are available in the Strapi Marketplace for SEO, e-commerce, and more. Built-in i18n handles multi-locale content at the Strapi layer, reducing locale-switching logic in Java code.
How to Integrate Java with Strapi
Prerequisites
Before writing any Java code, you need a running Strapi v5 instance with content to consume.
Strapi side:
- Node.js 20.x is recommended, with 18.x as the minimum for Strapi 5.
- Strapi v5 installed and running
- At least one content type defined with published entries
- Public API permissions configured (Settings → Users & Permissions → Roles → Public)
Java side:
- Java 21 (a release with built-in
HttpClient) - Maven or Gradle
- Jackson Databind
2.20.1for JSON processing
Scaffold Strapi if you don't have an instance running:
npx create-strapi@latest my-strapi-backendAfter setup, create an Article content type in the Content-Type Builder with fields like title (Text), slug (Text), and content (Rich Text). Add a few entries and publish them: entries in draft state are not returned by the REST API by default. Then grant find and findOne permissions under Settings → Users & Permissions → Roles → Public for your content type.
Verify the endpoint works before touching Java:
curl http://localhost:1337/api/articlesStep 1: Set Up the Java Project
Add Jackson Databind to your pom.xml. The built-in java.net.http.HttpClient requires no Maven dependency since it ships with Java 11+.
<dependencies>
<!-- Jackson Databind for JSON serialization/deserialization -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.20.1</version>
</dependency>
</dependencies>Create a StrapiClient class with shared constants and a reusable HttpClient instance. The HttpClient is immutable and thread-safe; create it once and reuse it across all requests.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.Map;
public class StrapiClient {
private static final String BASE_URL = "http://localhost:1337";
private static final HttpClient httpClient = HttpClient.newHttpClient();
private static final ObjectMapper objectMapper = new ObjectMapper();
}Step 2: Authenticate with Strapi
Strapi v5 supports multiple authentication methods, including JSON Web Token (JWT) tokens for user sessions and API Tokens for service-to-service calls.
For Java backend services, API Tokens are the recommended approach. Generate one in the Admin Panel under Settings → API Tokens. Three scope levels are available: read-only, full-access, and custom.
Pass the token on every request:
Authorization: bearer <your-api-token>If your use case requires user authentication (for example, a Java backend serving a login flow), use JWT authentication via POST /api/auth/local:
/**
* Authenticates with Strapi and returns a JWT token.
* Use API Tokens instead for service-to-service calls.
*/
public static String login(String identifier, String password) throws Exception {
String loginBody = objectMapper.writeValueAsString(
Map.of("identifier", identifier, "password", password)
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/api/auth/local"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(loginBody))
.build();
HttpResponse<String> response =
httpClient.send(request, BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException(
"Login failed: HTTP " + response.statusCode()
+ " — " + response.body()
);
}
JsonNode root = objectMapper.readTree(response.body());
return root.get("jwt").asText();
}For a read-only Java service consuming public content, a read-only token follows least-privilege principles and reduces risk if the token is accidentally exposed in logs.
Step 3: Fetch Content (GET Requests)
This is where Strapi v5's flattened response format matters. Fields sit directly on data.fieldName with no data.attributes wrapper. The documentId is a string, not a numeric ID:
{
"data": [
{
"id": 1,
"documentId": "znrlzntu9ei5onjvwfaalu2v",
"title": "Test Article",
"slug": "test-article",
"createdAt": "2024-03-06T13:42:05.098Z",
"publishedAt": "2024-03-06T13:42:05.103Z",
"locale": "en"
}
],
"meta": {
"pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 }
}
}Fetch all entries in a collection:
/**
* GET /api/restaurants — fetches all entries.
* Response uses v5 flattened format: data[].fieldName (no attributes wrapper).
*/
public static JsonNode getRestaurants(String token) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/api/restaurants"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.GET()
.build();
HttpResponse<String> response =
httpClient.send(request, BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException(
"GET failed: HTTP " + response.statusCode()
+ " — " + response.body()
);
}
JsonNode root = objectMapper.readTree(response.body());
JsonNode data = root.get("data");
for (JsonNode entry : data) {
System.out.println(
"id: " + entry.get("id").asInt()
+ " | documentId: " + entry.get("documentId").asText()
+ " | Name: " + entry.get("Name").asText()
);
}
return root;
}Fetch a single entry by documentId:
/**
* GET /api/restaurants/:documentId
* The path parameter in v5 is the documentId string, not a numeric id.
*/
public static JsonNode getRestaurantByDocumentId(String documentId, String token)
throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/api/restaurants/" + documentId))
.header("Authorization", "Bearer " + token)
.GET()
.build();
HttpResponse<String> response =
httpClient.send(request, BodyHandlers.ofString());
if (response.statusCode() == 404) {
throw new RuntimeException("Not found: documentId=" + documentId);
}
if (response.statusCode() != 200) {
throw new RuntimeException(
"GET failed: HTTP " + response.statusCode()
+ " — " + response.body()
);
}
return objectMapper.readTree(response.body()).get("data");
}Query parameters give you fine-grained control over results. Strapi v5 uses LHS bracket syntax for filtering, sorting, pagination, and population:
GET /api/restaurants?filters[stars][$gte]=3&filters[open][$eq]=true
GET /api/articles?sort=publishedAt:desc&pagination[page]=1&pagination[pageSize]=25
GET /api/articles?fields[0]=title&fields[1]=slug&populate[category]=true
GET /api/articles?status=publishedOne thing that trips up nearly every developer on first contact: Strapi does not populate relations by default. You need to explicitly request them with the populate parameter. Forget this, and your Java client receives empty fields for all relational data.
Step 4: Create Content (POST Requests)
POST requests should follow the request body format documented for the specific Strapi API you are using. Strapi v5 validates input by default, so payloads that don't match the content-type schema return HTTP 400.
/**
* POST /api/restaurants — creates a new entry.
* The request body must include a "data" wrapper.
* Returns the new entry's documentId.
*/
public static String createRestaurant(String name, String description, String token)
throws Exception {
String requestBody = objectMapper.writeValueAsString(
Map.of(
"data", Map.of(
"Name", name,
"Description", description
)
)
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/api/restaurants"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response =
httpClient.send(request, BodyHandlers.ofString());
if (response.statusCode() != 200 && response.statusCode() != 201) {
throw new RuntimeException(
"POST failed: HTTP " + response.statusCode()
+ " — " + response.body()
);
}
JsonNode created = objectMapper.readTree(response.body()).get("data");
String newDocumentId = created.get("documentId").asText();
System.out.println("Created entry. documentId: " + newDocumentId);
return newDocumentId;
}Step 5: Update Content (PUT Requests)
PUT requests target a specific documentId in the URL path. Include only the fields you're updating.
/**
* PUT /api/restaurants/:documentId — updates an existing entry.
* Path param is the documentId string. Body requires "data" wrapper.
*/
public static void updateRestaurant(String documentId, String newName, String token)
throws Exception {
String requestBody = objectMapper.writeValueAsString(
Map.of("data", Map.of("Name", newName))
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/api/restaurants/" + documentId))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response =
httpClient.send(request, BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException(
"PUT failed: HTTP " + response.statusCode()
+ " — " + response.body()
);
}
System.out.println("Updated documentId=" + documentId + " → HTTP " + response.statusCode());
}Step 6: Delete Content (DELETE Requests)
DELETE also targets :documentId. In Strapi v5, the REST DELETE endpoint for a collection entry typically returns HTTP 204 (No Content) on success, not the deleted entry data.
/**
* DELETE /api/restaurants/:documentId — removes an entry.
* Strapi v5 returns HTTP 204 on success and does not return any data in the response body.
*/
public static void deleteRestaurant(String documentId, String token) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/api/restaurants/" + documentId))
.header("Authorization", "Bearer " + token)
.DELETE()
.build();
HttpResponse<String> response =
httpClient.send(request, BodyHandlers.ofString());
if (response.statusCode() != 204) {
throw new RuntimeException(
"DELETE failed: HTTP " + response.statusCode()
+ " — " + response.body()
);
}
System.out.println("Deleted documentId=" + documentId + " → HTTP " + response.statusCode());
}Step 7: Run the Full CRUD Cycle
Tie all the operations together in a main() method to verify the integration end to end:
public static void main(String[] args) throws Exception {
// Authenticate — use API Token for production, JWT for testing
String token = StrapiClient.login("admin@example.com", "yourpassword");
System.out.println("Authenticated. JWT acquired.");
// GET all entries
getRestaurants(token);
// POST — create entry, capture documentId
String docId = createRestaurant("Java Café", "A café for Java developers", token);
// GET single entry by documentId
JsonNode entry = getRestaurantByDocumentId(docId, token);
System.out.println("Fetched:\n" + entry.toPrettyString());
// PUT — update using documentId
updateRestaurant(docId, "Java Café (Updated)", token);
// DELETE — remove using documentId
deleteRestaurant(docId, token);
}Project Example: Blog Reader Platform with Spring Boot
This project demonstrates a practical architecture where Strapi v5 manages editorial content and a Spring Boot application fetches and serves it. Editors work in Strapi's Admin Panel; the Java backend applies business logic, caching, and exposes clean API endpoints for the frontend.
Data flow:
[Editor] → Strapi Admin Panel → Strapi v5 (PostgreSQL)
↓ REST: GET /api/articles
Spring Boot BlogService.java
↓ Java POJOs
BlogController.java → /blog endpoints
↓
Thymeleaf / React / AngularStrapi Content Type: Article
| Field | Type | Notes |
|---|---|---|
title | Text | Required |
slug | Text | Required, Unique |
content | Rich Text (Blocks) | Main body |
category | Enumeration | tutorial, news |
publishedAt | Auto-managed | Draft & Publish |
Define this content type in the Content-Type Builder, add several entries, and publish them before proceeding.
Project structure:
blog-reader/
├── src/main/java/com/example/blogreader/
│ ├── BlogReaderApplication.java
│ ├── config/
│ │ └── RestTemplateConfig.java
│ ├── model/
│ │ ├── Article.java
│ │ ├── StrapiResponse.java
│ │ └── Meta.java
│ ├── service/
│ │ └── BlogService.java
│ └── controller/
│ └── BlogController.java
└── src/main/resources/
└── application.propertiesJava data models (v5-compatible):
The Article class maps fields directly at the top level with no attributes nesting. The documentId is a String, not an integer. Including locale reflects Strapi v5's core i18n feature, which adds a locale field to API responses, but the official docs do not state that it must be modeled to avoid deserialization issues.
import java.util.List;
// Generic envelope for Strapi v5 list responses
public class StrapiResponse<T> {
private List<T> data;
private Meta meta;
public List<T> getData() { return data; }
public void setData(List<T> data) { this.data = data; }
public Meta getMeta() { return meta; }
public void setMeta(Meta meta) { this.meta = meta; }
}
// Pagination metadata
public class Meta {
private Pagination pagination;
public Pagination getPagination() { return pagination; }
public void setPagination(Pagination pagination) { this.pagination = pagination; }
}
public class Pagination {
private int page;
private int pageSize;
private int pageCount;
private int total;
// getters and setters
}// Article.java — fields flattened at top level per v5 format
public class Article {
private String documentId; // String, not int — v5 requirement
private String title;
private String slug;
private String content;
private String category;
private String publishedAt;
private String locale;
public String getDocumentId() { return documentId; }
public void setDocumentId(String documentId) { this.documentId = documentId; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public String getPublishedAt() { return publishedAt; }
public void setPublishedAt(String publishedAt) { this.publishedAt = publishedAt; }
public String getLocale() { return locale; }
public void setLocale(String locale) { this.locale = locale; }
}application.properties:
strapi.base-url=http://localhost:1337
strapi.api-token=your-read-only-api-token-hereRestTemplateConfig.java:
// RestTemplateConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}BlogService.java:
The service layer handles all HTTP communication with Strapi. Note the ?status=published parameter, which keeps the query focused on published items.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Collections;
import java.util.List;
@Service
public class BlogService {
private final RestTemplate restTemplate;
@Value("${strapi.base-url}")
private String strapiBaseUrl;
@Value("${strapi.api-token}")
private String apiToken;
public BlogService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public List<Article> getPublishedArticles() {
String url = strapiBaseUrl
+ "/api/articles?sort=publishedAt:desc&status=published";
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
var response = restTemplate.exchange(
url,
HttpMethod.GET,
entity,
new ParameterizedTypeReference<StrapiResponse<Article>>() {}
);
return response.getBody() != null
? response.getBody().getData()
: Collections.emptyList();
}
public Article getArticleByDocumentId(String documentId) {
String url = strapiBaseUrl + "/api/articles/" + documentId;
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
var response = restTemplate.exchange(
url,
HttpMethod.GET,
entity,
new ParameterizedTypeReference<StrapiSingleResponse<Article>>() {}
);
return response.getBody() != null
? response.getBody().getData()
: null;
}
public List<Article> getArticlesByCategory(String category) {
String url = strapiBaseUrl
+ "/api/articles?filters[category][$eq]=" + category
+ "&sort=publishedAt:desc&status=published";
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
var response = restTemplate.exchange(
url,
HttpMethod.GET,
entity,
new ParameterizedTypeReference<StrapiResponse<Article>>() {}
);
return response.getBody() != null
? response.getBody().getData()
: Collections.emptyList();
}
}You also need a single-item response wrapper:
// StrapiSingleResponse.java — for single-entry endpoints
public class StrapiSingleResponse<T> {
private T data;
private Meta meta;
public T getData() { return data; }
public void setData(T data) { this.data = data; }
public Meta getMeta() { return meta; }
public void setMeta(Meta meta) { this.meta = meta; }
}BlogController.java:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class BlogController {
private final BlogService blogService;
public BlogController(BlogService blogService) {
this.blogService = blogService;
}
@GetMapping("/blog")
public List<Article> listArticles() {
return blogService.getPublishedArticles();
}
@GetMapping("/blog/{documentId}")
public Article getArticle(@PathVariable String documentId) {
return blogService.getArticleByDocumentId(documentId);
}
@GetMapping("/blog/category/{category}")
public List<Article> listByCategory(@PathVariable String category) {
return blogService.getArticlesByCategory(category);
}
}Architecture note: Consider adding a dto/ProductDTO.java layer that strips CMS metadata (documentId, locale, publishedAt) from the public response. This decouples your frontend API contract from Strapi's response shape. If Strapi's structure changes in a future version, the impact doesn't cascade to your consumers.
Deployment topology:
Run Strapi and Spring Boot as separate services, ideally orchestrated with Docker Compose:
[External clients]
↓
[Reverse proxy / [nginx](https://nginx.org/)]
↓
[Spring Boot JVM container] ← HTTP → [Strapi Node.js container] ← → [PostgreSQL]Strapi defaults to port 1337 and Spring Boot to 8080. For service-to-service calls within the Docker network, use API Tokens rather than admin JWTs.
You now have a working pattern for connecting Java applications to Strapi v5. From here, explore adding webhook listeners for real-time content updates, implementing response caching with Spring's @Cacheable, or extending the blog reader with search and pagination. The Strapi documentation covers additional features like custom controllers and middleware that can further optimize your integration.
Strapi Open Office Hours
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Java documentation.