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.
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) {}
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.
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.
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:
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.