How to build a simple CRM with Spring Boot and Thymeleaf

Building a CRM with Spring Boot and Thymelead header


This article will show you how to build a fully functional yet simple CRUD application with Spring Boot using Java in order to manage a single database-backed entity, in this case, customers.

The Spring Boot CRM will provide the following features;

  • A welcome screen
  • A list Screen, showing an attractive data grid allowing the user to browse and find their customers quickly using search, and sort functionality.
    Results should also be paginated so we’re not returning the entire database but rather a short subset of paged results.
  • Create, Edit, and Delete functions.

I’m going to use IntelliJ Idea as my IDE of choice, a H2 in-memory database with 1000 fictitious records, Spring Boot, and Thymeleaf templating engine.

This is part one of a series, part two – authentication covers the introduction of Spring Security so we can lock down the application with Role-based security.

All source code can be found on our Tucanoo Github repository.

Let’s get started and build a Spring Boot CRM.

Now updated for Spring Boot 3.1.3, Java 17 and Bootstrap 5


IntelliJ has a nice interface to Spring Initializer that we can use to initialise our project. So from within Idea, create a new project and select the Spring Boot Initializr.

For the purpose of this article, I’m going to write all the code for this web application in pure Java. For dependency management, my personal preference is Gradle although feel free to use Maven if you so wish.

Building a CRM in Spring Boot 3.1 IntelliJ Project setup using Spring Initializr

Hit Next where we’ll select our initial dependencies. As per the image below, I have selected:

  • Spring Boot Dev Tools
  • Lombok
  • Spring Web
  • Validation
  • Thymeleaf Templating Engine
  • Spring Data JPA
  • H2 Database
Building a CRM with Spring Boot in intelliJ dependency setup

Click next and define where your project will reside and we can begin looking at the necessary code to make all this work.

The Data

We’re going to focus on a single database backed entity in this article, a Customer, the C in CRM. We’ll apply a minimal set of validation rules just to ensure the customer record will also have a first and last name.

This is enough to demonstrate how to enforce validation, and how to present errors back to the user using Thymeleaf templates.

So let’s take a look at our Custom domain.

@Table(name = "customer")
public class Customer {
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;

    String firstName;

    String lastName;

    String emailAddress;

    String address;

    String city;

    String country;

    String phoneNumber;

We’ve set the GenerationType on the Id field to GenerationType.IDENTITY, rather than GenerationType.AUTO as H2 will otherwise have a problem generating the next number in the sequence when we attempt to save a new record.

We’ve added two @NotBlank Validation Constraints against the first and last name fields. @NotNull is not enough, we don’t want blank space to be entered either.

Thanks to mockaroo, we can produce fictitious sample data to pre-populate our customer table. This allows us to immediately increase our productivity when we’re working against some actual data, we can see straightaway our sorting and filtering behave as expected.

To utilise this sample data include the following two files in the src/main/resources directory.

At application startup, Spring Boot will create the database schema and execute all the SQL insert statements providing us with 1000 customers in our database.

Data Repositories and Services

Much of our CRUD operations, can be handled by Spring Data JPA. For simple Find By’s, Saves, and Deletes we could simply create an interface that extends CrudRepository and we wouldn’t need to do anything else.

However, we have a feature-rich datatable component on our list screen. We want to permit the user to search against multiple fields in one hit, with sorted and paginated results.

So in a repositories package, create a CustomerRepository class containing the following,

public interface CustomerRepository extends CrudRepository<Customer, Long>,
    PagingAndSortingRepository<Customer, Long>,
    JpaSpecificationExecutor<Customer> {

We extend PagingAndSortingRepository to allow for paging and sorting of results, and also the JpaSpecificationExecutor so that we can perform filtering with a custom Hibernate Criteria Specification.

Within a specifications package, create a CustomerDatatableFilter class and copy the following code:

public class CustomerDatatableFilter implements<Customer>{

    String userQuery;

    public CustomerDatatableFilter(String queryString) {
        this.userQuery = queryString;

    public Predicate toPredicate(Root<Customer> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
        ArrayList<Predicate> predicates = new ArrayList<>();

        if (userQuery != null && userQuery != "") {
            predicates.add("firstName"), '%' + userQuery + '%'));
            predicates.add("lastName"), '%' + userQuery + '%'));
            predicates.add("city"), '%' + userQuery + '%'));
            predicates.add("emailAddress"), '%' + userQuery + '%'));
            predicates.add("phoneNumber"), '%' + userQuery + '%'));
            predicates.add("country"), '%' + userQuery + '%'));

        return (! predicates.isEmpty() ? criteriaBuilder.or(predicates.toArray(new Predicate[predicates.size()])) : null);

We are going to present our user with a single search input textbox. When they start to type, we will perform a Criteria based query against our Customer fields and respond with the results appropriately.

In this specific case, we will be creating an instance of this filter, passing in the user’s query to the userQuery field. We’ll then use this to perform ‘like’ criteria, aka wildcard searches against the name fields, the city, address, phone number and country fields all in one hit.

Using Criteria Specifications gives us a great deal of power and flexibility as to how we will perform the searching against our tables.

Now we only need a service to call our powerful Specification. Within a ‘services’ package, create a CustomerService class and insert the following:

public class CustomerService {

    private final CustomerRepository customerRepository;

    public Page<Customer> getCustomersForDatatable(String queryString, Pageable pageable) {

        CustomerDatatableFilter customerDatatableFilter = new CustomerDatatableFilter(queryString);

        return customerRepository.findAll(customerDatatableFilter, pageable);

The Web Controller

Now we have built up our data layer, using both a little custom code and the Spring Data JPA implementation, we can look at the controller layer to provide the expected responses to our web requests.

We’re going to use Spring MVC to handle the routing of requests, binding of variables and Bean validation for us.

Along with Thymeleaf for templating for inserting our dynamic content into Html based templates.

So within a controllers package, create a new CustomerWebController class and paste the following:

public class CustomerWebController {

    private final CustomerRepository customerRepository;
    private final CustomerService customerService;

    public String index() {
        return "/customer/index.html";

    @GetMapping(value = "/data_for_datatable", produces = "application/json")
    public String getDataForDatatable(@RequestParam Map<String, Object> params) {
        int draw = params.containsKey("draw") ? Integer.parseInt(params.get("draw").toString()) : 1;
        int length = params.containsKey("length") ? Integer.parseInt(params.get("length").toString()) : 30;
        int start = params.containsKey("start") ? Integer.parseInt(params.get("start").toString()) : 30;
        int currentPage = start / length;

        String sortName = "id";
        String dataTableOrderColumnIdx = params.get("order[0][column]").toString();
        String dataTableOrderColumnName = "columns[" + dataTableOrderColumnIdx + "][data]";
        if (params.containsKey(dataTableOrderColumnName))
            sortName = params.get(dataTableOrderColumnName).toString();
        String sortDir = params.containsKey("order[0][dir]") ? params.get("order[0][dir]").toString() : "asc";

        Sort.Order sortOrder = new Sort.Order((sortDir.equals("desc") ? Sort.Direction.DESC : Sort.Direction.ASC), sortName);
        Sort sort =;

        Pageable pageRequest = PageRequest.of(currentPage,

        String queryString = (String) (params.get("search[value]"));

        Page<Customer> customers = customerService.getCustomersForDatatable(queryString, pageRequest);

        long totalRecords = customers.getTotalElements();

        List<Map<String, Object>> cells = new ArrayList<>();
        customers.forEach(customer -> {
            Map<String, Object> cellData = new HashMap<>();
            cellData.put("id", customer.getId());
            cellData.put("firstName", customer.getFirstName());
            cellData.put("lastName", customer.getLastName());
            cellData.put("emailAddress", customer.getEmailAddress());
            cellData.put("city", customer.getCity());
            cellData.put("country", customer.getCountry());
            cellData.put("phoneNumber", customer.getPhoneNumber());

        Map<String, Object> jsonMap = new HashMap<>();

        jsonMap.put("draw", draw);
        jsonMap.put("recordsTotal", totalRecords);
        jsonMap.put("recordsFiltered", totalRecords);
        jsonMap.put("data", cells);

        String json = null;
        try {
            json = new ObjectMapper().writeValueAsString(jsonMap);
        } catch (JsonProcessingException e) {

        return json;

    public String edit(@PathVariable String id, Model model) {
        Customer customerInstance = customerRepository.findById(Long.valueOf(id)).get();

        model.addAttribute("customerInstance", customerInstance);

        return "/customer/edit.html";

    public String update(@Valid @ModelAttribute("customerInstance") Customer customerInstance,
                         BindingResult bindingResult,
                         Model model,
                         RedirectAttributes atts) {
        if (bindingResult.hasErrors()) {
            return "/customer/edit.html";
        } else {
            if ( != null)
                atts.addFlashAttribute("message", "Customer updated successfully");
                atts.addFlashAttribute("message", "Customer update failed.");

            return "redirect:/customer";

    public String create(Model model)
        model.addAttribute("customerInstance", new Customer());
        return "/customer/create.html";

    public String save(@Valid @ModelAttribute("customerInstance") Customer customerInstance,
                       BindingResult bindingResult,
                       Model model,
                       RedirectAttributes atts) {
        if (bindingResult.hasErrors()) {
            return "/customer/create.html";
        } else {
            if ( != null)
                atts.addFlashAttribute("message", "Customer created successfully");
                atts.addFlashAttribute("message", "Customer creation failed.");

            return "redirect:/customer";

    public String delete(@RequestParam Long id, RedirectAttributes atts) {
        Customer customerInstance = customerRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("Customer Not Found:" + id));


        atts.addFlashAttribute("message", "Customer deleted.");

        return "redirect:/customer";


As per the controller class above, you can see we have an index() which is just a stub for the customer index page. We don’t process any data here as our call for data will be an Ajax request from the datatable.

The getDataForDatatable() method is where this processing occurs. Initially, we are identifying the parameters to establish our pagination and sorting. This forms the basis of the PageRequest which we then pass along with any query string to getCustomersForDatatable() in our CustomerService.

Lastly, we iterate over the results, producing a map in the form as expected by Datatable. An ObjectMapper finally converts our Map to JSON.

The remaining methods in our controller are the wrappers around our basic CRUD functions, edit() and update(), create() and save(), and delete(). I do like to make use of the Flash Scope, to display a confirmation message back to the user upon successfully completing an action, so we make use of the ‘RedirectAttributes atts‘ in our method parameters to set attributes in Flash scope.

You may notice we don’t have any read-only ‘show()‘ functionality per se. For the purposes of this article, I believe selecting a customer and going directly to the edit page will suffice for this demonstration.

Onto the Views with Thymeleaf

Right now we have our backend prepared and ready for some UI to call so we can introduce our Thymeleaf templates to our project.

Thymeleaf offers a wealth of extra functionality that I show here, this article really only touches the surface of what can be accomplished and I recommend using this project as a starter and improving upon it to aid in learning. For example, I have a fair bit of repeated code in our templates, remember DRY, Don’t Repeat Yourself? Well, this is a great opportunity to move the Customer Form, the Footer, and the Header from our pages into their own Thymeleaf Fragments. Additionally, you can add verbose error messages against the individual textboxes on our form.

I want to keep this project minimal, however, I have grander plans I want to share at the end of this article.

Our templates will use Twitter Bootstrap for UI polish and cross-browser reliability, with jQuery for some JS functionality.

Our home page has no dynamic content except for the copyright notice where I write the current year.

<span th:text="${#dates.format(#dates.createNow(), 'yyyy')}"></span>

Other than this, this is just a splash page that passes the user through to our customer/index.html page.

Customer List Page

This page acts as our customer list, allowing the user to search for, sort, and navigate through the table of customers.

We also display confirmation messages back to the user here that may have been set in Flash Scope:

<div class="alert alert-info" th:if="${message}">
    <h3 th:text="${message}"></h3>

The ‘th:if’ attribute is a conditional test, if the boolean result is false, the div will NOT be present in the resulting page.

Further, we define our table structure:

<table id="customerTable" class="table table-striped table-bordered" style="width:100%">
        <th>First Name</th>
        <th>Last Name</th>

Which in turn is transformed into our fully functional grid with a call to Datatables:

var url = '/customer/data_for_datatable';

  $(document).ready(function () {

      "ajax": url,
      "processing": true,
      "serverSide": true,
      "columns": [
          "data": "id",
          "render": function (data, type, row, meta) {
            return '<a href="/customer/edit/' + + '">' + data + '</a>';
          "data": "firstName",
          "render": function (data, type, row, meta) {
            return '<a href="/customer/edit/' + + '">' + data + '</a>';
          "data": "lastName",
          "render": function (data, type, row, meta) {
            return '<a href="/customer/edit/' + + '">' + data + '</a>';
        {"data": "emailAddress"},
        {"data": "city"},
        {"data": "country"},
        {"data": "phoneNumber"}

As a result, that makes up the most important parts of our list page. That leaves only the pages for creating and editing customers.

Create & Edit Templates

If we take a look at the create.html we can inspect the form a little more closely;

<form action="/customer/save" th:object="${customerInstance}" class="form" method="post">

    <div class="alert alert-danger" th:if="${! #fields.errors('all').isEmpty()}">
        <li th:each="e : ${#fields.detailedErrors()}" th:class="${}? globalerr : fielderr">
            <span th:text="${}? '*' : ${e.fieldName}">The field name</span>
            <span th:text="${e.message}">The error message</span>

    <div class="row">
        <div class="form-group col-6">
            <label>First Name</label>
            <input class="form-control" name="firstName" th:value="${customerInstance?.firstName}"/>
        <div class="form-group col-6">
            <label>Last Name</label>
            <input class="form-control" name="lastName" th:value="${customerInstance?.lastName}"/>


    <div class="row">
        <div class="col">
            <button type="submit" class="btn btn-success btn-block">Create Customer</button>

What’s important here is the Thymeleaf binding between the Form and our Customer backing bean with th:object=”${customerInstance}”.

Consequently, this enables amongst other things, the ‘fields’ object to reflect fields within our backing bean. If something goes wrong during submission, e.g. we try to save the form with no first name, then the errors will be detected and displayed.

The Web Application

If you’ve done everything correctly, please refer to the GitHub code if you’ve hit any issues you should see the following screens.

[rev_slider alias=”grails-crm-slider” slidertitle=”grails crm”][/rev_slider]

Final Thoughts

From this article and its accompanying code on github you have seen how we can build a fairly simple CRM based on Spring Boot with full CRUD functionality.

Furthermore, the javascript Datatables component has given us a searchable, sortable, and paginated interface for our UI.

Note: this article will form part of a series. To see how we can introduce authentication make sure to continue on to part two – authentication.

You may also be interested in seeing how we can create the same application with the Grails framework. See how to build a CRM with Grails for the full Grails tutorial.

Founder of Tucanoo Solutions Ltd, a Cloud / Web Application development company. AWS Cloud Solutions Architect. Specialties: Spring Boot, Java, Grails, React.JS, App Architecture, Agile, Scrum, Git, AWS, Javascript.


Leave a Reply

Your email address will not be published. Required fields are marked *