Pagination and Sorting
Accept page, size and sort from the request, return a stable JSON page shape, and pick between Page, Slice and List.
Page<Book> findByAuthorId(Long authorId, Pageable pageable);
books.findAll(PageRequest.of(0, 20, Sort.by("title").and(Sort.by("id"))));
@GetMapping PagedModel<BookDto> list(@PageableDefault(size = 20, sort = "title") Pageable p)Any list endpoint over a table that grows needs paging. Spring Data's Pageable carries a page number, a page size and a Sort. Add it as the last parameter of a repository method and Spring Data adds limit, offset and order by to the query.
From the request
Spring Boot registers a resolver, so a controller method can take a Pageable parameter directly. It reads ?page=0&size=20&sort=title,asc. Pages are zero-based. The default size is 20 and the maximum is 2000; a client asking for more is quietly capped. Lower the cap with spring.data.web.pageable.max-page-size and set per-endpoint defaults with @PageableDefault.
Page, Slice or List
Page<T>runs a secondcountquery so it can report total elements and total pages. On a large filtered table that count can cost more than the page itself.Slice<T>fetches one row more than requested to know whether there is a next page, and skips the count. Use it for infinite scroll and "load more" buttons.List<T>with aPageablejust applies limit and offset.
The JSON shape
Returning a Page from a controller serialises Spring Data's internal PageImpl, whose fields are not a stable contract, and Spring Data logs a warning saying so. Either wrap it in PagedModel, which produces content plus a page object with size, number, total elements and total pages, or set spring.data.web.pageable.serialization-mode=via-dto so every Page is converted for you. Map entities to DTOs with page.map(...) before returning.
Sorting safely
The sort parameter names entity properties. An unknown name throws PropertyReferenceException, which becomes a 500 unless you handle it, and clients can sort on any property, including unindexed ones. For a public API, accept a small set of sort keys and build the Sort yourself. Always add a unique tiebreaker such as the id: sorting only by a non-unique column lets rows move between pages.
Deep pages
Offset paging reads and discards every skipped row, so page 5,000 is slow and rows inserted meanwhile shift what the client sees. For feeds and exports, keyset (scroll) pagination continues from the last row seen instead.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.web.PageableDefault;
import org.springframework.data.web.PagedModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
class Product {
private Long id;
private String name;
private String category;
private BigDecimal price;
protected Product() { }
Long getId() { return id; }
String getName() { return name; }
BigDecimal getPrice() { return price; }
}
interface ProductRepository extends JpaRepository<Product, Long> {
Page<Product> findByCategory(String category, Pageable pageable);
}
record ProductDto(Long id, String name, BigDecimal price) { }
("/api/products")
public class ProductController {
private final ProductRepository products;
public ProductController(ProductRepository products) {
this.products = products;
}
public PagedModel<ProductDto> list( String category,
(size = 20, sort = {"name", "id"}) Pageable pageable) {
Page<ProductDto> page = products.findByCategory(category, pageable)
.map(p -> new ProductDto(p.getId(), p.getName(), p.getPrice()));
return new PagedModel<>(page);
}
}When to use it
- Serve a product catalogue page by page with a total count for the pagination control.
- Power an infinite-scroll comment list without paying for a count query each time.
- Let clients sort orders by date or total while rejecting arbitrary property names.
More examples
Paging configuration
Capping the size at 100 stops a client from pulling the whole table with size=100000. With via-dto, a controller that returns Page directly still produces the stable content plus page metadata shape, which helps when you cannot change every controller at once.
spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.max-page-size=100
spring.data.web.pageable.serialization-mode=via-dtoA whitelist of sort keys
The API exposes sort keys, not entity property names, so renaming a field does not break clients and nobody can sort on an unindexed column. An unknown key is a clear 400 instead of a 500 from PropertyReferenceException. The id tiebreaker keeps the order stable between pages.
import java.util.Map;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@Component
public class ProductPaging {
private static final Map<String, Sort> SORTS = Map.of(
"name", Sort.by("name"),
"price", Sort.by("price"),
"newest", Sort.by(Sort.Direction.DESC, "createdAt"));
public Pageable of(int page, int size, String sortKey) {
Sort sort = SORTS.get(sortKey);
if (sort == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sort must be one of " + SORTS.keySet());
}
int safeSize = Math.clamp(size, 1, 100);
return PageRequest.of(Math.max(page, 0), safeSize, sort.and(Sort.by("id")));
}
}A Slice for load more
A comment thread under a popular post does not need a total count, and skipping it removes a query on every request. The Slice asks for 26 rows, returns 25 and uses the extra one only to answer hasNext().
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.time.Instant;
import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.Sort;
import org.springframework.data.repository.Repository;
import org.springframework.stereotype.Service;
@Entity
class Comment {
@Id
private Long id;
private Long postId;
private String body;
private Instant createdAt;
protected Comment() { }
String getBody() { return body; }
}
interface CommentRepository extends Repository<Comment, Long> {
Slice<Comment> findByPostId(Long postId, Pageable pageable);
}
record CommentPage(List<String> bodies, boolean hasMore) { }
@Service
public class CommentFeed {
private final CommentRepository comments;
public CommentFeed(CommentRepository comments) { this.comments = comments; }
public CommentPage page(Long postId, int page) {
var request = PageRequest.of(page, 25, Sort.by(Sort.Direction.DESC, "createdAt", "id"));
Slice<Comment> slice = comments.findByPostId(postId, request);
return new CommentPage(slice.map(Comment::getBody).getContent(), slice.hasNext());
}
}
Discussion