Lab: Creating a 'Student Portal' Web Application
Objective:
Develop a basic Spring Boot web application that displays student details dynamically using Thymeleaf.
Prerequisites
- JDK 17 or higher installed
- Maven or Gradle installed
- A text editor or IDE
- Completed Module 1 lab
Step 1: Set Up the Project
Start by extending the project from Module 1 or creating a new project:
If creating a new project, use Spring Initializr:
- Go to start.spring.io
- Configure:
- Project: Maven
- Language: Java
- Spring Boot: 3.2.x
- Group: com.example
- Artifact: student-portal
- Dependencies:
- Spring Web
- Thymeleaf
- Spring Boot DevTools (for auto-reload)
- Validation (for form validation)
- Generate and extract the project
If continuing from Module 1:
- Add the following dependencies to your
pom.xml
:
<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-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
Step 2: Create the Student Model
Create a new package com.example.studentportal.model
and add the Student.java
class:
package com.example.studentportal.model;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public class Student {
private Long id;
@NotBlank(message = "Name is required")
@Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters")
private String name;
@NotNull(message = "Age is required")
@Min(value = 16, message = "Age must be at least 16")
private Integer age;
@NotBlank(message = "Email is required")
@Email(message = "Please provide a valid email address")
private String email;
@NotBlank(message = "Course is required")
private String course;
@Pattern(regexp = "^[A-Z0-9]{8}$", message = "Student ID must be 8 characters with uppercase letters and numbers only")
private String studentId;
// Default constructor
public Student() {
}
// Constructor with fields
public Student(Long id, String name, Integer age, String email, String course, String studentId) {
this.id = id;
this.name = name;
this.age = age;
this.email = email;
this.course = course;
this.studentId = studentId;
}
// Getters and setters
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getCourse() {
return course;
}
public void setCourse(String course) {
this.course = course;
}
public String getStudentId() {
return studentId;
}
public void setStudentId(String studentId) {
this.studentId = studentId;
}
@Override
public String toString() {
return "Student{id=" + id + ", name='" + name + "', age=" + age +
", email='" + email + "', course='" + course + "', studentId='" + studentId + "'}";
}
}
Step 3: Create a Service Layer
Create a new package com.example.studentportal.service
and add the StudentService.java
interface:
package com.example.studentportal.service;
import com.example.studentportal.model.Student;
import java.util.List;
import java.util.Optional;
public interface StudentService {
List<Student> getAllStudents();
Optional<Student> getStudentById(Long id);
Student saveStudent(Student student);
void deleteStudent(Long id);
}
Now create the implementation StudentServiceImpl.java
:
package com.example.studentportal.service;
import com.example.studentportal.model.Student;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
@Service
public class StudentServiceImpl implements StudentService {
// In-memory database using a Map
private final Map<Long, Student> studentDb = new HashMap<>();
private final AtomicLong idCounter = new AtomicLong();
public StudentServiceImpl() {
// Add some sample data
Student student1 = new Student(idCounter.incrementAndGet(), "John Doe", 21,
"[email protected]", "Computer Science", "CS123456");
Student student2 = new Student(idCounter.incrementAndGet(), "Jane Smith", 22,
"[email protected]", "Mathematics", "MT789012");
Student student3 = new Student(idCounter.incrementAndGet(), "Bob Johnson", 20,
"[email protected]", "Physics", "PH345678");
studentDb.put(student1.getId(), student1);
studentDb.put(student2.getId(), student2);
studentDb.put(student3.getId(), student3);
}
@Override
public List<Student> getAllStudents() {
return new ArrayList<>(studentDb.values());
}
@Override
public Optional<Student> getStudentById(Long id) {
return Optional.ofNullable(studentDb.get(id));
}
@Override
public Student saveStudent(Student student) {
if (student.getId() == null) {
// New student
student.setId(idCounter.incrementAndGet());
}
studentDb.put(student.getId(), student);
return student;
}
@Override
public void deleteStudent(Long id) {
studentDb.remove(id);
}
}
Step 4: Create the Controller
Create a new package com.example.studentportal.controller
and add the StudentController.java
class:
package com.example.studentportal.controller;
import com.example.studentportal.model.Student;
import com.example.studentportal.service.StudentService;
import jakarta.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.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class StudentController {
private final StudentService studentService;
@Autowired
public StudentController(StudentService studentService) {
this.studentService = studentService;
}
@GetMapping("/")
public String home(Model model) {
model.addAttribute("students", studentService.getAllStudents());
return "index";
}
@GetMapping("/students")
public String listStudents(Model model) {
model.addAttribute("students", studentService.getAllStudents());
return "students/list";
}
@GetMapping("/students/{id}")
public String viewStudent(@PathVariable Long id, Model model) {
Student student = studentService.getStudentById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid student ID: " + id));
model.addAttribute("student", student);
return "students/view";
}
@GetMapping("/students/new")
public String showNewStudentForm(Model model) {
model.addAttribute("student", new Student());
return "students/form";
}
@PostMapping("/students/save")
public String saveStudent(@Valid @ModelAttribute("student") Student student,
BindingResult result, RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {
return "students/form";
}
studentService.saveStudent(student);
redirectAttributes.addFlashAttribute("successMessage", "Student saved successfully!");
return "redirect:/students";
}
@GetMapping("/students/edit/{id}")
public String showEditForm(@PathVariable Long id, Model model) {
Student student = studentService.getStudentById(id)
.orElseThrow(() -> new IllegalArgumentException("Invalid student ID: " + id));
model.addAttribute("student", student);
return "students/form";
}
@GetMapping("/students/delete/{id}")
public String deleteStudent(@PathVariable Long id, RedirectAttributes redirectAttributes) {
studentService.deleteStudent(id);
redirectAttributes.addFlashAttribute("successMessage", "Student deleted successfully!");
return "redirect:/students";
}
}
Step 5: Create Thymeleaf Templates
Create the following directory structure in src/main/resources/templates/
:
templates/
├── fragments/
│ ├── header.html
│ ├── footer.html
│ └── layout.html
├── students/
│ ├── form.html
│ ├── list.html
│ └── view.html
└── index.html
- Create
fragments/header.html
:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div th:fragment="header">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" th:href="@{/}">Student Portal</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="@{/}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/students}">Students</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/students/new}">Add Student</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</body>
</html>
- Create
fragments/footer.html
:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div th:fragment="footer">
<footer class="footer mt-auto py-3 bg-light">
<div class="container text-center">
<span class="text-muted">Student Portal © 2025</span>
</div>
</footer>
</div>
</body>
</html>
- Create
fragments/layout.html
:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" th:fragment="layout (title, content)">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title th:text="${title}">Student Portal</title>
<link
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link rel="stylesheet" th:href="@{/css/styles.css}" />
</head>
<body class="d-flex flex-column min-vh-100">
<div th:replace="fragments/header :: header"></div>
<div class="container my-4">
<div
th:if="${successMessage}"
class="alert alert-success alert-dismissible fade show"
role="alert"
>
<span th:text="${successMessage}"></span>
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
<div th:replace="${content}"></div>
</div>
<div th:replace="fragments/footer :: footer"></div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script th:src="@{/js/scripts.js}"></script>
</body>
</html>
- Create
index.html
:
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
th:replace="fragments/layout :: layout(~{::title}, ~{::main})"
>
<head>
<title>Home - Student Portal</title>
</head>
<body>
<main>
<div class="jumbotron">
<h1 class="display-4">Welcome to Student Portal</h1>
<p class="lead">A platform to manage student information.</p>
<hr class="my-4" />
<p>You can view, add, edit, and delete student records.</p>
<a class="btn btn-primary btn-lg" th:href="@{/students}" role="button"
>View Students</a
>
</div>
<div class="row mt-5">
<div class="col-md-4 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Total Students</h5>
<p class="card-text display-4" th:text="${students.size()}">0</p>
<a th:href="@{/students}" class="card-link">View All</a>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Quick Actions</h5>
<a th:href="@{/students/new}" class="btn btn-success"
>Add New Student</a
>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Recent Students</h5>
<ul class="list-group list-group-flush">
<li
class="list-group-item"
th:each="student, stat : ${students}"
th:if="${stat.index < 3}"
>
<a
th:href="@{/students/{id}(id=${student.id})}"
th:text="${student.name}"
>Student Name</a
>
</li>
</ul>
</div>
</div>
</div>
</div>
</main>
</body>
</html>
- Create
students/list.html
:
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
th:replace="fragments/layout :: layout(~{::title}, ~{::main})"
>
<head>
<title>Students - Student Portal</title>
</head>
<body>
<main>
<h2>Students</h2>
<a th:href="@{/students/new}" class="btn btn-primary mb-3"
>Add New Student</a
>
<div th:if="${#lists.isEmpty(students)}" class="alert alert-info">
No students found. Please add a student.
</div>
<div th:unless="${#lists.isEmpty(students)}">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Name</th>
<th>Age</th>
<th>Email</th>
<th>Course</th>
<th>Student ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="student : ${students}">
<td th:text="${student.id}">1</td>
<td th:text="${student.name}">John Doe</td>
<td th:text="${student.age}">21</td>
<td th:text="${student.email}">[email protected]</td>
<td th:text="${student.course}">Computer Science</td>
<td th:text="${student.studentId}">CS123456</td>
<td>
<a
th:href="@{/students/{id}(id=${student.id})}"
class="btn btn-sm btn-info"
>View</a
>
<a
th:href="@{/students/edit/{id}(id=${student.id})}"
class="btn btn-sm btn-warning"
>Edit</a
>
<a
th:href="@{/students/delete/{id}(id=${student.id})}"
class="btn btn-sm btn-danger"
onclick="return confirm('Are you sure you want to delete this student?');"
>Delete</a
>
</td>
</tr>
</tbody>
</table>
</div>
</main>
</body>
</html>
- Create
students/view.html
:
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
th:replace="fragments/layout :: layout(~{::title}, ~{::main})"
>
<head>
<title th:text="${'Student - ' + student.name}">Student Details</title>
</head>
<body>
<main>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Student Details</h2>
<div>
<a th:href="@{/students}" class="btn btn-secondary">Back to List</a>
<a
th:href="@{/students/edit/{id}(id=${student.id})}"
class="btn btn-warning"
>Edit</a
>
</div>
</div>
<div class="card">
<div class="card-header bg-primary text-white">
<h4 th:text="${student.name}" class="mb-0">John Doe</h4>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>ID:</strong> <span th:text="${student.id}">1</span></p>
<p>
<strong>Name:</strong>
<span th:text="${student.name}">John Doe</span>
</p>
<p>
<strong>Age:</strong> <span th:text="${student.age}">21</span>
</p>
</div>
<div class="col-md-6">
<p>
<strong>Email:</strong>
<span th:text="${student.email}">[email protected]</span>
</p>
<p>
<strong>Course:</strong>
<span th:text="${student.course}">Computer Science</span>
</p>
<p>
<strong>Student ID:</strong>
<span th:text="${student.studentId}">CS123456</span>
</p>
</div>
</div>
</div>
</div>
</main>
</body>
</html>
- Create
students/form.html
:
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
th:replace="fragments/layout :: layout(~{::title}, ~{::main})"
>
<head>
<title th:text="${student.id == null ? 'Add Student' : 'Edit Student'}">
Student Form
</title>
</head>
<body>
<main>
<h2 th:text="${student.id == null ? 'Add New Student' : 'Edit Student'}">
Student Form
</h2>
<a th:href="@{/students}" class="btn btn-secondary mb-3">Back to List</a>
<div class="card">
<div class="card-body">
<form
th:action="@{/students/save}"
th:object="${student}"
method="post"
>
<input type="hidden" th:field="*{id}" />
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input
type="text"
class="form-control"
th:field="*{name}"
id="name"
/>
<div
class="text-danger"
th:if="${#fields.hasErrors('name')}"
th:errors="*{name}"
>
Name Error
</div>
</div>
<div class="mb-3">
<label for="age" class="form-label">Age</label>
<input
type="number"
class="form-control"
th:field="*{age}"
id="age"
/>
<div
class="text-danger"
th:if="${#fields.hasErrors('age')}"
th:errors="*{age}"
>
Age Error
</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input
type="email"
class="form-control"
th:field="*{email}"
id="email"
/>
<div
class="text-danger"
th:if="${#fields.hasErrors('email')}"
th:errors="*{email}"
>
Email Error
</div>
</div>
<div class="mb-3">
<label for="course" class="form-label">Course</label>
<select class="form-select" th:field="*{course}" id="course">
<option value="">Select a course</option>
<option value="Computer Science">Computer Science</option>
<option value="Mathematics">Mathematics</option>
<option value="Physics">Physics</option>
<option value="Chemistry">Chemistry</option>
<option value="Biology">Biology</option>
<option value="Engineering">Engineering</option>
</select>
<div
class="text-danger"
th:if="${#fields.hasErrors('course')}"
th:errors="*{course}"
>
Course Error
</div>
</div>
<div class="mb-3">
<label for="studentId" class="form-label">Student ID</label>
<input
type="text"
class="form-control"
th:field="*{studentId}"
id="studentId"
placeholder="e.g., CS123456"
/>
<div class="text-small text-muted">
Must be 8 characters with uppercase letters and numbers only
</div>
<div
class="text-danger"
th:if="${#fields.hasErrors('studentId')}"
th:errors="*{studentId}"
>
Student ID Error
</div>
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a th:href="@{/students}" class="btn btn-outline-secondary"
>Cancel</a
>
</form>
</div>
</div>
</main>
</body>
</html>
Step 6: Add Static Resources
Create the following directory structure in src/main/resources/static/
:
static/
├── css/
│ └── styles.css
└── js/
└── scripts.js
- Create
css/styles.css
:
/* Custom styles for Student Portal */
body {
padding-bottom: 60px;
}
.jumbotron {
padding: 2rem 1rem;
margin-bottom: 2rem;
background-color: #e9ecef;
border-radius: 0.3rem;
}
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
margin-bottom: 1.5rem;
}
.card-title {
color: #495057;
font-weight: 600;
}
.footer {
margin-top: auto;
}
- Create
js/scripts.js
:
// Custom JavaScript for Student Portal
document.addEventListener("DOMContentLoaded", function () {
// Auto-hide alerts after 5 seconds
setTimeout(function () {
const alerts = document.querySelectorAll(".alert");
alerts.forEach(function (alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
});
}, 5000);
// Confirm delete actions
const deleteLinks = document.querySelectorAll('a[href*="/delete/"]');
deleteLinks.forEach(function (link) {
link.addEventListener("click", function (event) {
if (!confirm("Are you sure you want to delete this item?")) {
event.preventDefault();
}
});
});
});
Step 7: Create an Exception Handler
Create a new package com.example.studentportal.exception
and add the GlobalExceptionHandler.java
class:
package com.example.studentportal.exception;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public String handleIllegalArgumentException(IllegalArgumentException ex, RedirectAttributes redirectAttributes) {
redirectAttributes.addFlashAttribute("errorMessage", ex.getMessage());
return "redirect:/students";
}
@ExceptionHandler(Exception.class)
public String handleGenericException(Exception ex, Model model) {
model.addAttribute("errorMessage", "An unexpected error occurred: " + ex.getMessage());
return "error";
}
}
Step 8: Create a Custom Error Page
Create error.html
in the templates
directory:
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
th:replace="fragments/layout :: layout(~{::title}, ~{::main})"
>
<head>
<title>Error - Student Portal</title>
</head>
<body>
<main>
<div class="text-center">
<h1 class="display-1">Oops!</h1>
<p class="lead">Something went wrong.</p>
<div
class="alert alert-danger"
th:if="${errorMessage}"
th:text="${errorMessage}"
>
Error message goes here
</div>
<div class="my-4">
<a th:href="@{/}" class="btn btn-primary">Go Home</a>
<a th:href="@{/students}" class="btn btn-secondary">View Students</a>
</div>
</div>
</main>
</body>
</html>
Step 9: Configure Spring Boot Application Properties
Update the application.properties
file in src/main/resources/
:
# Server configuration
server.port=8080
# Thymeleaf configuration
spring.thymeleaf.cache=false
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML
# Logging configuration
logging.level.org.springframework.web=INFO
logging.level.com.example.studentportal=DEBUG
# Static resources configuration
spring.mvc.static-path-pattern=/static/**
# Server error handling
server.error.include-message=always
server.error.include-binding-errors=always
Step 10: Run the Application
- Start the application:
- From IDE: Run the main application class
- With Maven:
mvn spring-boot:run
- With Gradle:
./gradlew bootRun
- Open a web browser and navigate to:
http://localhost:8080/
- Test the application by:
- Viewing the student list
- Adding a new student
- Editing an existing student
- Viewing student details
- Deleting a student
Step 11: Enhance the Application (To do)
Add these enhancements:
- Add student search functionality
- Implement sorting in the student list
- Add pagination for the student list
- Implement a dark mode toggle with JavaScript
- Add form validation on the client side using JavaScript
Step 12: Documentation and Reflection
Write down answers to the following questions:
- How does Thymeleaf help in creating dynamic web content?
- What is the purpose of the
@Valid
annotation in form handling? - How does the flash attribute mechanism work in Spring MVC?
- Explain the layered architecture used in this application.
- How would you extend this application to use a real database instead of in-memory storage?
This module has taught you how to build a complete web application using Spring Boot and Thymeleaf. You’ve learned about:
- Spring MVC architecture and request flow
- Controller annotations and handler methods
- Creating and using models
- Thymeleaf templating syntax and features
- Form handling and validation
- Static resource management
- Layout and fragment reuse with Thymeleaf
- Error handling and exception management
By Wahid Hamdi