Spring Boot Thymeleaf - Guia Completa
Introducció a Thymeleaf
Thymeleaf és un template engine modern orientat a Java que permet generar contingut HTML dinàmic a partir del servidor. A diferència de les APIs REST que retornen JSON, Thymeleaf integra lògica de negoci amb la renderització de vistes HTML directament.
Comparació: REST API vs Thymeleaf
Aspecte
REST API (JSON)
Thymeleaf (HTML)
Resposta
JSON estructurat
HTML renderitzat
Client
Frontend separat (Angular, React)
Browser directament
Arquitectura
Microserveis, decoupled
Monolítica, integrada
SEO
Pobre (client-side rendering)
Bé (server-side rendering)
Complexitat
Més simple per SPAs
Més adequat per aplicacions tradicionals
Instal·lació i Configuració
1. Afegir Dependència
<!-- pom.xml -->
<dependency>
<groupId> org.springframework.boot</groupId>
<artifactId> spring-boot-starter-thymeleaf</artifactId>
</dependency>
2. Estructura de Projecte
my-app/
├── src/main/java/com/example/myapp/
│ ├── MyAppApplication.java
│ ├── controller/
│ │ └── UserController.java
│ ├── service/
│ │ └── UserService.java
│ ├── repository/
│ │ └── UserRepository.java
│ └── entity/
│ └── User.java
├── src/main/resources/
│ ├── templates/
│ │ ├── layout/
│ │ │ └── base.html
│ │ ├── user/
│ │ │ ├── list.html
│ │ │ ├── create.html
│ │ │ └── edit.html
│ │ └── index.html
│ ├── static/
│ │ ├── css/
│ │ │ └── style.css
│ │ └── js/
│ │ └── script.js
│ └── application.yml
3. Configuració application.yml
spring :
thymeleaf :
cache : false # Desactivar cache en desenvolupament
check-template-location : true
prefix : classpath:/templates/
suffix : .html
mode : HTML5
encoding : UTF-8
# Base de dades
datasource :
url : jdbc:mysql://localhost:3306/myapp_db
username : root
password : password
jpa :
hibernate :
ddl-auto : update
show-sql : false
server :
port : 8080
Fundaments de Thymeleaf
Sintaxi Bàsica
1. Mostrar Variables
<!-- Mostrar contingut dinàmic -->
< p th:text = "${message}" > Placeholder text</ p >
<!-- Mostrar sense escapar HTML -->
< div th:utext = "${htmlContent}" ></ div >
<!-- Null-safe: si la variable és null, mostra placeholder -->
< p th:text = "${user?.name ?: 'No name'}" > Default</ p >
2. Condicionals
<!-- if -->
< div th:if = "${user.age >= 18}" >
< p > Ets major d'edat</ p >
</ div >
<!-- unless (opuesto a if) -->
< div th:unless = "${user.premium}" >
< p > Actualitza a versió premium</ p >
</ div >
<!-- switch/case -->
< div th:switch = "${user.role}" >
< p th:case = "'ADMIN'" > Eres administrador</ p >
< p th:case = "'USER'" > Eres usuario estándar</ p >
< p th:case = "*" > Rol desconocido</ p >
</ div >
3. Iteracions (Loops)
<!-- Iterar sobre llista -->
< table >
< thead >
< tr >
< th > Nom</ th >
< th > Email</ th >
< th > Accions</ th >
</ tr >
</ thead >
< tbody >
< tr th:each = "user : ${users}" >
< td th:text = "${user.name}" ></ td >
< td th:text = "${user.email}" ></ td >
< td >
< a th:href = "@{/users/{id}(id=${user.id})}" > Veure</ a >
< a th:href = "@{/users/{id}/edit(id=${user.id})}" > Editar</ a >
</ td >
</ tr >
</ tbody >
</ table >
<!-- Variables d'estat -->
< div th:each = "user, stat : ${users}" >
< p > Index: < span th:text = "${stat.index}" ></ span ></ p >
< p > Count: < span th:text = "${stat.count}" ></ span ></ p >
< p > Parell: < span th:text = "${stat.even}" ></ span ></ p >
< p > Primer: < span th:text = "${stat.first}" ></ span ></ p >
< p > Últim: < span th:text = "${stat.last}" ></ span ></ p >
</ div >
4. Links i URLs
<!-- Link simple -->
< a th:href = "@{/users}" > Llistar usuaris</ a >
<!-- Link amb paràmetres -->
< a th:href = "@{/users/{id}(id=${user.id})}" > Veure usuari</ a >
< a th:href = "@{/users/search(query=${searchTerm})}" > Cercar</ a >
<!-- Link amb múltiples paràmetres -->
< a th:href = "@{/products(page=${page},size=${size},sort=${sort})}" >
Pàgina siguiente
</ a >
Controlador i Views
Controlador amb Thymeleaf
package com.example.myapp.controller ;
import com.example.myapp.entity.User ;
import com.example.myapp.service.UserService ;
import lombok.RequiredArgsConstructor ;
import org.springframework.stereotype.Controller ;
import org.springframework.ui.Model ;
import org.springframework.web.bind.annotation.* ;
import java.util.List ;
@Controller
@RequestMapping ( "/users" )
@RequiredArgsConstructor
public class UserController {
private final UserService userService ;
// Mostrar llista d'usuaris
@GetMapping
public String listUsers ( Model model ) {
List < User > users = userService . getAllUsers ();
model . addAttribute ( "users" , users );
model . addAttribute ( "title" , "Llistat d'Usuaris" );
return "user/list" ; // Retorna templates/user/list.html
}
// Mostrar form de creació
@GetMapping ( "/create" )
public String createForm ( Model model ) {
model . addAttribute ( "user" , new User ());
model . addAttribute ( "title" , "Crear Usuari" );
return "user/create" ;
}
// Guardar usuari
@PostMapping
public String saveUser ( @ModelAttribute User user ) {
userService . saveUser ( user );
return "redirect:/users" ; // Redirigir a la llista
}
// Mostrar formulari d'edició
@GetMapping ( "/{id}/edit" )
public String editForm ( @PathVariable Long id , Model model ) {
User user = userService . getUserById ( id );
model . addAttribute ( "user" , user );
model . addAttribute ( "title" , "Editar Usuari" );
return "user/edit" ;
}
// Actualizar usuari
@PutMapping ( "/{id}" )
public String updateUser ( @PathVariable Long id , @ModelAttribute User user ) {
user . setId ( id );
userService . updateUser ( user );
return "redirect:/users" ;
}
// Eliminar usuari
@DeleteMapping ( "/{id}" )
public String deleteUser ( @PathVariable Long id ) {
userService . deleteUser ( id );
return "redirect:/users" ;
}
// Veure detalls d'usuari
@GetMapping ( "/{id}" )
public String viewUser ( @PathVariable Long id , Model model ) {
User user = userService . getUserById ( id );
model . addAttribute ( "user" , user );
return "user/view" ;
}
}
Templates Thymeleaf
Template Base (Layout)
<!-- templates/layout/base.html -->
<!DOCTYPE html>
< html xmlns:th = "http://www.thymeleaf.org" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title th:text = "${title ?: 'Mi App'}" ></ title >
< link rel = "stylesheet" href = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" >
< link th:href = "@{/css/style.css}" rel = "stylesheet" >
</ head >
< body >
<!-- Header -->
< nav th:insert = "~{layout/header :: header}" ></ nav >
<!-- Contenido principal -->
< div class = "container mt-4" >
<!-- Missatges d'error/éxit -->
< div th:if = "${param.success}" class = "alert alert-success alert-dismissible fade show" role = "alert" >
< span th:text = "#{message.success}" ></ span >
< button type = "button" class = "btn-close" data-bs-dismiss = "alert" ></ button >
</ div >
< div th:if = "${param.error}" class = "alert alert-danger alert-dismissible fade show" role = "alert" >
< span th:text = "#{message.error}" ></ span >
< button type = "button" class = "btn-close" data-bs-dismiss = "alert" ></ button >
</ div >
<!-- Contingut dinàmic -->
< div th:insert = "~{:: content}" ></ div >
</ div >
<!-- Footer -->
< footer th:insert = "~{layout/footer :: footer}" ></ footer >
< script src = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" ></ script >
</ body >
</ html >
<!-- templates/layout/header.html -->
< header th:fragment = "header" >
< nav class = "navbar navbar-expand-lg navbar-dark bg-dark" >
< div class = "container-fluid" >
< a class = "navbar-brand" th:href = "@{/}" > Mi App</ a >
< button class = "navbar-toggler" type = "button" data-bs-toggle = "collapse" data-bs-target = "#navbarNav" >
< span class = "navbar-toggler-icon" ></ span >
</ button >
< div class = "collapse navbar-collapse" id = "navbarNav" >
< ul class = "navbar-nav ms-auto" >
< li class = "nav-item" >
< a class = "nav-link" th:href = "@{/}" > Inicio</ a >
</ li >
< li class = "nav-item" >
< a class = "nav-link" th:href = "@{/users}" > Usuarios</ a >
</ li >
< li class = "nav-item" >
< a class = "nav-link" th:href = "@{/logout}" > Cerrar sesión</ a >
</ li >
</ ul >
</ div >
</ div >
</ nav >
</ header >
Llistat d'Usuaris
<!-- templates/user/list.html -->
<!DOCTYPE html>
< html xmlns:th = "http://www.thymeleaf.org" >
< head >
< meta charset = "UTF-8" >
< title th:text = "${title}" ></ title >
< link rel = "stylesheet" href = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" >
</ head >
< body >
< div class = "container mt-5" >
< div class = "row mb-3" >
< div class = "col-md-8" >
< h1 th:text = "${title}" ></ h1 >
</ div >
< div class = "col-md-4 text-end" >
< a th:href = "@{/users/create}" class = "btn btn-primary" >
Crear Usuari
</ a >
</ div >
</ div >
<!-- Taula d'usuaris -->
< div th:if = "${users.size() > 0}" >
< table class = "table table-striped table-hover" >
< thead class = "table-dark" >
< tr >
< th > ID</ th >
< th > Nom</ th >
< th > Email</ th >
< th > Accions</ th >
</ tr >
</ thead >
< tbody >
< tr th:each = "user : ${users}" >
< td th:text = "${user.id}" ></ td >
< td th:text = "${user.firstName + ' ' + user.lastName}" ></ td >
< td th:text = "${user.email}" ></ td >
< td >
< a th:href = "@{/users/{id}(id=${user.id})}" class = "btn btn-sm btn-info" >
Veure
</ a >
< a th:href = "@{/users/{id}/edit(id=${user.id})}" class = "btn btn-sm btn-warning" >
Editar
</ a >
< form th:action = "@{/users/{id}(id=${user.id})}" method = "post"
style = "display:inline;" onsubmit = "return confirm('Segur que vols eliminar?')" >
< input type = "hidden" name = "_method" value = "DELETE" >
< button type = "submit" class = "btn btn-sm btn-danger" >
Eliminar
</ button >
</ form >
</ td >
</ tr >
</ tbody >
</ table >
</ div >
<!-- Missatge si no hi ha usuaris -->
< div th:if = "${users.size() == 0}" class = "alert alert-info" >
< p > No hi ha usuaris registrats.
< a th:href = "@{/users/create}" > Crear el primer usuari</ a >
</ p >
</ div >
</ div >
</ body >
</ html >
<!-- templates/user/create.html -->
<!DOCTYPE html>
< html xmlns:th = "http://www.thymeleaf.org" >
< head >
< meta charset = "UTF-8" >
< title th:text = "${title}" ></ title >
< link rel = "stylesheet" href = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" >
</ head >
< body >
< div class = "container mt-5" >
< div class = "row" >
< div class = "col-md-6 offset-md-3" >
< h1 th:text = "${title}" ></ h1 >
< form th:action = "@{/users}" th:object = "${user}" method = "post" class = "mt-4" >
<!-- Nom -->
< div class = "mb-3" >
< label for = "firstName" class = "form-label" > Nom</ label >
< input type = "text" class = "form-control" id = "firstName"
th:field = "*{firstName}" required >
< small class = "text-danger" th:if = "${#fields.hasErrors('firstName')}"
th:errors = "*{firstName}" ></ small >
</ div >
<!-- Cognom -->
< div class = "mb-3" >
< label for = "lastName" class = "form-label" > Cognom</ label >
< input type = "text" class = "form-control" id = "lastName"
th:field = "*{lastName}" required >
< small class = "text-danger" th:if = "${#fields.hasErrors('lastName')}"
th:errors = "*{lastName}" ></ small >
</ div >
<!-- Email -->
< div class = "mb-3" >
< label for = "email" class = "form-label" > Email</ label >
< input type = "email" class = "form-control" id = "email"
th:field = "*{email}" required >
< small class = "text-danger" th:if = "${#fields.hasErrors('email')}"
th:errors = "*{email}" ></ small >
</ div >
<!-- Botns -->
< div class = "d-flex gap-2" >
< button type = "submit" class = "btn btn-primary" >
Guardar
</ button >
< a th:href = "@{/users}" class = "btn btn-secondary" >
Cancelar
</ a >
</ div >
</ form >
</ div >
</ div >
</ div >
</ body >
</ html >
Entity amb Anotacions de Validació
package com.example.myapp.entity ;
import jakarta.persistence.* ;
import jakarta.validation.constraints.* ;
import lombok.AllArgsConstructor ;
import lombok.Data ;
import lombok.NoArgsConstructor ;
@Entity
@Table ( name = "users" )
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue ( strategy = GenerationType . IDENTITY )
private Long id ;
@NotBlank ( message = "El nom és obligatori" )
@Size ( min = 2 , max = 50 , message = "El nom ha de tenir entre 2 i 50 caràcters" )
private String firstName ;
@NotBlank ( message = "El cognom és obligatori" )
@Size ( min = 2 , max = 50 )
private String lastName ;
@NotBlank ( message = "L'email és obligatori" )
@Email ( message = "L'email ha de ser vàlid" )
@Column ( unique = true )
private String email ;
}
Controlador amb Validació
@PostMapping
public String saveUser ( @Valid @ModelAttribute User user ,
BindingResult bindingResult ,
Model model ) {
// Si hi ha errors de validació
if ( bindingResult . hasErrors ()) {
model . addAttribute ( "title" , "Crear Usuari" );
return "user/create" ; // Retorna el formulari amb errors
}
userService . saveUser ( user );
return "redirect:/users?success=true" ;
}
Template amb Mostrada d'Errors
< form th:action = "@{/users}" th:object = "${user}" method = "post" >
< div class = "mb-3" >
< label for = "email" class = "form-label" > Email</ label >
< input type = "email"
class = "form-control"
id = "email"
th:field = "*{email}"
th:classappend = "${#fields.hasErrors('email') ? 'is-invalid' : ''}" >
<!-- Mostrar error si existeix -->
< div class = "invalid-feedback"
th:if = "${#fields.hasErrors('email')}"
th:errors = "*{email}" >
</ div >
</ div >
</ form >
Internacionalització (i18n)
Configurar Idiomes
# application.yml
spring :
messages :
basename : messages
encoding : UTF-8
fallback-to-system-locale : false
default-locale : ca
Arxius de Mensajes
# src/main/resources/messages.properties (Català)
message.title = Benvingut a la meva app
message.welcome = Benvingut, {0}
message.save = Guardar
message.cancel = Cancelar
message.delete = Eliminar
message.success = Operació realitzada correctament
message.error = Ha ocorregut un error
# src/main/resources/messages_en.properties (Anglès)
message.title = Welcome to my app
message.welcome = Welcome, {0}
message.save = Save
message.cancel = Cancel
message.delete = Delete
message.success = Operation completed successfully
message.error = An error occurred
# src/main/resources/messages_es.properties (Espanyol)
message.title = Bienvenido a mi app
message.welcome = Bienvenido, {0}
Usar els Missatges
<!-- Missatge simple -->
< h1 th:text = "#{message.title}" ></ h1 >
<!-- Missatge amb paràmetres -->
< p th:text = "#{message.welcome(${user.name})}" ></ p >
<!-- Selector d'idioma -->
< a th:href = "@{(lang=ca)}" > Català</ a >
< a th:href = "@{(lang=en)}" > English</ a >
< a th:href = "@{(lang=es)}" > Español</ a >
Millors Pràctiques
1. Usar Fragments per a Reutilització
<!-- Definir fragment -->
< div th:fragment = "message" >
< div class = "alert alert-info" >
< span th:text = "${message}" ></ span >
</ div >
</ div >
<!-- Usar fragment -->
< div th:insert = "~{fragments :: message}" ></ div >
2. Separar Lògica en Service
// NO fer això en el controller
model . addAttribute ( "result" , complexCalculation ());
// FER AIXÒ en el service
model . addAttribute ( "result" , userService . complexCalculation ());
3. Usar DTOs
@GetMapping
public String listUsers ( Model model ) {
List < UserDTO > users = userService . getAllUsersAsDTO ();
model . addAttribute ( "users" , users );
return "user/list" ;
}
4. Cache de Templates en Producció
spring :
thymeleaf :
cache : false # Development
# cache: true # Production
Comparació: REST API vs Thymeleaf en Spring Boot
Característica
REST API
Thymeleaf
Tipus de resposta
JSON
HTML
Client
Aplicació separada
Navegador
SEO
Dolent (client rendering)
Bon (server rendering)
Complexitat
Simpler per APIs
Simpler per aplicacions MVC
Interactivitat
Necessita JS framework
HTML natiu + JS
Escalabilitat
Millor per microserveis
Millor per monòlit
Recursos
24 de novembre del 2025
12 de novembre del 2025