# Spring Boot Integration Patterns for HTMX

Complete reference for integrating HTMX with Spring Boot 2.6+ applications.

## Table of Contents

1. [Request Detection](#request-detection)
2. [Response Headers](#response-headers)
3. [Controller Patterns](#controller-patterns)
4. [View Resolution](#view-resolution)
5. [Error Handling](#error-handling)
6. [Validation](#validation)
7. [Redirects](#redirects)
8. [Testing](#testing)

---

## Request Detection

### @HxRequest Annotation

Conditionally handle HTMX requests using `@HxRequest`:

```java
@Controller
@RequestMapping("/users")
public class UserController {

    // Responds only to HTMX requests
    @HxRequest
    @GetMapping
    public String listUsers(Model model) {
        model.addAttribute("users", userService.findAll());
        return "fragments/user-list"; // Returns fragment
    }

    // Responds to non-HTMX requests
    @GetMapping
    public String listUsersPage(Model model) {
        model.addAttribute("users", userService.findAll());
        return "user/list"; // Returns full page
    }
}
```

### Trigger-Based Routing

Route based on triggering element ID:

```java
@HxRequest(value = "edit-button")
@GetMapping("/{id}/edit")
public String editForm(@PathVariable Long id, Model model) {
    model.addAttribute("user", userService.findById(id));
    return "fragments/user-edit-form";
}

@HxRequest(value = "cancel-button")
@GetMapping("/{id}")
public String cancelEdit(@PathVariable Long id, Model model) {
    model.addAttribute("user", userService.findById(id));
    return "fragments/user-details";
}
```

**Corresponding HTML:**

```html
<button id="edit-button" hx-get="/users/123/edit" hx-target="#user-container">
  Edit
</button>
<button id="cancel-button" hx-get="/users/123" hx-target="#user-container">
  Cancel
</button>
```

### Target-Based Routing

Route based on target element:

```java
@HxRequest(target = "user-details")
@GetMapping("/{id}")
public String userDetails(@PathVariable Long id, Model model) {
    model.addAttribute("user", userService.findById(id));
    return "fragments/user-details";
}

@HxRequest(target = "user-summary")
@GetMapping("/{id}")
public String userSummary(@PathVariable Long id, Model model) {
    model.addAttribute("user", userService.findById(id));
    return "fragments/user-summary-card";
}
```

### Programmatic Detection

Inject `HtmxRequest` for manual checks:

```java
@GetMapping("/dashboard")
public String dashboard(HtmxRequest htmxRequest, Model model) {
    if (htmxRequest.isHtmxRequest()) {
        return "fragments/dashboard-content";
    }
    return "dashboard/full-page";
}
```

---

## Response Headers

### Annotation-Based Headers

Set HTMX response headers using annotations:

```java
// Trigger client-side event
@HxRequest
@HxTrigger("user-updated")
@PostMapping("/{id}")
public String updateUser(@PathVariable Long id, @Valid UserForm form) {
    userService.update(id, form);
    return "fragments/user-card";
}

// Retarget response
@HxRequest
@HxRetarget("#notification-area")
@PostMapping("/{id}")
public String saveUser(@PathVariable Long id, @Valid UserForm form) {
    userService.save(id, form);
    return "fragments/success-notification";
}

// Change swap strategy
@HxRequest
@HxReswap("beforeend")
@PostMapping
public String createUser(@Valid UserForm form, Model model) {
    User user = userService.create(form);
    model.addAttribute("user", user);
    return "fragments/user-row";
}

// Multiple headers combined
@HxRequest
@HxTrigger("item-deleted")
@HxRetarget("#items-count")
@HxReswap("innerHTML")
@DeleteMapping("/{id}")
public String deleteUser(@PathVariable Long id, Model model) {
    userService.delete(id);
    model.addAttribute("count", userService.count());
    return "fragments/items-count";
}
```

### Programmatic Headers with HtmxResponse

Use `HtmxResponse` for dynamic header control:

```java
@HxRequest
@PostMapping
public HtmxResponse createUser(@Valid UserForm form, BindingResult result) {
    if (result.hasErrors()) {
        return HtmxResponse.builder()
            .retarget("#error-container")
            .reswap(HtmxReswap.innerHTML())
            .view(new ModelAndView("fragments/validation-errors",
                  Map.of("errors", result.getAllErrors())))
            .build();
    }

    User user = userService.create(form);

    return HtmxResponse.builder()
        .view(new ModelAndView("fragments/user-card",
              Map.of("user", user)))
        .trigger("user-created")
        .pushUrl("/users/" + user.getId())
        .build();
}
```

### Available Response Headers

| Method                        | HTMX Header               | Description                   |
| ----------------------------- | ------------------------- | ----------------------------- |
| `.trigger(String)`            | `HX-Trigger`              | Trigger client-side event     |
| `.triggerAfterSettle(String)` | `HX-Trigger-After-Settle` | Trigger after content settled |
| `.triggerAfterSwap(String)`   | `HX-Trigger-After-Swap`   | Trigger after swap            |
| `.retarget(String)`           | `HX-Retarget`             | Change target element         |
| `.reswap(HtmxReswap)`         | `HX-Reswap`               | Change swap strategy          |
| `.pushUrl(String)`            | `HX-Push-Url`             | Push URL to browser history   |
| `.replaceUrl(String)`         | `HX-Replace-Url`          | Replace URL without history   |
| `.redirect(String)`           | `HX-Redirect`             | Client-side redirect          |
| `.refresh()`                  | `HX-Refresh`              | Refresh current page          |

### Complex Trigger Events

Trigger events with JSON payload:

```java
@HxRequest
@PostMapping("/items/{id}")
public HtmxResponse updateItem(@PathVariable Long id,
                               @Valid ItemForm form) {
    Item item = itemService.update(id, form);

    // Trigger event with data
    String trigger = "itemUpdated:{\"id\":" + item.getId() +
                    ",\"name\":\"" + item.getName() + "\"}";

    return HtmxResponse.builder()
        .view(new ModelAndView("fragments/item-row",
              Map.of("item", item)))
        .trigger(trigger)
        .build();
}
```

**Client-side handler:**

```html
<script>
  document.body.addEventListener("itemUpdated", function (evt) {
    const data = evt.detail.value; // { id: 123, name: "Item Name" }
    console.log("Item updated:", data);
  });
</script>
```

---

## Controller Patterns

### CRUD Controller Example

Complete CRUD operations with HTMX:

```java
@Controller
@RequestMapping("/items")
public class ItemController {

    private final ItemService itemService;

    public ItemController(ItemService itemService) {
        this.itemService = itemService;
    }

    // CREATE - Show form
    @HxRequest
    @GetMapping("/new")
    public String newItemForm(Model model) {
        model.addAttribute("item", new ItemForm());
        return "fragments/item-form";
    }

    // CREATE - Submit
    @HxRequest
    @PostMapping
    public HtmxResponse createItem(@Valid @ModelAttribute ItemForm form,
                                   BindingResult result,
                                   Model model) {
        if (result.hasErrors()) {
            model.addAttribute("item", form);
            return HtmxResponse.builder()
                .view("fragments/item-form")
                .build();
        }

        Item item = itemService.create(form);
        model.addAttribute("item", item);

        return HtmxResponse.builder()
            .view("fragments/item-row")
            .trigger("item-created")
            .build();
    }

    // READ - List
    @HxRequest
    @GetMapping
    public String listItems(Model model) {
        model.addAttribute("items", itemService.findAll());
        return "fragments/item-list";
    }

    // READ - Single
    @HxRequest
    @GetMapping("/{id}")
    public String getItem(@PathVariable Long id, Model model) {
        model.addAttribute("item", itemService.findById(id));
        return "fragments/item-details";
    }

    // UPDATE - Show form
    @HxRequest
    @GetMapping("/{id}/edit")
    public String editItemForm(@PathVariable Long id, Model model) {
        Item item = itemService.findById(id);
        model.addAttribute("item", ItemForm.fromEntity(item));
        return "fragments/item-form";
    }

    // UPDATE - Submit
    @HxRequest
    @PutMapping("/{id}")
    public HtmxResponse updateItem(@PathVariable Long id,
                                   @Valid @ModelAttribute ItemForm form,
                                   BindingResult result,
                                   Model model) {
        if (result.hasErrors()) {
            model.addAttribute("item", form);
            return HtmxResponse.builder()
                .view("fragments/item-form")
                .build();
        }

        Item item = itemService.update(id, form);
        model.addAttribute("item", item);

        return HtmxResponse.builder()
            .view("fragments/item-row")
            .trigger("item-updated")
            .build();
    }

    // DELETE
    @HxRequest
    @DeleteMapping("/{id}")
    public HtmxResponse deleteItem(@PathVariable Long id) {
        itemService.delete(id);

        return HtmxResponse.builder()
            .trigger("item-deleted")
            .build(); // Empty response removes element
    }
}
```

### REST-style Method Routing

Use `_method` parameter for PUT/DELETE with forms:

```java
@HxRequest
@RequestMapping(value = "/{id}", method = RequestMethod.POST,
                params = "_method=delete")
public HtmxResponse deleteItemFallback(@PathVariable Long id) {
    itemService.delete(id);
    return HtmxResponse.builder()
        .trigger("item-deleted")
        .build();
}
```

**HTML:**

```html
<form hx-post="/items/123" hx-target="closest tr" hx-swap="outerHTML">
  <input type="hidden" name="_method" value="delete" />
  <button type="submit">Delete</button>
</form>
```

---

## View Resolution

### Fragment Rendering

Return Thymeleaf fragments instead of full pages:

```java
@HxRequest
@GetMapping("/users/{id}")
public String userDetails(@PathVariable Long id, Model model) {
    model.addAttribute("user", userService.findById(id));
    return "user/details :: content"; // Returns specific fragment
}
```

**Template (user/details.html):**

```html
<div th:fragment="content">
  <h2 th:text="${user.name}">Name</h2>
  <p th:text="${user.email}">email@example.com</p>
</div>
```

### Out-of-Band Swaps

Update multiple elements in single response:

```java
@HxRequest
@PostMapping("/items")
public View createItem(@Valid ItemForm form, Model model) {
    Item item = itemService.create(form);
    long count = itemService.count();

    model.addAttribute("item", item);
    model.addAttribute("count", count);

    return FragmentsRendering
        .with("fragments/item-row")      // Main target
        .fragment("fragments/item-count") // OOB update
        .build();
}
```

**Templates:**

```html
<!-- fragments/item-row.html -->
<tr>
  <td th:text="${item.name}">Item Name</td>
  <td th:text="${item.price}">0.00</td>
</tr>

<!-- fragments/item-count.html -->
<span id="item-count" hx-swap-oob="true">
  <span th:text="${count}">0</span> items
</span>
```

### Multiple OOB Swaps

Update 3+ elements:

```java
@HxRequest
@PostMapping("/cart/items")
public View addToCart(@Valid CartItemForm form, Model model) {
    CartItem item = cartService.addItem(form);
    Cart cart = cartService.getCurrentCart();

    model.addAttribute("item", item);
    model.addAttribute("cart", cart);
    model.addAttribute("total", cart.getTotal());

    return FragmentsRendering
        .with("fragments/cart-item")       // Main
        .fragment("fragments/cart-count")  // OOB 1
        .fragment("fragments/cart-total")  // OOB 2
        .fragment("fragments/success-msg") // OOB 3
        .build();
}
```

---

## Error Handling

### Global Error Handler

Handle HTMX errors globally:

```java
@ControllerAdvice
public class HtmxErrorHandler {

    @ExceptionHandler(Exception.class)
    public Object handleException(Exception ex,
                                  HtmxRequest htmxRequest,
                                  Model model) {
        if (!htmxRequest.isHtmxRequest()) {
            throw ex; // Let Spring handle non-HTMX
        }

        model.addAttribute("error", ex.getMessage());
        model.addAttribute("timestamp", Instant.now());

        return HtmxResponse.builder()
            .retarget("#error-container")
            .reswap(HtmxReswap.innerHTML())
            .view("fragments/error-message")
            .build();
    }

    @ExceptionHandler(NotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Object handleNotFound(NotFoundException ex,
                                 HtmxRequest htmxRequest,
                                 Model model) {
        if (!htmxRequest.isHtmxRequest()) {
            return "error/404";
        }

        model.addAttribute("message", ex.getMessage());
        return HtmxResponse.builder()
            .retarget("#content")
            .view("fragments/not-found")
            .build();
    }

    @ExceptionHandler(ValidationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Object handleValidation(ValidationException ex,
                                   HtmxRequest htmxRequest,
                                   Model model) {
        if (!htmxRequest.isHtmxRequest()) {
            return "error/400";
        }

        model.addAttribute("errors", ex.getErrors());
        return HtmxResponse.builder()
            .retarget("#validation-errors")
            .reswap(HtmxReswap.innerHTML())
            .view("fragments/validation-errors")
            .build();
    }
}
```

### Controller-Level Error Handling

Handle errors within controller:

```java
@HxRequest
@PostMapping("/items")
public HtmxResponse createItem(@Valid ItemForm form,
                               BindingResult result,
                               Model model) {
    try {
        if (result.hasErrors()) {
            model.addAttribute("errors", result.getAllErrors());
            return HtmxResponse.builder()
                .retarget("#form-errors")
                .view("fragments/validation-errors")
                .build();
        }

        Item item = itemService.create(form);
        model.addAttribute("item", item);

        return HtmxResponse.builder()
            .view("fragments/item-row")
            .trigger("item-created")
            .build();

    } catch (DuplicateItemException ex) {
        model.addAttribute("error", "Item already exists");
        return HtmxResponse.builder()
            .retarget("#error-container")
            .reswap(HtmxReswap.beforeend())
            .view("fragments/error-message")
            .build();
    }
}
```

---

## Validation

### Server-Side Validation

Validate forms and return errors:

```java
@HxRequest
@PostMapping("/users")
public String createUser(@Valid @ModelAttribute UserForm form,
                        BindingResult result,
                        Model model) {
    if (result.hasErrors()) {
        model.addAttribute("user", form);
        return "fragments/user-form"; // Re-render with errors
    }

    User user = userService.create(form);
    model.addAttribute("user", user);
    return "fragments/user-card";
}
```

**Form Template:**

```html
<form hx-post="/users" hx-target="#user-container" hx-swap="outerHTML">
  <div class="form-group">
    <label for="name">Name</label>
    <input
      type="text"
      id="name"
      name="name"
      th:field="*{name}"
      th:classappend="${#fields.hasErrors('name')} ? 'is-invalid' : ''"
    />
    <div th:if="${#fields.hasErrors('name')}" class="invalid-feedback">
      <span th:each="err : ${#fields.errors('name')}" th:text="${err}">
        Error message
      </span>
    </div>
  </div>

  <button type="submit">Create User</button>
</form>
```

### Inline Validation Endpoint

Validate individual fields:

```java
@RestController
@RequestMapping("/validate")
public class ValidationController {

    private final EmailValidator emailValidator;
    private final UserService userService;

    @HxRequest
    @PostMapping("/email")
    public String validateEmail(@RequestParam String email) {
        if (email == null || email.isBlank()) {
            return "<span class='error'>Email is required</span>";
        }

        if (!emailValidator.isValid(email, null)) {
            return "<span class='error'>Invalid email format</span>";
        }

        if (userService.emailExists(email)) {
            return "<span class='error'>Email already taken</span>";
        }

        return "<span class='success'>✓ Available</span>";
    }

    @HxRequest
    @PostMapping("/username")
    public String validateUsername(@RequestParam String username) {
        if (username == null || username.length() < 3) {
            return "<span class='error'>Min 3 characters</span>";
        }

        if (!username.matches("^[a-zA-Z0-9_]+$")) {
            return "<span class='error'>Alphanumeric only</span>";
        }

        if (userService.usernameExists(username)) {
            return "<span class='error'>Username taken</span>";
        }

        return "<span class='success'>✓ Available</span>";
    }
}
```

**HTML:**

```html
<input
  name="email"
  hx-post="/validate/email"
  hx-trigger="blur, keyup changed delay:500ms"
  hx-target="next .validation-message"
  hx-swap="innerHTML"
/>
<span class="validation-message"></span>
```

---

## Redirects

### HtmxRedirectView (Full Page Reload)

Client-side redirect with full page reload:

```java
@HxRequest
@PostMapping("/login")
public HtmxRedirectView login(@Valid LoginForm form) {
    authService.authenticate(form);
    return new HtmxRedirectView("/dashboard");
}
```

### HtmxLocationRedirectView (AJAX Navigation)

Navigate without full reload (HTMX boosting):

```java
@HxRequest
@PostMapping("/users/{id}")
public HtmxLocationRedirectView updateUser(@PathVariable Long id,
                                           @Valid UserForm form) {
    userService.update(id, form);
    return new HtmxLocationRedirectView("/users/" + id);
}
```

### HtmxRefreshView (Refresh Page)

Refresh current page:

```java
@HxRequest
@PostMapping("/settings")
public HtmxRefreshView updateSettings(@Valid SettingsForm form) {
    settingsService.update(form);
    return new HtmxRefreshView();
}
```

### String-based Redirects

Use view name prefixes:

```java
@HxRequest
@PostMapping("/users/{id}")
public String updateUser(@PathVariable Long id,
                        @Valid UserForm form,
                        RedirectAttributes attrs) {
    userService.update(id, form);
    attrs.addFlashAttribute("message", "User updated successfully");
    return "redirect:htmx:/users/" + id;
}
```

---

## Testing

### MockMvc Testing

Test HTMX controllers with MockMvc:

```java
@WebMvcTest(UserController.class)
public class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    public void testCreateUser_HtmxRequest() throws Exception {
        User user = new User(1L, "John Doe", "john@example.com");
        when(userService.create(any())).thenReturn(user);

        mockMvc.perform(post("/users")
                .header("HX-Request", "true")
                .param("name", "John Doe")
                .param("email", "john@example.com"))
            .andExpect(status().isOk())
            .andExpect(header().string("HX-Trigger", "user-created"))
            .andExpect(view().name("fragments/user-card"))
            .andExpect(model().attributeExists("user"));
    }

    @Test
    public void testCreateUser_ValidationErrors() throws Exception {
        mockMvc.perform(post("/users")
                .header("HX-Request", "true")
                .param("name", "") // Invalid
                .param("email", "invalid"))
            .andExpect(status().isOk())
            .andExpect(view().name("fragments/user-form"))
            .andExpect(model().attributeHasFieldErrors("user", "name", "email"));
    }

    @Test
    public void testListUsers_HtmxRequest() throws Exception {
        List<User> users = Arrays.asList(
            new User(1L, "John", "john@example.com"),
            new User(2L, "Jane", "jane@example.com")
        );
        when(userService.findAll()).thenReturn(users);

        mockMvc.perform(get("/users")
                .header("HX-Request", "true"))
            .andExpect(status().isOk())
            .andExpect(view().name("fragments/user-list"))
            .andExpect(model().attribute("users", users));
    }

    @Test
    public void testDeleteUser_HtmxRequest() throws Exception {
        mockMvc.perform(delete("/users/1")
                .header("HX-Request", "true"))
            .andExpect(status().isOk())
            .andExpect(header().string("HX-Trigger", "user-deleted"));
    }
}
```

### Integration Testing

Test full HTMX flow:

```java
@SpringBootTest
@AutoConfigureMockMvc
public class ItemControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ItemRepository itemRepository;

    @BeforeEach
    public void setup() {
        itemRepository.deleteAll();
    }

    @Test
    public void testCreateAndDeleteItem_FullFlow() throws Exception {
        // Create item
        MvcResult createResult = mockMvc.perform(post("/items")
                .header("HX-Request", "true")
                .param("name", "Test Item")
                .param("price", "19.99"))
            .andExpect(status().isOk())
            .andExpect(header().string("HX-Trigger", "item-created"))
            .andReturn();

        // Verify item created in DB
        List<Item> items = itemRepository.findAll();
        assertEquals(1, items.size());
        Item item = items.get(0);
        assertEquals("Test Item", item.getName());

        // Delete item
        mockMvc.perform(delete("/items/" + item.getId())
                .header("HX-Request", "true"))
            .andExpect(status().isOk())
            .andExpect(header().string("HX-Trigger", "item-deleted"));

        // Verify deletion
        assertEquals(0, itemRepository.count());
    }
}
```

---

## Best Practices

1. **Use @HxRequest consistently** - Separate HTMX and full-page endpoints
2. **Return fragments for HTMX** - Don't return full pages for AJAX requests
3. **Validate server-side always** - Never trust client validation
4. **Use HtmxResponse for complex responses** - More control than annotations
5. **Handle errors globally** - Use @ControllerAdvice for consistent error handling
6. **Test with HX-Request header** - Ensure HTMX detection works
7. **Use out-of-band swaps wisely** - Update multiple elements efficiently
8. **Trigger custom events** - Enable decoupled client-side behavior
9. **Implement proper CSRF protection** - Required for security
10. **Use appropriate redirect types** - Match UX expectations
