Thymeleaf CRUD Operation in Spring Boot
In this tutorial, you will build a complete CRUD web application using Spring Boot, Thymeleaf, Spring Data JPA, and MySQL. The application manages Student records with add, list, update, and delete operations, plus form validation using Bean Validation annotations.
You will learn how to use th:object, th:field,
th:each, th:errors, and th:href — the core
Thymeleaf attributes for building dynamic server-rendered pages.
Project Structure
The project follows a standard Spring Boot MVC layout:
pom.xml
The Maven configuration with all required dependencies:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.3</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>Spring-Annotation</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Spring-Annotation</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Note: The original project included unused AspectJ dependencies. They have been removed since the tutorial only covers Thymeleaf CRUD.
StudentController.java
The @Controller handles all MVC requests. Methods returning strings resolve
to Thymeleaf templates, and @Valid + BindingResult capture
validation failures.
package com.example.demo.controller;
import java.util.List;
import javax.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.ModelAndView;
import com.example.demo.model.Student;
import com.example.demo.repo.StudentRepository;
@Controller
public class StudentController {
@Autowired
private StudentRepository studentRepository;
@GetMapping("/")
public String welcome(Student student) {
return "home";
}
@GetMapping("/add")
public String add(Student student) {
return "index";
}
@PostMapping("/form")
public String formData(@Valid Student student, BindingResult result, Model model) {
if (result.hasErrors()) {
return "index";
}
studentRepository.save(student);
model.addAttribute("msg", "Successfully Added");
return "index";
}
@PostMapping("/updateform")
public String updateFormData(@Valid Student student, BindingResult result) {
if (result.hasErrors()) {
return "index";
}
studentRepository.save(student);
return "redirect:/students";
}
@GetMapping("/students")
public ModelAndView getAllStudents(ModelAndView mv) {
List<Student> list = studentRepository.findAll();
mv.addObject("students", list);
mv.setViewName("success");
return mv;
}
@GetMapping("/delete/{roll}")
public String delete(@PathVariable int roll) {
Student student = studentRepository.findById(roll).get();
studentRepository.delete(student);
return "redirect:/students";
}
@GetMapping("/update/{roll}")
public ModelAndView edit(@PathVariable int roll, ModelAndView mv) {
Student student = studentRepository.findById(roll).get();
mv.addObject("student", student);
mv.setViewName("updatepage");
return mv;
}
}
Endpoint summary:
GET /— welcome pageGET /add— show add student formPOST /form— handle add submission with validationGET /students— show all studentsGET /update/{roll}— show edit formPOST /updateform— handle update submissionGET /delete/{roll}— delete a student and redirect
Note: .get() on Optional will throw
NoSuchElementException if the ID doesn't exist. In production, use
orElseThrow() with a custom exception.
Student.java (Entity + Validation)
The entity uses Bean Validation annotations to enforce rules on the form fields. Errors are displayed in the Thymeleaf templates.
package com.example.demo.model;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.validation.constraints.Email;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Entity
public class Student {
@NotBlank
@Size(min = 5, max = 30)
private String name;
@Min(value = 1)
@Id
private int roll;
@Email(regexp = "^[a-zA-Z0-9]{1,20}@[a-zA-Z]{1,20}.[a-zA-Z]{2,3}$")
private String email;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getRoll() {
return roll;
}
public void setRoll(int roll) {
this.roll = roll;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
}
What changed:
- Replaced
@NonNull(Spring annotation, not a validation annotation) with@NotBlank(Bean Validation). - Removed
@Component— an entity is not a Spring bean.
StudentRepository.java
package com.example.demo.repo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.example.demo.model.Student;
@Repository
public interface StudentRepository extends JpaRepository<Student, Integer> {
}
JpaRepository provides save(), findAll(),
findById(), delete(), and more — no implementation needed.
SpringAnnotationApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SpringAnnotationApplication {
public static void main(String[] args) {
SpringApplication.run(SpringAnnotationApplication.class, args);
}
}
Note: @EnableAspectJAutoProxy was removed since AspectJ is not used in this CRUD example.
application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/springcrud
spring.datasource.username=root
spring.datasource.password=password
spring.jpa.hibernate.ddl-auto=update
# Thymeleaf (optional, shown for clarity)
spring.thymeleaf.cache=false
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
Note: spring.thymeleaf.cache=false should only be enabled during development. Remove it for production.
head.html
A reusable fragment included in every page. Contains the navigation bar and Bootstrap CDN links.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Thymeleaf CRUD</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
</head>
</html>
<nav class="navbar navbar-inverse btn-primary">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse"
data-target="#myNavbar">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">Thymeleaf CRUD</a>
</div>
<div class="collapse navbar-collapse" id="myNavbar">
<ul class="nav navbar-nav"></ul>
<ul class="nav navbar-nav navbar-right">
<li><a href="/">Home</a></li>
<li><a href="/add">Add Student</a></li>
<li><a href="/students">Student List</a></li>
</ul>
</div>
</div>
</nav>
index.html (Add Student Form)
The add form uses th:object to bind to the student model
attribute, th:field for two-way binding, and th:errors to show
validation messages.
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<span th:insert="head.html"></span>
<div align="center">
<b th:text="${msg}"></b>
</div>
<body>
<div class="container">
<form action="#" th:action="@{/form}" th:object="${student}" method="post">
<div class="form-group">
<label for="roll">Roll:</label>
<input type="text" th:field="*{roll}" id="roll" class="form-control"/>
<p th:if="${#fields.hasErrors('roll')}" th:errors="*{roll}">Roll Error</p>
</div>
<div class="form-group">
<label for="name">Full Name:</label>
<input type="text" th:field="*{name}" id="name" class="form-control"/>
<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name Error</p>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" th:field="*{email}" id="email" class="form-control"/>
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}">Email Error</p>
</div>
<button type="submit" class="btn btn-primary btn-block">Add Student</button>
</form>
</div>
</body>
</html>
Key Thymeleaf attributes used here:
th:object="${student}"— binds the form to the model attributeth:field="*{roll}"— two-way binding for each field${#fields.hasErrors('roll')}— checks if there's a validation errorth:errors="*{roll}"— displays the error message
success.html (Student List)
Lists all students using th:each. Handles the empty case with
${students.isEmpty()}.
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<span th:insert="head.html"></span>
<div class="container table-responsive">
<h1 align="center" th:if="${students.isEmpty()}">No Student Available</h1>
<table class="table table-hover" th:if="${!students.isEmpty()}">
<thead>
<tr>
<th>Roll</th>
<th>Name</th>
<th>Email</th>
<th>Update/Delete</th>
</tr>
</thead>
<tbody>
<tr th:each="student : ${students}">
<td th:text="${student.roll}"></td>
<td th:text="${student.name}"></td>
<td th:text="${student.email}"></td>
<td>
<a th:href="@{/update/{roll}(roll=${student.roll})}"
class="btn btn-primary">Edit</a>
<a th:href="@{/delete/{roll}(roll=${student.roll})}"
class="btn btn-danger">Delete</a>
</td>
</tr>
</tbody>
</table>
</div>
</html>
Key Thymeleaf attributes used here:
th:each="student : ${students}"— iterates over the listth:text="${student.roll}"— safely escapes and displays textth:href="@{/update/{roll}(roll=${student.roll})}"— builds URLs with path variablesth:if— conditionally renders elements
updatepage.html (Update Form)
Similar to the add form, but submits to /updateform instead of
/form.
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<span th:insert="head.html"></span>
<body>
<div class="container">
<form action="#" th:action="@{/updateform}" th:object="${student}" method="post">
<div class="form-group">
<label for="roll">Roll:</label>
<input type="text" th:field="*{roll}" id="roll" class="form-control" readonly/>
<p th:if="${#fields.hasErrors('roll')}" th:errors="*{roll}">Roll Error</p>
</div>
<div class="form-group">
<label for="name">Full Name:</label>
<input type="text" th:field="*{name}" id="name" class="form-control"/>
<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Name Error</p>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" th:field="*{email}" id="email" class="form-control"/>
<p th:if="${#fields.hasErrors('email')}" th:errors="*{email}">Email Error</p>
</div>
<button type="submit" class="btn btn-primary btn-block">Update Student</button>
</form>
</div>
</body>
</html>
Tip: Marking the roll field as readonly prevents users from changing the primary key.
Output Screens
Below are the output screenshots of the running application:
How It Works (Step-by-Step)
-
User opens
/add— controller creates an emptyStudentobject and returns theindexview, which Thymeleaf resolves totemplates/index.html. -
Form is rendered —
th:object="${student}"binds the form to the empty object;th:fieldrenders each input. -
User submits — browser POSTs to
/form. Spring MVC binds parameters to theStudentobject and validates it via@Valid. -
Validation failures — if any rule fails, errors are stored in
BindingResult, and the controller returnsindexagain. The template shows errors viath:errors. -
On success —
studentRepository.save()inserts the row, and a success message is displayed. -
Listing students —
GET /studentsfetches all students and passes them tosuccess.html, which iterates withth:each. -
Update — clicking Edit navigates to
/update/{roll}, which loads the entity and rendersupdatepage.htmlpre-filled with its data. -
Delete — clicking Delete hits
/delete/{roll}, which removes the row and redirects back to the list.
Frequently Asked Questions
What is Thymeleaf in Spring Boot?
Thymeleaf is a server-side Java template engine that renders HTML on the server before sending it to the browser. In Spring Boot, it works with Spring MVC controllers to display dynamic content and handle forms.
How do I use th:object and th:field in Thymeleaf forms?
th:object binds the form to a model attribute (usually an entity), and
th:field binds individual inputs to fields of that object. Together they
handle two-way data binding, validation error display, and form submission automatically.
How do I display validation errors in Thymeleaf?
Use th:if="${#fields.hasErrors('fieldName')}" to check for errors on a
field, and th:errors="*{fieldName}" to display the error message. This
requires @Valid on the controller method parameter and a
BindingResult parameter to capture validation failures.
What is the difference between @Controller and @RestController?
@Controller returns view names (like 'index' or
'success') that Thymeleaf resolves to HTML templates.
@RestController combines @Controller and
@ResponseBody, returning JSON or XML instead of views — used for REST APIs.
Why does my Thymeleaf template not render?
Thymeleaf templates must be placed in src/main/resources/templates/ and have
an .html extension. Also verify that
spring-boot-starter-thymeleaf is on the classpath and that your controller
returns a view name matching the file (without .html).
Conclusion
In this tutorial, you built a complete Thymeleaf CRUD application in Spring
Boot with Spring Data JPA, MySQL, and Bean Validation. You learned the core
Thymeleaf attributes — th:object, th:field,
th:each, th:errors, and th:href — and how they
work together with a Spring MVC @Controller.
You can extend this project with pagination, search filters, file uploads for student
photos, or switch to Spring Boot 3 + Jakarta EE namespaces
(jakarta.persistence.* instead of javax.persistence.*).
Related topics: Thymeleaf Layout Dialect, Spring Boot 3 Migration with Jakarta EE, Thymeleaf Fragments and Includes, Pagination with Spring Data JPA, Spring Boot Form Validation Best Practices
No comments:
Post a Comment