Thymeleaf
Object Binding
Java
Spring Boot
Web Development

How to bind an object list with thymeleaf?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Binding a list of objects in Thymeleaf is straightforward once you separate two use cases: displaying a list and posting an editable list back to Spring MVC. Read-only rendering only needs th:each, but real form binding needs indexed field names so Spring can reconstruct the list on submit.

Render a list with th:each

For display-only pages, put the list into the model and iterate over it in the template.

java
1package com.example.demo.web;
2
3import java.util.List;
4import org.springframework.stereotype.Controller;
5import org.springframework.ui.Model;
6import org.springframework.web.bind.annotation.GetMapping;
7
8@Controller
9public class StudentController {
10    @GetMapping("/students")
11    public String list(Model model) {
12        List<Student> students = List.of(
13            new Student(1L, "Ava", "Math"),
14            new Student(2L, "Mina", "Physics")
15        );
16        model.addAttribute("students", students);
17        return "students";
18    }
19}
20
21record Student(Long id, String name, String major) {}
html
1<table>
2  <tbody>
3    <tr th:each="student : ${students}">
4      <td th:text="${student.id}">1</td>
5      <td th:text="${student.name}">Ava</td>
6      <td th:text="${student.major}">Math</td>
7    </tr>
8  </tbody>
9</table>

That is enough when the page is only reading data.

Use a wrapper object for editable lists

For form submission, do not bind directly to a raw List in most Spring MVC setups. A wrapper object is easier to validate and easier for Thymeleaf to target.

java
1import java.util.ArrayList;
2import java.util.List;
3
4public class StudentForm {
5    private List<StudentInput> students = new ArrayList<>();
6
7    public List<StudentInput> getStudents() {
8        return students;
9    }
10
11    public void setStudents(List<StudentInput> students) {
12        this.students = students;
13    }
14}
15
16public class StudentInput {
17    private Long id;
18    private String name;
19    private String major;
20
21    public Long getId() { return id; }
22    public void setId(Long id) { this.id = id; }
23    public String getName() { return name; }
24    public void setName(String name) { this.name = name; }
25    public String getMajor() { return major; }
26    public void setMajor(String major) { this.major = major; }
27}

Now Spring can bind the form as one object whose students property is a list.

Bind indexed fields in the template

The crucial detail is the field name. Spring expects indexed paths such as students[0].name, students[1].name, and so on.

html
1<form th:action="@{/students/edit}" th:object="${form}" method="post">
2  <table>
3    <tr th:each="student, iter : *{students}">
4      <td>
5        <input type="hidden" th:field="*{students[__${iter.index}__].id}" />
6        <input type="text" th:field="*{students[__${iter.index}__].name}" />
7      </td>
8      <td>
9        <input type="text" th:field="*{students[__${iter.index}__].major}" />
10      </td>
11    </tr>
12  </table>
13  <button type="submit">Save</button>
14</form>

The double-underscore syntax tells Thymeleaf to inject the loop index into the binding path before Spring sees the submitted field name.

Match it with a controller method

Your controller then receives the wrapper object:

java
1import org.springframework.stereotype.Controller;
2import org.springframework.ui.Model;
3import org.springframework.web.bind.annotation.GetMapping;
4import org.springframework.web.bind.annotation.ModelAttribute;
5import org.springframework.web.bind.annotation.PostMapping;
6
7@Controller
8public class StudentEditController {
9    @GetMapping("/students/edit")
10    public String editForm(Model model) {
11        StudentForm form = new StudentForm();
12
13        StudentInput a = new StudentInput();
14        a.setId(1L);
15        a.setName("Ava");
16        a.setMajor("Math");
17
18        StudentInput b = new StudentInput();
19        b.setId(2L);
20        b.setName("Mina");
21        b.setMajor("Physics");
22
23        form.getStudents().add(a);
24        form.getStudents().add(b);
25
26        model.addAttribute("form", form);
27        return "students-edit";
28    }
29
30    @PostMapping("/students/edit")
31    public String submit(@ModelAttribute("form") StudentForm form) {
32        System.out.println(form.getStudents().size());
33        return "redirect:/students";
34    }
35}

If the indexed field names line up, Spring rebuilds the list automatically.

Common Pitfalls

The most common mistake is trying to bind a raw list directly without a wrapper object. That often makes validation and field binding awkward.

Another mistake is forgetting the index in the field path. If every input is named students.name, Spring cannot rebuild separate list elements correctly.

People also mix display iteration and form binding syntax. th:each is for looping, but th:field still needs the exact indexed property path.

Finally, if you support adding or removing rows dynamically in the browser, the submitted indexes must still be consistent when the form is posted.

Summary

  • Use th:each for display-only object lists.
  • Use a wrapper form object when posting a list back to Spring MVC.
  • Bind each input with indexed paths such as students[0].name.
  • 'th:field plus the loop index is the key to reconstructing the list on submit.'
  • Keep indexes consistent if the browser can add or remove rows dynamically.

Course illustration
Course illustration

All Rights Reserved.