Displaying Data, Pagination & Sorting
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring Data JPA with H2) and Java 21,
checked on 2026-10-08, and the examples were compiled and run on that stack (Maven wrapper, MockMvc, JDK 26 with --release 21; the
rendered output shown is the real output, trimmed only where it says so). Iterating and conditionals are Standard Dialect features that work
standalone, with Spring MVC and with Spring WebFlux; the Pageable argument resolver, Page handling in controllers and
ServletUriComponentsBuilder are Spring MVC (Servlet) features, so the paging parts of this page apply to Spring MVC. WebFlux has its own argument
resolution, which this page does not cover.
A list page is the first thing every application needs and the first thing that stops scaling: ten rows are a table, ten thousand rows are an outage.
This page continues the Bookshelf scenario with a public catalogue: a table of books that is paged, sortable by
column and searchable, whose links always carry the current search, sort and page size. The Thymeleaf side is small (th:each, link expressions
and a fragment); the substance is in how Spring Data turns request parameters into a Pageable, and in keeping those parameters alive across links.
page, size, sort"] res --> wl["Whitelist the sort properties
cap the page size"] wl --> repo["Repository method
returns Page"] repo --> model["Model attribute page"] model --> tpl["Template
table, sort links, pagination fragment"] tpl --> html["HTML with links that keep q, size and sort"]
For the persistence side (entities, derived queries, Pageable in repository methods) see Spring Data JPA; for
why large offsets get slow and what keyset pagination does about it, see Pagination: Offset vs. Keyset.
Tables, lists and empty states
A table in Thymeleaf is th:each on the repeated element, th:text for the cells and a separate branch for "there is nothing to show". The empty state
is not an afterthought: a search that matches nothing is a normal result, and a table with a header and no rows looks like a bug.
@Entity
public class CatalogBook {
@Id
@GeneratedValue
private Long id;
private String title;
private String author;
private int publicationYear;
protected CatalogBook() {
}
public CatalogBook(String title, String author, int publicationYear) {
this.title = title;
this.author = author;
this.publicationYear = publicationYear;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
public int getPublicationYear() { return publicationYear; }
}
A plain list is the smallest case. The controller returns the three most recent books (a sorted, size-limited query, not findAll() followed by a loop):
@GetMapping("/catalog/latest")
public String latest(Model model) {
model.addAttribute("books", repository.findAll(PageRequest.of(0, 3, Sort.by("publicationYear").descending())).getContent());
return "catalog/latest";
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<ul th:if="${not #lists.isEmpty(books)}">
<li th:each="book, stat : ${books}">
<span th:text="${stat.count}">1</span>. <span th:text="${book.title}">Title</span>
<em th:if="${stat.last}">(last)</em>
</li>
</ul>
<p th:if="${#lists.isEmpty(books)}">Nothing to show yet.</p>
</body>
</html>
The second variable of th:each (stat) is the iteration status object: index, count, size, current, even, odd, first and last. Rendered output:
<ul>
<li>
<span>1</span>. <span>Sample Book 29</span>
</li>
<li>
<span>2</span>. <span>Sample Book 28</span>
</li>
<li>
<span>3</span>. <span>Sample Book 27</span>
<em>(last)</em>
</li>
</ul>
With an empty table the same template renders the other branch:
<p>Nothing to show yet.</p>
Three ways to test for "nothing" appear in templates, and they are not interchangeable:
| Test | Works on |
|---|---|
|
A |
|
A Spring Data |
|
A string, for example to decide whether a search term is active |
Spring Data Pageable and Page
A Pageable says which slice of a result set is wanted: a zero-based page number, a page size and a Sort. A Page<T> is the answer: the content of that
slice plus the metadata a view needs (total number of elements and pages, whether there is a next or previous page). Spring Data creates the Pageable for a
controller method when the method simply declares one as an argument, reading the request parameters page, size and sort, so GET /catalog?page=2&size=10&sort=author,desc
arrives as page index 2 (the third page), ten rows per page, ordered by author descending.
Page carries a price: to know the total, the repository runs a second count query. A Slice skips it and only knows whether a next slice exists, which is
all a "Previous / Next" or "load more" control needs. Both are covered below. For very deep paging neither is the right tool, because the database still
walks over every skipped row; keyset pagination (Window/ScrollPosition in Spring Data) is explained on Pagination: Offset vs. Keyset.
The members of Page and Slice that templates use:
| Template expression | Meaning |
|---|---|
|
The rows of the current page, a |
|
Zero-based index of the current page (always zero-based, even with one-indexed request parameters) |
|
The requested page size |
|
How many rows this page really holds (fewer than |
|
Totals; only on |
|
True on the first or last page ( |
|
Method calls, because there is no |
|
|
|
The |
The controller and the repository
The repository method takes the Pageable as its last parameter and returns a Page (or a Slice):
public interface CatalogBookRepository extends JpaRepository<CatalogBook, Long> {
Page<CatalogBook> findByTitleContainingIgnoreCase(String text, Pageable pageable);
Slice<CatalogBook> findSliceByTitleContainingIgnoreCase(String text, Pageable pageable);
}
The controller declares the Pageable, the search text and the model attribute, and never trusts the sort that came in (see Sort whitelist below):
@Controller
public class CatalogController {
private static final Set<String> SORTABLE = Set.of("title", "author", "publicationYear");
private final CatalogBookRepository repository;
public CatalogController(CatalogBookRepository repository) {
this.repository = repository;
}
@GetMapping("/catalog")
public String list(@ModelAttribute("search") SearchForm search,
@PageableDefault(size = 10)
@SortDefault(sort = "title", direction = Sort.Direction.ASC) Pageable requested,
Model model) {
Pageable pageable = whitelisted(requested);
Page<CatalogBook> page = repository.findByTitleContainingIgnoreCase(search.q(), pageable);
model.addAttribute("page", page);
return "catalog/list";
}
@GetMapping("/catalog/slice")
public String slice(@ModelAttribute("search") SearchForm search,
@PageableDefault(size = 10)
@SortDefault(sort = "title") Pageable requested,
Model model) {
Slice<CatalogBook> slice = repository.findSliceByTitleContainingIgnoreCase(search.q(), whitelisted(requested));
model.addAttribute("slice", slice);
return "catalog/slice";
}
@GetMapping("/catalog/variants")
public String variants(Model model) {
model.addAttribute("page", repository.findAll(PageRequest.of(0, 2, Sort.by("title"))));
model.addAttribute("search", new SearchForm(""));
return "catalog/variants";
}
@GetMapping("/catalog/defaults")
public String defaults(Pageable requested, Model model) {
Slice<CatalogBook> slice = repository.findSliceByTitleContainingIgnoreCase("", whitelisted(requested));
model.addAttribute("slice", slice);
return "catalog/slice";
}
@GetMapping("/catalog/latest")
public String latest(Model model) {
model.addAttribute("books", repository.findAll(PageRequest.of(0, 3, Sort.by("publicationYear").descending())).getContent());
return "catalog/latest";
}
/** Keeps only the sort orders that name a property the table really offers. */
static Pageable whitelisted(Pageable requested) {
List<Sort.Order> allowed = requested.getSort().stream()
.filter(order -> SORTABLE.contains(order.getProperty()))
.toList();
Sort sort = allowed.isEmpty() ? Sort.by("title") : Sort.by(allowed);
return PageRequest.of(requested.getPageNumber(), requested.getPageSize(), sort);
}
}
Defaults with @PageableDefault and @SortDefault
When the request carries no paging parameters the resolver falls back to a default page size of 20 and no sorting. @PageableDefault overrides the page number and size
(and can name the sort) for one method; @SortDefault sets the default sort, and @SortDefault.SortDefaults several. In the controller above @PageableDefault(size = 10) and
@SortDefault(sort = "title", direction = Sort.Direction.ASC) make GET /catalog mean "first ten books ordered by title". Both are only defaults: parameters in the request win.
When one controller method takes two pageables, @Qualifier("name") on each makes them read prefixed parameters, joined by a delimiter (name_page, name_size).
Pageable configuration properties
Spring Boot exposes the resolver settings as properties. These are the names and defaults from the Spring Boot 4.1.1 configuration metadata:
| Property | Default | Effect |
|---|---|---|
|
|
Page size when the request has none and no |
|
|
Upper bound for the requested size; larger values are cut to it |
|
|
Treat the |
|
|
Name of the page parameter |
|
|
Name of the page-size parameter |
|
empty |
Prefix added to the page and size parameter names |
|
|
Delimiter between a qualifier and the parameter name |
|
|
JSON serialisation mode of Spring Data pages; irrelevant to HTML views |
|
|
Name of the sort parameter |
The scratch application caps the page size, which matters more than it looks: without a cap ?size=1000000 is a request for the whole table.
spring.data.web.pageable.max-page-size=50
A request for size=5000 is served with fifty rows (the summary line of the response says Showing 1 to 50 of 53 books). To see the other properties at work, a second
test class renames the parameters and makes the page number one-based:
@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
@TestPropertySource(properties = {
"spring.data.web.pageable.default-page-size=4",
"spring.data.web.pageable.one-indexed-parameters=true",
"spring.data.web.pageable.page-parameter=p",
"spring.data.web.pageable.size-parameter=n",
"spring.data.web.sort.sort-parameter=order"})
class PageablePropertiesTest {
@Autowired MockMvc mvc;
@Autowired CatalogBookRepository repository;
@BeforeEach
void seed() {
repository.deleteAll();
repository.saveAll(IntStream.rangeClosed(1, 53)
.mapToObj(i -> new CatalogBook("Sample Book %02d".formatted(i), "Author", 2000))
.toList());
}
private String body(String uri) throws Exception {
var r = mvc.perform(get(uri)).andExpect(status().isOk()).andReturn().getResponse();
r.setCharacterEncoding("UTF-8");
return r.getContentAsString();
}
@Test
void renamedParameters() throws Exception {
System.out.println("=== PROPS default\n" + body("/catalog/defaults"));
System.out.println("=== PROPS p=2 n=3 order=title,desc\n" + body("/catalog/defaults?p=2&n=3&order=title,desc"));
System.out.println("=== PROPS ignored old names page=2 size=3\n" + body("/catalog/defaults?page=2&size=3"));
}
}
With those properties, GET /catalog/defaults (no parameters) returns four rows, the default page size, and GET /catalog/defaults?p=2&n=3&order=title,desc returns the second page
of three rows in descending title order, books 50, 49 and 48 (the one-based p=2 is page index 1). The old names are ignored: ?page=2&size=3 falls back to the defaults.
There are two traps. First, page.number is still zero-based when one-indexed-parameters is true, so a link to the next page must be built as page.number + 2
in a one-indexed application, and a display label as page.number + 1 in both. Second, the links in the templates hard-code the parameter names: after renaming them with the properties above,
a template that still writes page=... produces links the resolver no longer understands. In the test run, the generated "Next" link on that page was
<a href="/catalog/defaults?p=2&n=3&order=title,desc&page=2" rel="next">Next</a>
which adds an ignored page=2 next to the unchanged p=2. Change the names in the properties and the templates together, or leave them at the defaults.
Sort whitelist and size cap
sort=password,desc is a valid request as far as the resolver is concerned: it builds a Sort for any property name. Handing that to a repository that does not have the
property fails:
org.springframework.data.core.PropertyReferenceException: No property 'password' found for type 'CatalogBook'
Failing is the good outcome. With a hand-written @Query that concatenates the sort into the query text, or with Sort.unsafe(...), an unchecked sort is an injection vector, and
even a safe one lets a client probe which properties exist. The rule is the same as for any other request input: accept only a known set. The whitelisted method of the controller
above keeps only the orders whose property is in a Set and falls back to title; it is covered by a unit test:
@Test
void whitelistKeepsAllowedAndDropsUnknown() {
var requested = PageRequest.of(3, 7, Sort.by("author").descending().and(Sort.by("secret")));
var safe = CatalogController.whitelisted(requested);
assertEquals(3, safe.getPageNumber());
assertEquals(7, safe.getPageSize());
assertEquals(Sort.by("author").descending(), safe.getSort());
}
The same discipline applies to the size (the max-page-size property above) and to the search text, which here only reaches the repository as a bound parameter of a derived query. The view name
(catalog/list) is a constant; none of the request parameters ever selects a template or a fragment.
Seeding data and web form objects
The scratch application seeds its catalogue only under a demo profile, so that tests and production never get the sample rows:
@Configuration
@Profile("demo")
class DemoDataSeeder {
@Bean
ApplicationRunner seedCatalog(CatalogBookRepository repository) {
return args -> {
if (repository.count() == 0) {
repository.saveAll(IntStream.rangeClosed(1, 53)
.mapToObj(i -> new CatalogBook("Sample Book %02d".formatted(i),
"Author %d".formatted(i % 5 + 1), 1990 + i % 30))
.toList());
}
};
}
}
Source: Spring Boot — Profiles
Run it with ./mvnw spring-boot:run -Dspring-boot.run.profiles=demo; a test with @ActiveProfiles("demo") confirms that 53 books are present. The search box does not bind to a repository
parameter directly. A small record owned by the web layer carries what the form submits (and normalises it), so that the shape of the form can change without touching the persistence method:
/** Web-layer form data: what the search form submits, never handed to the repository as is. */
public record SearchForm(String q) {
public SearchForm {
q = q == null ? "" : q.strip();
}
}
The controller declares it as @ModelAttribute("search") SearchForm search; a missing q becomes the empty string, which matches every title. The record is also what the templates read (${search.q})
to keep the search term in the links. The same split (domain-side parameters, web-side form data) is used for validated forms in the Forms & Data Binding page of this section.
A table fragment with optional parameters
The table is the part that changes least between pages, so it lives in a fragment (see Fragments). A fragment’s declared parameters are mandatory: a call that leaves one out fails at render time with
Cannot resolve fragment. Signature "bookTable (books,showYear)" declares parameter "showYear", which is not specified at the fragment selection.
(that was the real message when showYear was a second parameter). An optional input is therefore not declared in the signature; the fragment reads it from the surrounding context
and gives it a default with the Elvis operator ?: inside th:with. The sortable headers are another fragment, shown in the next section.
<!-- Table with an optional input: showYear comes from the caller context and defaults to true. -->
<table th:fragment="bookTable(books)"
th:with="withYear=${showYear} ?: true">
<thead>
<tr>
<th:block th:replace="~{::sortHeader('title', 'Title')}"></th:block>
<th:block th:replace="~{::sortHeader('author', 'Author')}"></th:block>
<th:block th:if="${withYear}">
<th:block th:replace="~{::sortHeader('publicationYear', 'Year')}"></th:block>
</th:block>
</tr>
</thead>
<tbody>
<tr th:each="book, stat : ${books}" th:classappend="${stat.odd} ? 'odd'">
<td th:text="${book.title}">Title</td>
<td th:text="${book.author}">Author</td>
<td th:if="${withYear}" th:text="${book.publicationYear}">2001</td>
</tr>
</tbody>
</table>
${showYear} ?: true yields showYear when it is not null and true otherwise (a variable that does not exist evaluates to null in the Spring dialect), so the column is shown unless a caller opts out. A
caller opts out by defining the variable around the call, on a wrapper because th:replace runs before th:with on the same element:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h2>default</h2>
<table th:replace="~{catalog/fragments :: bookTable(${page.content})}"></table>
<h2>showYear=false</h2>
<th:block th:with="showYear=false">
<table th:replace="~{catalog/fragments :: bookTable(${page.content})}"></table>
</th:block>
</body>
</html>
In the rendered output of the first call the table has three columns, and of the second call two: the "Year" header and the year cells are gone. Note that the th:if for the header sits on a wrapper
th:block and the th:replace on an inner one: on a single element th:replace has higher precedence than th:if and would run first, so the condition would never be evaluated.
<table>
<thead>
<tr>
<th aria-sort="ascending">
<a href="/catalog?q=&size=2&page=0&sort=title,desc">Title</a>
<span aria-hidden="true">▲</span>
</th>
<th aria-sort="none">
<a href="/catalog?q=&size=2&page=0&sort=author,asc">Author</a>
</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Sample Book 01</td>
<td>Author 2</td>
</tr>
<tr>
<td>Sample Book 02</td>
<td>Author 3</td>
</tr>
</tbody>
</table>
Sortable column headers
A sortable header is a link to the same page with the sort replaced, and three details decide whether it works. It must keep everything else (search text, page size); it goes back to the first
page, because "page 4 of the old order" has no meaning in the new one; and it toggles the direction when the column is already the sort key. page.sort.getOrderFor(field) returns the
Sort.Order for the column or null, which gives both the current direction and the aria-sort value that assistive technology reads.
<!-- Sortable column header: keeps q and size, goes back to the first page, toggles direction. -->
<th th:fragment="sortHeader(field, label)"
th:with="current=${page.sort.getOrderFor(field)},
nextDir=${current != null and current.ascending} ? 'desc' : 'asc'"
th:attr="aria-sort=${current == null} ? 'none' : (${current.ascending} ? 'ascending' : 'descending')">
<a th:href="@{/catalog(q=${search.q}, size=${page.size}, page=0, sort=${field + ',' + nextDir})}"
th:text="${label}">Title</a>
<span th:if="${current != null}" th:text="${current.ascending} ? '▲' : '▼'" aria-hidden="true"></span>
</th>
Source: Using Thymeleaf — Link URLs
The link expression @{/catalog(q=${search.q}, size=${page.size}, page=0, sort=${field + ',' + nextDir})} adds each parameter to the query string, URL-encodes names and values, and
prepends the context path (see Link URLs). Rendered for the first page, which is sorted by title ascending:
<th aria-sort="ascending">
<a href="/catalog?q=&size=10&page=0&sort=title,desc">Title</a>
<span aria-hidden="true">▲</span>
</th>
<th aria-sort="none">
<a href="/catalog?q=&size=10&page=0&sort=author,asc">Author</a>
</th>
The empty q= is the empty search term, rendered as an empty value. The ampersands are & in the HTML source, which is the correct
way to write them in an attribute and which the browser turns back into & in the actual URL. A single sort column is deliberate: several columns need repeated sort= parameters, which the link
expression can express but which the toggle logic above does not.
A reusable pagination fragment
The pagination control is the second fragment. It takes the Page and a radius, shows the previous and next controls and a window of page numbers around the current page, and hides itself when there is only one page.
The page numbers come from #numbers.sequence(from, to), which produces the integers between its arguments:
<!-- Windowed pagination; hidden when there is only one page. -->
<nav th:fragment="pagination(page, radius)" th:if="${page.totalPages > 1}" aria-label="Pagination"
th:with="first=${page.number > radius ? page.number - radius : 0},
last=${page.number + radius < page.totalPages - 1 ? page.number + radius : page.totalPages - 1},
order=${page.sort.isSorted() ? page.sort.toList().get(0) : null},
sortParam=${order != null ? order.property + ',' + order.direction.name().toLowerCase() : null}">
<ul class="pagination">
<li th:classappend="${page.first} ? 'disabled'">
<a th:if="${page.hasPrevious()}"
th:href="@{/catalog(q=${search.q}, size=${page.size}, sort=${sortParam}, page=${page.number - 1})}"
rel="prev">Previous</a>
<span th:unless="${page.hasPrevious()}">Previous</span>
</li>
<li th:each="i : ${#numbers.sequence(first, last)}" th:classappend="${i == page.number} ? 'active'">
<a th:href="@{/catalog(q=${search.q}, size=${page.size}, sort=${sortParam}, page=${i})}"
th:text="${i + 1}" th:attr="aria-current=${i == page.number} ? 'page'">1</a>
</li>
<li th:classappend="${page.last} ? 'disabled'">
<a th:if="${page.hasNext()}"
th:href="@{/catalog(q=${search.q}, size=${page.size}, sort=${sortParam}, page=${page.number + 1})}"
rel="next">Next</a>
<span th:unless="${page.hasNext()}">Next</span>
</li>
</ul>
</nav>
-
The window is computed with conditional expressions instead of
Math.max: the radius arrives from the call (pagination(${page}, 2)) as a number literal, andT(java.lang.Math).max(0, page.number - radius)failed on the first attempt withEL1033E: Method call of 'max' is ambiguous, because SpEL could not choose between the numeric overloads.T(...)is also rejected in restricted-mode contexts such as URL bases, so conditional expressions are the safer habit. -
#numbers.sequence(from, to)counts down whenfromis greater thanto(a run printed0,1,2,for(0, 2)and0,-1,for(0, -1)). That is why the whole nav is guarded bypage.totalPages > 1: with zero pages the window would be the sequence0,-1. -
The first, previous, next and last states are plain
spanelements instead of links, so the control keeps its shape;aria-current="page"andrel="prev"/rel="next"carry the meaning for browsers and assistive tools.
The page that uses both fragments:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head><title th:text="#{catalog.title}">Catalog</title></head>
<body>
<main layout:fragment="content">
<h1 th:text="#{catalog.title}">Catalog</h1>
<form method="get" th:action="@{/catalog}">
<label for="q" th:text="#{catalog.search}">Search</label>
<input type="search" id="q" name="q" th:value="${search.q}">
<input type="hidden" name="size" th:value="${page.size}">
<input type="hidden" name="sort" th:if="${page.sort.isSorted()}"
th:value="${page.sort.toList().get(0).property + ',' + page.sort.toList().get(0).direction.name().toLowerCase()}">
<button type="submit" th:text="#{catalog.search.button}">Search</button>
</form>
<p th:if="${page.empty}" th:text="#{catalog.empty}">No books found.</p>
<th:block th:unless="${page.empty}">
<p th:utext="#{catalog.summary(${page.number * page.size + 1}, ${page.number * page.size + page.numberOfElements}, ${page.totalElements})}">Showing 1-10 of 53 books</p>
<table th:replace="~{catalog/fragments :: bookTable(${page.content})}"></table>
<nav th:replace="~{catalog/fragments :: pagination(${page}, 2)}"></nav>
</th:block>
</main>
</body>
</html>
Page 3 of the catalogue sorted by author descending (GET /catalog?page=2&sort=author,desc), trimmed to the pagination control:
<nav aria-label="Pagination">
<ul class="pagination">
<li>
<a href="/catalog?q=&size=10&sort=author,desc&page=1"
rel="prev">Previous</a>
</li>
<li>
<a href="/catalog?q=&size=10&sort=author,desc&page=0">1</a>
</li>
<li>
<a href="/catalog?q=&size=10&sort=author,desc&page=1">2</a>
</li>
<li class="active">
<a href="/catalog?q=&size=10&sort=author,desc&page=2"
aria-current="page">3</a>
</li>
<li>
<a href="/catalog?q=&size=10&sort=author,desc&page=3">4</a>
</li>
<li>
<a href="/catalog?q=&size=10&sort=author,desc&page=4">5</a>
</li>
<li>
<a href="/catalog?q=&size=10&sort=author,desc&page=3"
rel="next">Next</a>
</li>
</ul>
</nav>
The fragments are stylesheet-agnostic; the class names (pagination, active, disabled) are the ones Bootstrap uses, and a component library
(see Reusable UI Components) can wrap them with its own markup.
Building links from the current request
Passing q, size and sort into every link works while the page knows all the parameters. Another approach is to take the current request and change only the parameter that differs, which preserves
whatever else is there, including parameters added later. Spring MVC provides ServletUriComponentsBuilder for that. A small bean wraps it:
@Component("pageUrls")
public class PageUrls {
/** Relative URL of the current request with only the page parameter replaced. */
public String withPage(int page) {
return ServletUriComponentsBuilder.fromCurrentRequest()
.scheme(null).host(null).port(-1)
.replaceQueryParam("page", page)
.build(true).toUriString();
}
}
Source: Spring Framework — URI Links
-
fromCurrentRequest()produces an absolute URL built from the request’s scheme andHostheader; clearingscheme,hostandportleaves a relative URL (with the context path), so a forgedHostheader never lands in a link. -
build(true)declares the query string as already encoded.toUriString()on its own would encode it a second time, and a search fora&b(q=a%26b) came out asq=a%2526b.
The template calls the bean with ${@pageUrls.withPage(...)}. The bean call must be in a variable, not directly in th:href: a bare th:href="${@pageUrls...}" is evaluated as a URL base,
which is restricted mode, and failed with Instantiation of new objects and access to static classes or parameters is forbidden in this context.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<ul>
<li th:each="book : ${slice.content}" th:text="${book.title}">Title</li>
</ul>
<nav>
<a th:if="${slice.hasPrevious()}" th:with="url=${@pageUrls.withPage(slice.number - 1)}" th:href="${url}" rel="prev">Previous</a>
<a th:if="${slice.hasNext()}" th:with="url=${@pageUrls.withPage(slice.number + 1)}" th:href="${url}" rel="next">Next</a>
</nav>
</body>
</html>
This template shows a Slice: Previous and Next, no page numbers and no count query. Requested as GET /app/catalog/slice?q=book&page=1 (context path /app), where the
controller passes Slice<CatalogBook> from findSliceByTitleContainingIgnoreCase, the navigation is
<nav>
<a href="/catalog/slice?q=book&page=0" rel="prev">Previous</a>
<a href="/catalog/slice?q=book&page=2" rel="next">Next</a>
</nav>
Because the URL is a plain string and not an @{...} expression, the context path is already in it (/app/catalog/slice?q=a%26b&page=0 for a request with context path /app) and is not added twice. The
price of this style: every link is built from the request, so it only makes sense on the page that is being paged; the explicit @{...} style used by the fragments above keeps the links testable from the model alone.
GET search forms
A search form changes nothing on the server, so it uses GET: the term lands in the query string, the result page is bookmarkable and shareable, and the browser’s back button works. The form in list.html above also carries
the page size and the sort as hidden fields, so searching does not reset them, and it starts again from the first page (no page field).
-
th:action="@{/catalog}"on aGETform adds no CSRF hidden field, becauseRequestDataValueProcessoronly adds the token for state-changing methods; a test asserts that the page contains no_csrf. Any form that does change state must be aPOSTwith the token (see the Spring MVC Integration page of this section). -
th:value="${search.q}"escapes the user’s text: a search for"><script>x</script>is rendered as"><script>, which a test asserts. The same text must never go toth:utext. -
The search text is not echoed in a
GETlink by hand; it is passed as a parameter of a link expression, which encodes it (a search forbook 1producedq=book%201in the sort links).
<form method="get" action="/catalog">
<label for="q">Search by title</label>
<input type="search" id="q" name="q" value="">
<input type="hidden" name="size" value="10">
<input type="hidden" name="sort" value="author,desc">
<button type="submit">Search</button>
</form>
A search with no hits gives the empty state, with no table and no pagination:
<h1>Catalog</h1>
<form method="get" action="/catalog">
<label for="q">Search by title</label>
<input type="search" id="q" name="q" value="zzz">
<input type="hidden" name="size" value="10">
<input type="hidden" name="sort" value="title,asc">
<button type="submit">Search</button>
</form>
<p>No books found.</p>
A translated result summary
"Showing 11 to 20 of 53 books" is a sentence with three numbers, so it belongs in the message bundle, not in string concatenation in the template (see the Internationalization page of this section).
The sentence has markup inside it, so it is rendered with th:utext:
catalog.summary=Showing <strong>{0}</strong> to <strong>{1}</strong> of <strong>{2}</strong> books
catalog.summary=Mostrando <strong>{0}</strong> a <strong>{1}</strong> de <strong>{2}</strong> libros
<p th:utext="#{catalog.summary(${page.number * page.size + 1}, ${page.number * page.size + page.numberOfElements}, ${page.totalElements})}">Showing 1-10 of 53 books</p>
Source: Using Thymeleaf — Messages
For page index 2 the output is <p>Showing <strong>21</strong> to <strong>30</strong> of <strong>53</strong> books</p>, and with Accept-Language: es the Spanish message. th:utext is safe here because the HTML comes from the message
bundle, which the developers control, and the arguments are numbers. Never pass user text as an argument to a message rendered with th:utext, and watch the MessageFormat rules: a single apostrophe in a message with arguments must be written twice.
The scratch application also sets spring.messages.fallback-to-system-locale=false, so a request without Accept-Language gets the default bundle and not the server’s locale.
Pitfalls
| Mistake | What happens | Fix |
|---|---|---|
Passing the request’s |
|
Whitelist the property names, fall back to a default sort |
No |
|
Set |
|
Next link points to the current page |
Remember |
Parameter names renamed by properties but not in the templates |
Links carry a |
Rename in both places or keep the defaults |
Sort or search links without the other parameters |
Sorting forgets the search; paging forgets the sort |
Pass |
Sort link keeps the current page number |
The new order opens on a page that may not exist |
Use |
|
Counts down: |
Guard with |
|
|
Use a conditional expression |
Declared fragment parameter left out of the call |
|
Declare only mandatory parameters; read optional ones from context with |
|
Restricted mode rejects it |
Assign it to a variable with |
|
Query string encoded twice ( |
|
User text in a |
Script injection |
Only numbers as arguments; |
Version notes
-
The
spring.data.web.*properties are provided by thespring-boot-data-commonsmodule in Spring Boot 4.1.1; the names and defaults in the table above come from its configuration metadata. -
With Spring Data 4 the exception for an unknown sort property is
org.springframework.data.core.PropertyReferenceException, as the output above shows. -
The consulted edition of Taming Thymeleaf (Wim Deblauwe, PDF v1.1.1, 2021) builds its pagination from the same ingredients (
Page,@SortDefault, a reusable fragment,#numbers.sequence) but predates Spring Boot 3 and 4; take the coordinates, package names and test annotations from this section.