Introduction: Why This Matters
As a backend developer, your code doesn't exist in isolation. Everything you build will be consumed by a frontend application—whether it's a web app, mobile app, or another service. The quality of this collaboration directly impacts the user experience and development speed.
This guide will teach you how to think from the frontend's perspective and build APIs that are easy to integrate, debug, and maintain. We'll cover everything from basic communication to advanced patterns, all with practical Java Spring Boot examples.
FRONTEND
React / Vue / Angular
User Interface
User Interactions
BACKEND
Spring Boot API
Business Logic
Database Operations
Understanding the Frontend Perspective
Before diving into technical details, you need to understand what the frontend developer needs from you:
What Frontend Developers Want
- Predictable responses: Same structure every time, no surprises
- Clear error messages: They need to show users what went wrong
- Consistent naming: camelCase in JSON (not snake_case or PascalCase)
- Proper status codes: So they know how to handle the response
- Documentation: They shouldn't have to guess what fields exist
- CORS configured: So their local development doesn't break
1. The API Contract: Your Agreement
An API contract is an agreement between backend and frontend about how an endpoint works. Before writing any code, you should agree on:
- Endpoint URL:
/api/users - HTTP Method: GET, POST, PUT, DELETE
- Request format: What data to send
- Response format: What data to expect back
- Status codes: What each code means
- Headers: Authentication, content-type, etc.
Example Contract: Create User
Endpoint: POST /api/users
Headers: Content-Type: application/json
Request Body:
{
"name": "Omar Dawoud",
"email": "omar@example.com",
"password": "securePass123"
}
Success Response (201 Created):
{
"success": true,
"message": "User created successfully",
"data": {
"id": 1,
"name": "Omar Dawoud",
"email": "omar@example.com",
"createdAt": "2025-11-19T10:30:00Z"
}
}
Error Response (400 Bad Request):
{
"success": false,
"message": "Validation failed",
"errors": {
"email": "Email already exists",
"password": "Password must be at least 8 characters"
}
}
2. Consistent Response Structure
The frontend needs to know what to expect. While Spring Boot doesn't enforce a specific response structure, it's important to be consistent across all your endpoints.
Option 1: Direct DTO Responses (Most Common)
The simplest and most common approach is to return DTOs directly using ResponseEntity:
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
UserDTO user = userService.findById(id);
return ResponseEntity.ok(user);
}
@GetMapping
public ResponseEntity<List<UserDTO>> getAllUsers() {
List<UserDTO> users = userService.findAll();
return ResponseEntity.ok(users);
}
@PostMapping
public ResponseEntity<UserDTO> createUser(
@Valid @RequestBody CreateUserRequest request) {
UserDTO user = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
}
Option 2: Wrapper for Consistency (Optional)
If you want to include metadata like success flags or messages, you can create a simple wrapper. This is optional but can be useful for frontend error handling:
// Simple response wrapper (optional)
public class ApiResponse<T> {
private T data;
private String message;
public ApiResponse(T data, String message) {
this.data = data;
this.message = message;
}
// Getters and setters
}
// Usage
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<UserDTO>> getUser(@PathVariable Long id) {
UserDTO user = userService.findById(id);
return ResponseEntity.ok(
new ApiResponse<>(user, "User retrieved successfully"));
}
What Matters Most: Consistency
Choose one approach and stick with it across all endpoints. The frontend needs predictability:
| Scenario | ❌ Inconsistent | ✓ Consistent |
|---|---|---|
| Single item | {user: {...}} |
{id: 1, name: "..."} |
| List | {users: [...]} |
[{id: 1, ...}, {id: 2, ...}] |
| Created | {data: {...}} |
{id: 1, name: "..."} |
3. HTTP Status Codes: Speak the Language
Status codes tell the frontend how to handle the response. Using the wrong code confuses the frontend and breaks error handling.
200 OK
Request succeeded. Use for successful GET, PUT, PATCH requests.
201 Created
Resource created successfully. Use for successful POST requests.
204 No Content
Success but no data to return. Use for DELETE requests.
400 Bad Request
Invalid input or validation failed. Return specific error details.
401 Unauthorized
Authentication required or failed. User needs to log in.
403 Forbidden
Authenticated but not authorized. User lacks permissions.
404 Not Found
Resource doesn't exist. Check if ID is valid.
409 Conflict
Resource conflict. E.g., email already exists.
500 Internal Server Error
Something went wrong on the server. Log the error.
Example: Proper Status Code Usage
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<UserDTO>> getUser(@PathVariable Long id) {
try {
User user = userService.findById(id);
return ResponseEntity.ok( // 200 OK
ApiResponse.success("User found", mapToDTO(user)));
} catch (UserNotFoundException e) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND) // 404 Not Found
.body(ApiResponse.error("User not found", null));
}
}
{error: "Something went
wrong"}. This breaks frontend error handling because the request "succeeded" according
to the status code.
4. Error Handling: Help the Frontend Help the User
When something goes wrong, the frontend needs to show a message to the user. Your error responses should make this easy.
Global Exception Handler
@RestControllerAdvice
public class GlobalExceptionHandler {
// Validation errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<?>> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("Validation failed", errors));
}
// Resource not found
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<?>> handleNotFound(
ResourceNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error(ex.getMessage(), null));
}
// Duplicate resource
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ApiResponse<?>> handleDuplicate(
DuplicateResourceException ex) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(ApiResponse.error(ex.getMessage(), null));
}
// Catch-all for unexpected errors
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleGenericError(
Exception ex) {
// Log the error for debugging
log.error("Unexpected error", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(
"An unexpected error occurred", null));
}
}
Validation Error Example
// Request DTO with validation
public class CreateUserRequest {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 50, message = "Name must be 2-50 characters")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
}
// Frontend receives:
{
"success": false,
"message": "Validation failed",
"errors": {
"email": "Email must be valid",
"password": "Password must be at least 8 characters"
}
}
5. CORS: Let the Frontend Talk to You
CORS (Cross-Origin Resource Sharing) is a security feature that prevents websites from making requests to different domains. During development, your frontend (localhost:3000) and backend (localhost:8080) are on different ports, so CORS will block requests unless you configure it.
What is CORS?
When the frontend tries to call your API from a different origin (different domain, port, or protocol), the browser sends a "preflight" request asking if it's allowed. Your backend must respond with the right headers.
// Simple CORS Configuration
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000") // Frontend URL
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
};
}
}
For Production
// Use environment variables for allowed origins
@Value("${cors.allowed-origins}")
private String[] allowedOrigins;
registry.addMapping("/api/**")
.allowedOrigins(allowedOrigins) // From application.properties
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true);
.allowedOrigins("*") with
.allowCredentials(true) in production. Always specify exact origins.
6. Naming Conventions: Speak JavaScript
Java uses camelCase for variables, but JSON should also use camelCase because that's what JavaScript uses. Spring Boot does this automatically, but you need to be aware of it.
Good Naming
// Java class
public class UserDTO {
private Long userId; // camelCase
private String firstName; // camelCase
private String lastName; // camelCase
private String emailAddress; // camelCase
}
// JSON output (automatically converted)
{
"userId": 1,
"firstName": "Omar",
"lastName": "Dawoud",
"emailAddress": "omar@example.com"
}
| Convention | Example | Use For |
|---|---|---|
| camelCase | firstName |
JSON fields, JavaScript variables |
| PascalCase | UserDTO |
Java class names |
| snake_case | first_name |
Database columns (not JSON!) |
| kebab-case | first-name |
URL paths |
7. Pagination: Don't Send Everything
Never return all records at once. If you have 10,000 users, sending them all will crash the frontend. Always paginate large datasets.
Pagination Response Structure
{
"success": true,
"message": "Users retrieved successfully",
"data": {
"content": [
{ "id": 1, "name": "User 1" },
{ "id": 2, "name": "User 2" }
],
"page": 0, // Current page (0-indexed)
"size": 20, // Items per page
"totalElements": 150, // Total items
"totalPages": 8, // Total pages
"first": true, // Is first page?
"last": false // Is last page?
}
}
Spring Boot Implementation
@GetMapping
public ResponseEntity<ApiResponse<Page<UserDTO>>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<User> users = userService.findAll(pageable);
Page<UserDTO> userDTOs = users.map(this::mapToDTO);
return ResponseEntity.ok(
ApiResponse.success("Users retrieved", userDTOs));
}
8. DTOs: Don't Expose Your Entities
Never return your database entities directly. Use Data Transfer Objects (DTOs) to control exactly what data the frontend sees.
Why DTOs Matter
- Security: Don't expose password hashes or internal IDs
- Flexibility: Change database structure without breaking the API
- Performance: Send only what's needed
- Clarity: Frontend knows exactly what fields exist
// Entity (Database)
@Entity
public class User {
private Long id;
private String name;
private String email;
private String passwordHash; // NEVER send this!
private String internalNotes; // NEVER send this!
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
// DTO (API Response)
public class UserDTO {
private Long id;
private String name;
private String email;
private String createdAt; // Formatted as ISO string
// No password, no internal notes!
}
// Mapping
private UserDTO mapToDTO(User user) {
UserDTO dto = new UserDTO();
dto.setId(user.getId());
dto.setName(user.getName());
dto.setEmail(user.getEmail());
dto.setCreatedAt(user.getCreatedAt().toString());
return dto;
}
9. Documentation: Make Life Easy
Good documentation means the frontend developer doesn't have to ask you questions or guess. Use Swagger/OpenAPI to auto-generate interactive API documentation.
Add Swagger to Your Project
<!-- pom.xml -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>
Annotate Your Controllers
@RestController
@RequestMapping("/api/users")
@Tag(name = "Users", description = "User management endpoints")
public class UserController {
@Operation(summary = "Create a new user")
@ApiResponses({
@ApiResponse(responseCode = "201",
description = "User created successfully"),
@ApiResponse(responseCode = "400",
description = "Invalid input")
})
@PostMapping
public ResponseEntity<ApiResponse<UserDTO>> createUser(
@RequestBody CreateUserRequest request) {
// Implementation
}
}
Access the documentation at: http://localhost:8080/swagger-ui.html
10. Testing: Verify Your Contract
Test your endpoints to ensure they return the correct structure and status codes. This prevents breaking the frontend with unexpected changes.
Controller Test Example
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void createUser_Success() throws Exception {
CreateUserRequest request = new CreateUserRequest(
"Omar", "omar@example.com", "password123");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.name").value("Omar"));
}
@Test
void createUser_ValidationError() throws Exception {
CreateUserRequest request = new CreateUserRequest(
"", "invalid-email", "123"); // Invalid data
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.errors").exists());
}
}
Pre-Deployment Checklist
Before you tell the frontend your API is ready, verify these items:
API Contract Checklist
- Endpoint URLs are documented and agreed upon
- Request and response formats are documented
- All responses use consistent structure
- Proper HTTP status codes for all scenarios
- Error messages are clear and actionable
- Validation errors include field-specific details
- CORS is configured for frontend origin
- Pagination is implemented for list endpoints
- DTOs are used (not raw entities)
- Sensitive data is never exposed
- Swagger documentation is up to date
- All endpoints are tested
- JSON uses camelCase naming
- Date/time fields use ISO 8601 format
- Authentication/authorization works correctly
Common Mistakes to Avoid
1. Inconsistent Response Structures
// ❌ BAD: Different structures
GET /api/users → [{...}, {...}]
GET /api/users/1 → {user: {...}}
POST /api/users → {data: {...}}
// ✓ GOOD: Same structure
GET /api/users → {success: true, data: [...]}
GET /api/users/1 → {success: true, data: {...}}
POST /api/users → {success: true, data: {...}}
2. Wrong Status Codes
// ❌ BAD: 200 OK for errors
return ResponseEntity.ok({error: "User not found"});
// ✓ GOOD: Proper status code
return ResponseEntity.status(404).body({...});
3. Exposing Sensitive Data
// ❌ BAD: Returning entity with password
return user; // Contains passwordHash!
// ✓ GOOD: Return DTO without sensitive data
return mapToDTO(user); // No password
4. No Validation
// ❌ BAD: No validation
@PostMapping
public User create(@RequestBody User user) {
return userService.save(user); // What if email is invalid?
}
// ✓ GOOD: Validate input
@PostMapping
public ResponseEntity<?> create(@Valid @RequestBody CreateUserRequest request) {
// Validation happens automatically
}
5. Forgetting CORS
// Frontend console error:
// "Access to fetch at 'http://localhost:8080/api/users'
// from origin 'http://localhost:3000' has been blocked by CORS policy"
// Solution: Configure CORS properly!
Conclusion
Working effectively with frontend developers is a skill that separates good backend developers from great ones. By following these practices, you'll:
- Reduce integration time and back-and-forth communication
- Prevent bugs caused by mismatched expectations
- Create APIs that are easy to use and maintain
- Build better products faster