STAGE 6 OF 37

Stage 6: introduce Model and Thymeleaf with a product catalog

Learning path / Stage 6 of 37

Before you start: use JDK 21+ and Maven 3.9+. Extract this stage’s project and open a terminal in its pom.xml folder. Complete the setup below, including a database connection where required, then run mvn spring-boot:run. Use the port specified for this stage and stop with Ctrl+C.

We have a text file containing an array of 30 product objects. We want one table row per product and a More details link in each row. A second template displays the selected product's complete details.

Download spring-products-thymeleaf.zip

Run: extract the ZIP, open a terminal beside pom.xml and run mvn spring-boot:run. Requires JDK 21+ and Maven 3.9+. Choose a free port in src/main/resources/application.properties; this project defaults to 8085. Open http://localhost:8085/products after startup. Stop with Ctrl+C.

See the two requests separately
GET /products
 -> listProducts(model)
 -> model.addAttribute("products", products)
 -> return "products"
 -> Thymeleaf repeats a table row for each product
 -> browser receives HTML containing 30 product rows

Click More details for product 1
 -> GET /products/1
 -> productDetails(1, model)
 -> find the matching Product object
 -> model.addAttribute("product", product)
 -> return "product-details"
 -> Thymeleaf renders product-details.html with all 8 fields

1. Start with the data file

src/main/resources/data/products.txt is an ordinary UTF-8 text file. Its contents use JSON format: square brackets contain an array and each pair of braces contains one product. There are exactly 30 objects. The file stays on the server; the browser receives rendered HTML.

First product — excerpt from the complete 30-product file
{
  "id": 1,
  "name": "Wireless Mouse",
  "category": "Electronics",
  "price": 699,
  "brand": "EduTech",
  "stock": 12,
  "description": "Wireless Mouse for everyday learning, study and work. Product 1 includes a simple user guide and protective packaging.",
  "warrantyMonths": 12
}

Java needs Product objects to work with this JSON text. Jackson reads the file and converts its array into Product[]. The Product class describes each object's fields. These data objects are not automatically Spring beans.

2. Pass the array to the table template

The list method — part of ProductController.java
@GetMapping({"/", "/products"})
public String listProducts(Model model) {
    model.addAttribute("products", products);
    return "products";
}

Read this slowly: Spring supplies a Model for this request. Our code places the Java array in it under the name products. Returning "products" selects templates/products.html. The attribute name and template name happen to match here, but they serve different jobs.

CodeMeaning
Model modelSpring provides a place to carry named data to this request's HTML view.
"products" in addAttributeThe name the template uses to find the array.
products after the commaThe actual Product[] Java array containing the 30 objects.
return "products"Select the products.html template, rather than returning text directly.

A model attribute means a named value placed in Model. Here we use model.addAttribute(). Model does not read the file or calculate values; our Java code prepares the data.

3. Repeat one HTML row for each product

The row inside products.html
<tr th:each="product : ${products}">
    <td th:text="${product.id}">1</td>
    <td th:text="${product.name}">Wireless Mouse</td>
    <td th:text="${product.category}">Electronics</td>
    <td th:text="${product.price}">699</td>
    <td><a th:href="@{/products/{id}(id=${product.id})}">More details</a></td>
</tr>

th:each reads products from Model. For each object it creates a row and calls that current object product. th:text writes a property as escaped text. For example, product.name reads getName(). One template row becomes 30 rows in the generated response. The placeholder Wireless Mouse is replaced by the actual name in each iteration.

4. Build a link for the current row

th:href builds /products/1 for product 1, /products/2 for product 2, and so on. Clicking the link sends a new request. The ID travels in the URL; the whole product object is not sent by the link.

5. Select one product and a different template

The details method — part of ProductController.java
@GetMapping("/products/{id}")
public String productDetails(@PathVariable("id") int id, Model model) {
    for (Product product : products) {
        if (product.getId() == id) {
            model.addAttribute("product", product);
            return "product-details";
        }
    }
    throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
}

@PathVariable reads the selected ID. The loop finds the matching object in our server-side array. This request gets its own Model: we explicitly add the chosen object under product. We do not rely on the previous table request's Model. Returning "product-details" selects a different file, templates/product-details.html.

6. Display the selected object's details

Examples inside product-details.html
<h2 th:text="${product.name}">Product name</h2>
<p th:text="${product.description}">Description</p>
<p th:text="${product.brand}">Brand</p>

The complete details template shows all eight fields: ID, name, category, price, brand, stock, description and warranty months. The same template works for every product because the controller supplies a different product object.

7. Understand the file-reading step

The controller constructor reads products.txt once during application startup. ClassPathResource can read the bundled resource even from the packaged JAR. try-with-resources closes the input stream. JsonMapper converts JSON text into a Product array. This Spring Boot 4 project uses Jackson 3's tools.jackson package. The read-only array is reused; it does not hold per-user request state. Restart after editing the text file; rebuild first if running a packaged JAR.

8. Verify the result yourself

  1. Open /products and confirm 30 rows and 30 More details links.
  2. Click product 1: the address becomes /products/1 and all its fields appear.
  3. Return to the table and open product 30. The same detail template now shows Travel Mug.
  4. Try /products/999: the controller returns HTTP 404 because there is no matching product.
  5. Edit a product name in products.txt, restart and verify the table and detail page both use the new name.
Open the complete Java and template files
src/main/java/com/edupoly/products/ProductController.java — complete file
package com.edupoly.products;

import java.io.IOException;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.databind.json.JsonMapper;

@Controller
public class ProductController {
    private final Product[] products;

    // Read the text file once when Spring creates this controller.
    public ProductController() throws IOException {
        var resource = new ClassPathResource("data/products.txt");
        try (var input = resource.getInputStream()) {
            JsonMapper mapper = JsonMapper.builder().build();
            products = mapper.readValue(input, Product[].class);
        }
    }

    @GetMapping({"/", "/products"})
    public String listProducts(Model model) {
        model.addAttribute("products", products);
        return "products";
    }

    @GetMapping("/products/{id}")
    public String productDetails(@PathVariable("id") int id, Model model) {
        for (Product product : products) {
            if (product.getId() == id) {
                model.addAttribute("product", product);
                return "product-details";
            }
        }
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
    }
}
src/main/resources/templates/products.html — complete file
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Products</title>
    <link rel="stylesheet" th:href="@{/style.css}">
</head>
<body>
<main>
    <h1>Products</h1>
    <p>Basic details for 30 products. Select More details to see a product's complete information.</p>
    <div class="table-scroll">
    <table>
        <thead>
        <tr><th>ID</th><th>Name</th><th>Category</th><th>Price (INR)</th><th>Action</th></tr>
        </thead>
        <tbody>
        <tr th:each="product : ${products}">
            <td th:text="${product.id}">1</td>
            <td th:text="${product.name}">Wireless Mouse</td>
            <td th:text="${product.category}">Electronics</td>
            <td th:text="${product.price}">699</td>
            <td><a th:href="@{/products/{id}(id=${product.id})}">More details</a></td>
        </tr>
        </tbody>
    </table>
    </div>
</main>
</body>
</html>
src/main/resources/templates/product-details.html — complete file
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title th:text="${product.name}">Product details</title>
    <link rel="stylesheet" th:href="@{/style.css}">
</head>
<body>
<main class="detail">
    <h1>Product details</h1>
    <h2 th:text="${product.name}">Wireless Mouse</h2>
    <dl>
        <dt>ID</dt><dd th:text="${product.id}">1</dd>
        <dt>Name</dt><dd th:text="${product.name}">Wireless Mouse</dd>
        <dt>Category</dt><dd th:text="${product.category}">Electronics</dd>
        <dt>Price (INR)</dt><dd th:text="${product.price}">699</dd>
        <dt>Brand</dt><dd th:text="${product.brand}">EduTech</dd>
        <dt>Stock</dt><dd th:text="${product.stock}">12</dd>
        <dt>Description</dt><dd th:text="${product.description}">Product description</dd>
        <dt>Warranty (months)</dt><dd th:text="${product.warrantyMonths}">12</dd>
    </dl>
    <a th:href="@{/products}">Back to products</a>
</main>
</body>
</html>

The ZIP also contains Product.java, all 30 objects, the startup class, pom.xml, port configuration, CSS and README.html. No database is needed.

After Stage 6: the Spring words to recognize

Focus: Give Java data to HTML without building HTML strings in the controller.

TermOur concrete exampleIts place in Spring
ModellistProducts(Model model) receives the view-data holder for the table request.Spring MVC supplies a Model to carry named data to the selected view.
Model attributeaddAttribute("products", products) puts our Product array under the name products.A model attribute is a named value available to the view for this request.
View name / templatereturn "products" selects products.html; return "product-details" selects product-details.html.Spring MVC uses the view name to select the HTML template to render.
Thymeleafth:each repeats rows, th:text displays values, and th:href builds each details link.Thymeleaf renders HTML on the server using the values supplied through Model.

Product.java describes ordinary data objects. The 30 Product objects are not registered as Spring beans in this example. The product-details request receives a new Model; the controller adds its selected product again.

You should now be able to say: The controller supplies products through Model. Thymeleaf turns those products into HTML rows.

One small check: When /products/1 is requested, which product is added to Model and which template is selected?

Concepts used in this stage

Use the right column for a quick reminder, or open a concept above for its full explanation.

Java 21 · Spring Boot 4.1.1 · Edupoly