Labs ICT
Pro Login

Practical Example: Blog

Building a complete blog with Thymeleaf templates.

Blog Layout with Fragments

Let's put everything together with a real blog template. We'll use fragments for the header, footer, and sidebar, then build out the main content area with posts, comments, and pagination.


<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title th:text="${pageTitle} + ' | My Blog'">My Blog</title>
    <link rel="stylesheet" href="/css/blog.css"/>
</head>
<body>
    <div th:replace="~{fragments/header :: header}"></div>

    <main class="container">
        <div class="content-area">
            <div th:replace="~{fragments/post-list :: postList(${posts})}"></div>
            <div th:replace="~{fragments/pagination :: paginate(${currentPage}, ${totalPages})}"></div>
        </div>
        <aside class="sidebar">
            <div th:replace="~{fragments/sidebar :: sidebar}"></div>
        </aside>
    </main>

    <div th:replace="~{fragments/footer :: footer}"></div>
</body>
</html>
    

This layout pulls together four fragments. Each fragment handles its own piece of the page, keeping the main template clean and focused on structure.

Displaying Blog Posts

The post list fragment iterates over your posts and displays each one with a title, excerpt, author, and date.


<div th:fragment="postList(posts)">
    <article th:each="post : ${posts}" class="post-card">
        <h2>
            <a th:href="@{/blog/{id}(id=${post.id})}" th:text="${post.title}">Post Title</a>
        </h2>
        <p class="meta">
            By <span th:text="${post.author.name}">Author</span>
            on <time th:text="${#temporals.format(post.createdAt, 'MMM dd, yyyy')}">Date</time>
        </p>
        <p th:text="${post.excerpt}">Post excerpt goes here...</p>
        <a th:href="@{/blog/{id}(id=${post.id})}" class="read-more">Read more →</a>
    </article>
</div>
    

The th:each loop handles the repetition, while th:text and th:href inject the actual data. Clean, readable, and maintainable.

Try it Yourself →

Comments Section

Each blog post can have comments. We'll display them with a form for adding new ones.


<section class="comments">
    <h3>Comments (<span th:text="${#lists.size(post.comments)}">0</span>)</h3>

    <div th:each="comment : ${post.comments}" class="comment">
        <strong th:text="${comment.author}">Author</strong>
        <time th:text="${#temporals.format(comment.date, 'MMM dd, yyyy HH:mm')}">Date</time>
        <p th:text="${comment.body}">Comment text</p>
    </div>

    <form th:action="@{/blog/{id}/comment(id=${post.id})}" method="post" class="comment-form">
        <input type="text" name="author" placeholder="Your name" required="required"/>
        <textarea name="body" placeholder="Write a comment..." required="required"></textarea>
        <button type="submit">Post Comment</button>
    </form>
</section>
    

The form posts to a controller endpoint. Thymeleaf's th:action generates the correct URL with the post ID, so your controller knows which post the comment belongs to.

Sidebar and Pagination

The sidebar shows recent posts and categories, while pagination lets users navigate through multiple pages of posts.


<div th:fragment="sidebar">
    <h3>Recent Posts</h3>
    <ul>
        <li th:each="recent : ${recentPosts}">
            <a th:href="@{/blog/{id}(id=${recent.id})}" th:text="${recent.title}">Title</a>
        </li>
    </ul>

    <h3>Categories</h3>
    <ul>
        <li th:each="cat : ${categories}">
            <a th:href="@{/blog/category/{slug}(slug=${cat.slug})}" th:text="${cat.name}">Category</a>
        </li>
    </ul>
</div>

<div th:fragment="paginate(current, total)">
    <nav class="pagination">
        <a th:if="${current > 1}" th:href="@{/blog(page=${current - 1})}">← Previous</a>
        <span th:text="'Page ' + ${current} + ' of ' + ${total}">Page 1 of 5</span>
        <a th:if="${current < total}" th:href="@{/blog(page=${current + 1})}">Next →</a>
    </nav>
</div>
    

The pagination fragment conditionally shows the Previous and Next links. When you're on page 1, there's no Previous link. When you're on the last page, there's no Next link. Simple and effective.

The Controller Behind It

All these templates need is a controller that passes the right data. Here's what a typical blog controller looks like.


@Controller
@RequestMapping("/blog")
public class BlogController {

    @GetMapping
    public String listPosts(
            @RequestParam(defaultValue = "1") int page,
            Model model) {
        Page<Post> posts = postService.getPosts(page, 10);
        model.addAttribute("posts", posts.getContent());
        model.addAttribute("currentPage", page);
        model.addAttribute("totalPages", posts.getTotalPages());
        model.addAttribute("recentPosts", postService.getRecent(5));
        model.addAttribute("categories", categoryService.getAll());
        model.addAttribute("pageTitle", "Blog");
        return "pages/blog/list";
    }
}
    

The controller is responsible for fetching data and putting it in the model. The template just renders what it's given. This separation keeps both layers clean and testable.