DEV Community

Sadiul Hakim
Sadiul Hakim

Posted on

Todo App with Spring Boot MVC and Thymeleaf.

Step 1: Project Setup

We start by creating a Spring Boot project. You can use:

Choose dependencies:

  • Spring Web → to build MVC controllers
  • Thymeleaf → to render HTML templates
  • Spring Data JPA → to interact with database
  • H2 Database → in-memory DB for development
  • Spring Security → for login/logout

pom.xml will have (important parts):

<dependencies>
    <!-- Web + Thymeleaf -->
    <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>

    <!-- JPA + H2 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
</dependencies>
Enter fullscreen mode Exit fullscreen mode

Step 2: Configure Application

We want H2 database in memory, and to see SQL queries.

src/main/resources/application.properties:

# Enable H2 console (useful for debugging)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# H2 database in memory
spring.datasource.url=jdbc:h2:mem:todoapp
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# Hibernate (JPA) config
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
Enter fullscreen mode Exit fullscreen mode

Now our app will use an in-memory DB (todoapp) and automatically create tables from entities.


Step 3: Create the Todo Model

We need a table todo with columns: id, title, completed, username.

Todo.java

package com.example.todoapp.model;

import jakarta.persistence.*;
import lombok.Data;

@Entity
@Data
public class Todo {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // Auto increment ID
    private Long id;

    private String title;

    private boolean completed = false;

    // Every todo belongs to a specific user
    private String username;
}
Enter fullscreen mode Exit fullscreen mode
  • @Entity → tells JPA this is a table.
  • @Id → primary key.
  • username → ensures each user only sees their own todos.

Step 4: Repository Layer

We need a repository to interact with DB.

TodoRepository.java

package com.example.todoapp.repository;

import com.example.todoapp.model.Todo;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface TodoRepository extends JpaRepository<Todo, Long> {
    // Custom query: fetch only todos belonging to logged-in user
    List<Todo> findByUsername(String username);
}
Enter fullscreen mode Exit fullscreen mode

This gives us CRUD methods like save, findById, deleteById for free.


Step 5: Security Setup (Login Form)

We don’t want open access. Let’s use Spring Security with in-memory users (simplest).

SecurityConfig.java

package com.example.todoapp.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/css/**").permitAll() // Bootstrap CSS allowed
                .anyRequest().authenticated() // everything else needs login
            )
            .formLogin(login -> login
                .loginPage("/login").permitAll() // custom login page
                .defaultSuccessUrl("/todos", true) // redirect after login
            )
            .logout(logout -> logout.permitAll());

        return http.build();
    }

    // In-memory users (for demo)
    @Bean
    public UserDetailsService users() {
        UserDetails user1 = User.withUsername("john")
                .password("1234")
                .roles("USER")
                .build();

        UserDetails user2 = User.withUsername("jane")
                .password("1234")
                .roles("USER")
                .build();

        return new InMemoryUserDetailsManager(user1, user2);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // ⚠ Not safe for real apps. Only for demo.
        return NoOpPasswordEncoder.getInstance();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now app has:

  • Login page at /login
  • Users:

    • john / 1234
    • jane / 1234

Step 6: Controller

This is where user actions are handled:

  • Show todos
  • Add new todo
  • Toggle status
  • Delete todo

TodoController.java

package com.example.todoapp.controller;

import com.example.todoapp.model.Todo;
import com.example.todoapp.repository.TodoRepository;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

@Controller
@RequestMapping("/todos")
public class TodoController {

    private final TodoRepository repo;

    public TodoController(TodoRepository repo) {
        this.repo = repo;
    }

    // Show all todos of logged-in user
    @GetMapping
    public String listTodos(Model model, Authentication auth) {
        String username = auth.getName();
        model.addAttribute("todos", repo.findByUsername(username));
        model.addAttribute("newTodo", new Todo());
        return "todos"; // renders todos.html
    }

    // Add new todo
    @PostMapping
    public String addTodo(@ModelAttribute Todo todo, Authentication auth) {
        todo.setUsername(auth.getName());
        repo.save(todo);
        return "redirect:/todos";
    }

    // Toggle completion
    @PostMapping("/{id}/toggle")
    public String toggleTodo(@PathVariable Long id) {
        Todo todo = repo.findById(id).orElseThrow();
        todo.setCompleted(!todo.isCompleted());
        repo.save(todo);
        return "redirect:/todos";
    }

    // Delete todo
    @PostMapping("/{id}/delete")
    public String deleteTodo(@PathVariable Long id) {
        repo.deleteById(id);
        return "redirect:/todos";
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Frontend (Thymeleaf + Bootstrap)

login.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Login</title>
    <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet"/>
</head>
<body class="container mt-5">
<div class="row justify-content-center">
    <div class="col-md-4">
        <h2 class="text-center">Login</h2>
        <form th:action="@{/login}" method="post">
            <div class="mb-3">
                <label>Username</label>
                <input class="form-control" type="text" name="username"/>
            </div>
            <div class="mb-3">
                <label>Password</label>
                <input class="form-control" type="password" name="password"/>
            </div>
            <button class="btn btn-primary w-100">Login</button>
        </form>
    </div>
</div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

todos.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Todos</title>
    <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet"/>
</head>
<body class="container mt-5">

<h2>Your Todos</h2>

<!-- Add new todo -->
<form th:action="@{/todos}" method="post" class="row g-3 mb-4">
    <div class="col-md-8">
        <input class="form-control" type="text" name="title" placeholder="New todo"/>
    </div>
    <div class="col-md-4">
        <button class="btn btn-success w-100">Add</button>
    </div>
</form>

<!-- List all todos -->
<table class="table table-bordered">
    <thead>
    <tr>
        <th>Title</th>
        <th>Status</th>
        <th>Actions</th>
    </tr>
    </thead>
    <tbody>
    <tr th:each="todo : ${todos}">
        <td th:text="${todo.title}"></td>
        <td>
            <span th:text="${todo.completed} ? '✅ Done' : '❌ Not done'"></span>
        </td>
        <td>
            <form th:action="@{/todos/{id}/toggle(id=${todo.id})}" method="post" style="display:inline">
                <button class="btn btn-warning btn-sm">Toggle</button>
            </form>
            <form th:action="@{/todos/{id}/delete(id=${todo.id})}" method="post" style="display:inline">
                <button class="btn btn-danger btn-sm">Delete</button>
            </form>
        </td>
    </tr>
    </tbody>
</table>

<a th:href="@{/logout}" class="btn btn-secondary">Logout</a>

</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Step 8: Run & Test

  1. Run with mvn spring-boot:run
  2. Open http://localhost:8080/login
  3. Login with:
  • john / 1234
  • jane / 1234
    1. Add todos, toggle, delete. Each user sees their own list.

Summary of Flow

  1. User logs in (/login) → Spring Security checks credentials.
  2. After login → redirect to /todos.
  3. TodoController fetches todos of logged-in user.
  4. Thymeleaf displays todos in todos.html.
  5. Bootstrap styles forms + tables.
  6. H2 stores todos in memory (resets when app restarts).

Top comments (0)