Thymeleaf CRUD opeartion in Spring Boot

Thymeleaf CRUD Operation in Spring Boot (2026 Guide with Validation)

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 page
  • GET /add — show add student form
  • POST /form — handle add submission with validation
  • GET /students — show all students
  • GET /update/{roll} — show edit form
  • POST /updateform — handle update submission
  • GET /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 attribute
  • th:field="*{roll}" — two-way binding for each field
  • ${#fields.hasErrors('roll')} — checks if there's a validation error
  • th: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>&nbsp;
                    <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 list
  • th:text="${student.roll}" — safely escapes and displays text
  • th:href="@{/update/{roll}(roll=${student.roll})}" — builds URLs with path variables
  • th: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)

  1. User opens /add — controller creates an empty Student object and returns the index view, which Thymeleaf resolves to templates/index.html.
  2. Form is rendered — th:object="${student}" binds the form to the empty object; th:field renders each input.
  3. User submits — browser POSTs to /form. Spring MVC binds parameters to the Student object and validates it via @Valid.
  4. Validation failures — if any rule fails, errors are stored in BindingResult, and the controller returns index again. The template shows errors via th:errors.
  5. On success — studentRepository.save() inserts the row, and a success message is displayed.
  6. Listing students — GET /students fetches all students and passes them to success.html, which iterates with th:each.
  7. Update — clicking Edit navigates to /update/{roll}, which loads the entity and renders updatepage.html pre-filled with its data.
  8. 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: