Build CRUD App using Spring Boot, JPA and Thymeleaf

Spring Boot is one of the widely used Java framework. Thymeleaf is one of popular templating engine which generates HTML.
Let's learn how to build a CRUD web application with Spring Boot and Thymeleaf in this tutorial.
Creating a Spring Boot Application
Create a spring application by right-clicking in the package explorer and selecting New -> Spring Starter Project and specify the project details.
Project Structure
The structure followed in the project is listed below
Maven Dependencies - pom.xml
You need to add the following dependencies to pom.xml:
<?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 http://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>3.1.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>SpringBoot-ThymeleafCrudTutorial</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>SpringBoot-ThymeleafCrudTutorial</name>
<description>Crud Tutorial</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</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-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>2.4.1</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</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>
<configuration>
<image>
<builder>paketobuildpacks/builder-jammy-base:latest</builder>
</image>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
Create JPA Entity
Create a JPA Candidate entity with these fields:
package com.application.thymeleafcrud.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
@Entity
public class Candidate {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
@NotBlank(message = "Name is mandatory")
@Column(name = "name")
private String name;
@NotBlank(message = "Email is mandatory")
@Column(name = "email")
private String email;
@Column(name = "age")
private Integer age;
public Candidate() {}
public Candidate(String name, String email) {
this.name = name;
this.email = email;
}
public void setId(long id) {
this.id = id;
}
public long getId() {
return id;
}
public void setName(String name) {
this.name = name;
}
public void setEmail(String email) {
this.email = email;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
The Repository Layer
Create a CandidateRepository
Interface that extends the JpaRepository
Interface:
package com.application.thymeleafcrud.repository;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
import com.application.thymeleafcrud.entity.Candidate;
@Repository
public interface CandidateRepository extends CrudRepository<Candidate, Long> {
List<Candidate> findByName(String name);
}
For the Candidate entity, this interface defines a data access object (DAO) that provides CRUD operations (Create, Read, Update, Delete).
The CandidateRepository
interface also provides a custom query method: findByName(String name)
, which returns all Candidate entities with the specified name.
The Controller Layer or Web Layer
Create a CandidateController class with the following methods:
package com.application.thymeleafcrud.controller;
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.bind.annotation.RequestMapping;
import com.application.thymeleafcrud.entity.Candidate;
import com.application.thymeleafcrud.repository.CandidateRepository;
import jakarta.validation.Valid;
@Controller
@RequestMapping("/candidates")
public class CandidateController {
private final CandidateRepository candidateRepository;
@Autowired
public CandidateController(CandidateRepository candidateRepository) {
this.candidateRepository = candidateRepository;
}
@GetMapping("/signup")
public String showSignUpForm(Candidate candidate) {
return "add-candidate";
}
@GetMapping("/list")
public String showUpdateForm(Model model) {
model.addAttribute("candidates", candidateRepository.findAll());
return "index";
}
@PostMapping("/add")
public String addCandidate(@Valid Candidate candidate, BindingResult result, Model model) {
if (result.hasFieldErrors("name")
|| result.hasFieldErrors("email")
|| result.hasFieldErrors("age")) {
return "add-candidate";
}
candidateRepository.save(candidate);
return "redirect:list";
}
@GetMapping("/edit/{id}")
public String showUpdateForm(@PathVariable("id") long id, Model model) {
Candidate candidate = candidateRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid candidate Id:" + id));
model.addAttribute("candidate", candidate);
return "update-candidate";
}
@PostMapping("/update/{id}")
public String updateCandidate(@PathVariable("id") long id,
@Valid Candidate candidate, BindingResult result,
Model model) {
if (result.hasErrors()) {
candidate.setId(id);
return "update-candidate";
}
candidateRepository.save(candidate);
model.addAttribute("candidates", candidateRepository.findAll());
return "index";
}
@GetMapping("/delete/{id}")
public String deleteCandidate(@PathVariable("id") long id, Model model) {
Candidate candidate = candidateRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid candidate Id:" + id));
candidateRepository.delete(candidate);
model.addAttribute("candidates", candidateRepository.findAll());
return "index";
}
}
The View Layer
For the Candidate signup and update, as well as rendering the list of persisted Candidate entities, we need to create HTML templates under src/main/resources/templates. To parse the template files, we will use Thymeleaf as the underlying template engine.
add-candidate.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Add User</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
crossorigin="anonymous">
<link rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.4.1/css/all.css"
integrity="sha384-5sAR7xN1Nv6T6+dT2mhtzEpVJvfS3NScPQTrOxhwjIuvcA67KV2R5Jz6kr4abQsz"
crossorigin="anonymous">
<!-- <link rel="stylesheet" href="../css/shards.min.css"> -->
</head>
<body>
<div class="container my-5">
<h3> Add Candidate</h3>
<div class="card">
<div class="card-body">
<div class="col-md-10">
<form action="#" th:action="@{/candidates/add}"
th:object="${candidate}" method="post">
<div class="row">
<div class="form-group col-md-8">
<label for="name" class="col-form-label">Name</label> <input
type="text" th:field="*{name}" class="form-control" id="name"
placeholder="Name"> <span
th:if="${#fields.hasErrors('name')}" th:errors="*{name}"
class="text-danger"></span>
</div>
<div class="form-group col-md-8">
<label for="email" class="col-form-label">Email</label> <input
type="text" th:field="*{email}" class="form-control" id="email"
placeholder="Email"> <span
th:if="${#fields.hasErrors('email')}" th:errors="*{email}"
class="text-danger"></span>
</div>
<div class="form-group col-md-8">
<label for="age" class="col-form-label">Age</label> <input
type="number" th:field="*{age}" class="form-control"
id="age" placeholder="Age"> <span
th:if="${#fields.hasErrors('age')}" th:errors="*{age}"
class="text-danger"></span>
</div>
<div class="col-md-6">
<input type="submit" class="btn btn-primary" value="Add Candidate">
</div>
<div class="form-group col-md-8"></div>
</div>
</form>
</div>
</div>
</div>
</div>
</body>
</html>
index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Users</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
crossorigin="anonymous">
<link rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.4.1/css/all.css"
integrity="sha384-5sAR7xN1Nv6T6+dT2mhtzEpVJvfS3NScPQTrOxhwjIuvcA67KV2R5Jz6kr4abQsz"
crossorigin="anonymous">
<!-- <link rel="stylesheet" href="../css/shards.min.css"> -->
</head>
<body>
<div class="container my-2">
<div class="card">
<div class="card-body">
<div th:switch="${candidates}" class="container my-5">
<p class="my-5">
<a href="/candidates/signup" class="btn btn-primary"><i
class="fas fa-user-plus ml-2"> Add Candidate</i></a>
</p>
<div class="col-md-10">
<h2 th:case="null">No Candidates yet!</h2>
<div th:case="*">
<table class="table table-striped table-responsive-md">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Age</th>
<th>Edit</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
<tr th:each="candidate : ${candidates}">
<td th:text="${candidate.name}"></td>
<td th:text="${candidate.email}"></td>
<td th:text="${candidate.age}"></td>
<td>
<a th:href="@{/candidates/edit/{id}(id=${candidate.id})}"
class="btn btn-primary"><i class="fas fa-user-edit ml-2"></i>
</a>
</td>
<td>
<a th:href="@{/candidates/delete/{id}(id=${candidate.id})}"
class="btn btn-primary"><i class="fas fa-user-times ml-2"></i>
</a></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
update-candidate.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Update User</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
crossorigin="anonymous">
<link rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.4.1/css/all.css"
integrity="sha384-5sAR7xN1Nv6T6+dT2mhtzEpVJvfS3NScPQTrOxhwjIuvcA67KV2R5Jz6kr4abQsz"
crossorigin="anonymous">
</head>
<body>
<div class="container my-5">
<h3> Update Candidate</h3>
<div class="card">
<div class="card-body">
<div class="col-md-8">
<form action="#"
th:action="@{/candidates/update/{id}(id=${candidate.id})}"
th:object="${candidate}" method="post">
<div class="row">
<div class="form-group col-md-6">
<label for="name" class="col-form-label">Name</label> <input
type="text" th:field="*{name}" class="form-control" id="name"
placeholder="Name"> <span
th:if="${#fields.hasErrors('name')}" th:errors="*{name}"
class="text-danger"></span>
</div>
<div class="form-group col-md-8">
<label for="email" class="col-form-label">Email</label> <input
type="text" th:field="*{email}" class="form-control" id="email"
placeholder="Email"> <span
th:if="${#fields.hasErrors('email')}" th:errors="*{email}"
class="text-danger"></span>
</div>
<div class="form-group col-md-8">
<label for="age" class="col-form-label">Age</label> <input
type="number" th:field="*{age}" class="form-control"
id="age" placeholder="age"> <span
th:if="${#fields.hasErrors('age')}" th:errors="age"
class="text-danger"></span>
</div>
<div class="form-group col-md-8">
<input type="submit" class="btn btn-primary"
value="Update Candidate">
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</body>
</html>
Demo Screenshots
Now when we run the application we would be able to see our application live at port 9098
Landing Screen:
Add Candidate Screen
List of Candidates Screen
Update Candidate Screen
After Updation List of Candidates
Delete Candidate Screen
Conclusion
We learned how to build CRUD web applications with Spring Boot and Thymeleaf in this tutorial.
Source code available in Github