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

→ HTTP Request (JSON)
← HTTP Response (JSON)

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
Pro Tip: Think of your API as a product. The frontend developer is your customer. Make their experience smooth, and they'll love working with you.

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"
  }
}
Common Mistake: Starting to code without agreeing on the contract first. This leads to constant back-and-forth changes and frustration on both sides.

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: "..."}
Pro Tip: Most Spring Boot projects return DTOs directly. Only add wrappers if you have a specific need for metadata like pagination info or success flags.

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));
    }
}
Common Mistake: Returning 200 OK for errors with {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"
  }
}
Pro Tip: Field-specific errors help the frontend highlight exactly which input fields have problems. This creates a better user experience than generic error messages.

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);
Security Warning: Never use .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));
}
Pro Tip: Include total counts so the frontend can show "Page 1 of 8" and build pagination controls.

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;
}
Security Risk: Returning entities directly can expose sensitive data, cause circular reference errors with relationships, and leak internal implementation details.

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

Pro Tip: Swagger UI lets the frontend developer test your API directly in the browser without writing any code. This speeds up integration significantly.

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
← Back to Blog