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.

flowchart LR req["GET /catalog with q, page, size and sort"] --> res["Pageable resolver
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:

Rendered output of the latest-books list
<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:

Rendered output when the list is empty
<p>Nothing to show yet.</p>

Three ways to test for "nothing" appear in templates, and they are not interchangeable:

Test Works on

${#lists.isEmpty(books)}

A List, and null counts as empty; #sets and #arrays are the counterparts for sets and arrays. See Expression Utility Objects

${page.empty}

A Spring Data Page or Slice (isEmpty(), true when the current page has no content, not when the table has no rows overall)

${#strings.isEmpty(q)}

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

${page.content}

The rows of the current page, a List

${page.number}

Zero-based index of the current page (always zero-based, even with one-indexed request parameters)

${page.size}

The requested page size

${page.numberOfElements}

How many rows this page really holds (fewer than size on the last page)

${page.totalElements}, ${page.totalPages}

Totals; only on Page, not on Slice

${page.first}, ${page.last}

True on the first or last page (isFirst(), isLast())

${page.hasNext()}, ${page.hasPrevious()}

Method calls, because there is no getHasNext(); available on Slice too

${page.empty}

isEmpty()

${page.sort}

The Sort that produced the page; page.sort.getOrderFor('title') returns the Sort.Order for a property or null

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

spring.data.web.pageable.default-page-size

20

Page size when the request has none and no @PageableDefault applies

spring.data.web.pageable.max-page-size

2000

Upper bound for the requested size; larger values are cut to it

spring.data.web.pageable.one-indexed-parameters

false

Treat the page parameter as one-based (page=1 is the first page)

spring.data.web.pageable.page-parameter

page

Name of the page parameter

spring.data.web.pageable.size-parameter

size

Name of the page-size parameter

spring.data.web.pageable.prefix

empty

Prefix added to the page and size parameter names

spring.data.web.pageable.qualifier-delimiter

_

Delimiter between a qualifier and the parameter name

spring.data.web.pageable.serialization-mode

direct

JSON serialisation mode of Spring Data pages; irrelevant to HTML views

spring.data.web.sort.sort-parameter

sort

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&amp;n=3&amp;order=title,desc&amp;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());
            }
        };
    }
}

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.

Rendered output of the second call (body rows trimmed)
<table>
  <thead>
    <tr>
      <th aria-sort="ascending">
  <a href="/catalog?q=&amp;size=2&amp;page=0&amp;sort=title,desc">Title</a>
  <span aria-hidden="true">▲</span>
</th>
      <th aria-sort="none">
  <a href="/catalog?q=&amp;size=2&amp;page=0&amp;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} ? '&#9650;' : '&#9660;'" aria-hidden="true"></span>
</th>

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:

Rendered header row on the first page
<th aria-sort="ascending">
  <a href="/catalog?q=&amp;size=10&amp;page=0&amp;sort=title,desc">Title</a>
  <span aria-hidden="true">▲</span>
</th>
<th aria-sort="none">
  <a href="/catalog?q=&amp;size=10&amp;page=0&amp;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, and T(java.lang.Math).max(0, page.number - radius) failed on the first attempt with EL1033E: 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 when from is greater than to (a run printed 0,1,2, for (0, 2) and 0,-1, for (0, -1)). That is why the whole nav is guarded by page.totalPages > 1: with zero pages the window would be the sequence 0,-1.

  • The first, previous, next and last states are plain span elements instead of links, so the control keeps its shape; aria-current="page" and rel="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:

Rendered pagination control on page 3 of 6
<nav aria-label="Pagination">
  <ul class="pagination">
    <li>
      <a href="/catalog?q=&amp;size=10&amp;sort=author,desc&amp;page=1"
         rel="prev">Previous</a>
    </li>
    <li>
      <a href="/catalog?q=&amp;size=10&amp;sort=author,desc&amp;page=0">1</a>
    </li>
    <li>
      <a href="/catalog?q=&amp;size=10&amp;sort=author,desc&amp;page=1">2</a>
    </li>
    <li class="active">
      <a href="/catalog?q=&amp;size=10&amp;sort=author,desc&amp;page=2"
         aria-current="page">3</a>
    </li>
    <li>
      <a href="/catalog?q=&amp;size=10&amp;sort=author,desc&amp;page=3">4</a>
    </li>
    <li>
      <a href="/catalog?q=&amp;size=10&amp;sort=author,desc&amp;page=4">5</a>
    </li>
    <li>
      <a href="/catalog?q=&amp;size=10&amp;sort=author,desc&amp;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.

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();
    }
}
  • fromCurrentRequest() produces an absolute URL built from the request’s scheme and Host header; clearing scheme, host and port leaves a relative URL (with the context path), so a forged Host header 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 for a&b (q=a%26b) came out as q=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

Rendered Previous and Next links of a Slice
<nav>
  <a href="/catalog/slice?q=book&amp;page=0" rel="prev">Previous</a>
  <a href="/catalog/slice?q=book&amp;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 a GET form adds no CSRF hidden field, because RequestDataValueProcessor only adds the token for state-changing methods; a test asserts that the page contains no _csrf. Any form that does change state must be a POST with 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 to th:utext.

  • The search text is not echoed in a GET link by hand; it is passed as a parameter of a link expression, which encodes it (a search for book 1 produced q=book%201 in the sort links).

Rendered search form when the sort is by author, descending
<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:

Rendered output of GET /catalog?q=zzz (inside main)
<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>

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

Table 1. Common mistakes with paging and sorting
Mistake What happens Fix

Passing the request’s Sort to the repository unchecked

PropertyReferenceException for unknown names; injection with Sort.unsafe or string-built queries

Whitelist the property names, fall back to a default sort

No max-page-size cap

?size=1000000 loads the whole table

Set spring.data.web.pageable.max-page-size (here 50)

one-indexed-parameters=true and links built from page.number

Next link points to the current page

Remember page.number stays zero-based; add 2 for the next link

Parameter names renamed by properties but not in the templates

Links carry a page= that the resolver ignores

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 q, size and sort in every link, or build links from the current request

Sort link keeps the current page number

The new order opens on a page that may not exist

Use page=0 in sort links

#numbers.sequence(0, totalPages - 1) with no pages

Counts down: 0,-1

Guard with page.totalPages > 1

T(java.lang.Math).max(...) with a fragment parameter

EL1033E ambiguous method

Use a conditional expression

Declared fragment parameter left out of the call

Cannot resolve fragment error

Declare only mandatory parameters; read optional ones from context with ?:

${@bean.method()} directly in th:href

Restricted mode rejects it

Assign it to a variable with th:with, then th:href="${var}"

toUriString() on a URL taken from the request

Query string encoded twice (%2526)

build(true).toUriString()

User text in a th:utext summary

Script injection

Only numbers as arguments; th:text for user data

Version notes

  • The spring.data.web.* properties are provided by the spring-boot-data-commons module 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.