If you build a REST API that returns a list of resources without pagination, you are building a ticking time bomb. I learned this the hard way early in my Java Backend career. I deployed a simple GET /api/orders endpoint to production, backed by a standard findAll() method. It worked flawlessly in dev with 50 records. Six months later, a client hit that endpoint, attempted to load 4 million rows into memory, spiked the Garbage Collection, threw an OutOfMemoryError, and crashed the entire JVM.
You cannot escape data growth in Java Enterprise applications. Whether you are building massive Java Microservices or a simple internal dashboard, implementing robust spring boot rest api pagination is mandatory. It prevents server crashes, reduces database load, minimizes network bandwidth, and drastically improves client-side rendering times.
Today, I am going to walk you through exactly how I implement pagination and sorting in modern Spring Boot applications. We will look at the built-in Spring Data JPA tools, how to cleanly map entities to DTOs using Java 21 records, how to fix Spring’s bloated default JSON responses, and how to avoid the performance traps of deep offset pagination.
The Mechanics of Spring Boot REST API Pagination
Under the hood, pagination is just a translation between your HTTP request and your database SQL. When a client requests a specific “page” of data, your Java Spring application needs to tell your database (via Hibernate and JDBC) to append a LIMIT and OFFSET clause to the SQL query.
Spring Data JPA abstracts this away using two core interfaces:
Pageable: The input. This interface represents the pagination request details (page number, page size, and sorting directives). The most common implementation isPageRequest.Page<T>: The output. This interface represents the chunk of data returned from the database, along with crucial metadata (total elements, total pages, current page number, etc.).
Let’s dive straight into the code. We will build an API for a product catalog.
Step 1: Setting Up the Entity and Repository
First, we need a standard JPA entity. I am using standard Jakarta EE annotations here. If you are focused on Java Performance and Clean Code Java, ensure you aren’t eagerly fetching massive relationships, as pagination can easily trigger the dreaded N+1 query problem.
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private BigDecimal price;
@Column(name = "category_id")
private Long categoryId;
// Constructors, Getters, and Setters omitted for brevity
}
Next, we create the repository. To enable spring boot rest api pagination, your repository needs to extend PagingAndSortingRepository or JpaRepository (which extends the former). I default to JpaRepository in almost all Java Database projects.
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
// Spring Data automatically implements this!
Page<Product> findByCategoryId(Long categoryId, Pageable pageable);
}
Notice that we simply append a Pageable object as the final argument to our query method. Spring Data intercepts this, injects the LIMIT and OFFSET based on the dialect (e.g., PostgreSQL, MySQL), and executes a secondary COUNT query to determine the total number of records.
Step 2: Designing the Service Layer
A common mistake I see in code reviews is passing HTTP request parameters directly into the repository from the controller. Your service layer should dictate the business logic. Furthermore, you should never return raw JPA entities to your controller. Doing so leaks your database schema and often leads to infinite recursion issues during Jackson JSON serialization.
Let’s use Java 17/Java 21 record syntax to create a clean DTO.
public record ProductDto(
Long id,
String name,
BigDecimal price
) {}
Now, let’s build the service layer. We will map the Page<Product> to a Page<ProductDto>. The Page interface has a beautiful built-in map() function specifically for this functional Java transformation.
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Page<ProductDto> getProductsByCategory(Long categoryId, Pageable pageable) {
Page<Product> productPage = productRepository.findByCategoryId(categoryId, pageable);
// Convert Entity to DTO seamlessly
return productPage.map(product -> new ProductDto(
product.getId(),
product.getName(),
product.getPrice()
));
}
}
Step 3: Creating the Controller and Handling Sort Parameters
Now we expose the endpoint. Spring Boot Web makes this incredibly elegant by providing the @PageableDefault annotation. This prevents your API from crashing or fetching the entire database if a client forgets to pass pagination parameters.
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/category/{categoryId}")
public ResponseEntity<Page<ProductDto>> getProducts(
@PathVariable Long categoryId,
@PageableDefault(
page = 0,
size = 20,
sort = "id",
direction = Sort.Direction.DESC
) Pageable pageable) {
Page<ProductDto> products = productService.getProductsByCategory(categoryId, pageable);
return ResponseEntity.ok(products);
}
}
With this setup, clients can query your API using standard URL parameters. To fetch the second page, with 50 items per page, sorted by price ascending, the client simply calls:
GET /api/v1/products/category/1?page=1&size=50&sort=price,asc

Crucial Note on 0-Based Indexing: Spring Data JPA pagination is 0-indexed. page=0 is the first page. Front-end developers almost universally build UIs that are 1-indexed. You must communicate this clearly in your API documentation, or you will spend hours debugging “missing data” issues when the UI asks for page 1 and misses the first 20 records.
Fixing the Bloated Default JSON Response
If you hit the endpoint we just built, you will notice something frustrating. The default JSON serialization of Spring’s PageImpl object is an absolute mess. It leaks Spring framework internals to your API consumers.
Here is what Spring returns by default:
{
"content": [
{ "id": 101, "name": "Mechanical Keyboard", "price": 120.00 },
{ "id": 102, "name": "Wireless Mouse", "price": 45.50 }
],
"pageable": {
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"offset": 0,
"pageNumber": 0,
"pageSize": 20,
"paged": true,
"unpaged": false
},
"last": false,
"totalPages": 50,
"totalElements": 1000,
"size": 20,
"number": 0,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"first": true,
"numberOfElements": 2,
"empty": false
}
Look at all that noise. pageable, sort.empty, unpaged. Your frontend Vue, React, or Android Java clients do not care about Spring Data internals. They just want the data and basic metadata to render their UI controls.
In enterprise Java Architecture, we strictly define our API contracts. We need to intercept this Page object and wrap it in a clean, custom response payload. Here is the custom wrapper I use in almost every Java Backend project.
import org.springframework.data.domain.Page;
import java.util.List;
public record PagedResponse<T>(
List<T> data,
int pageNumber,
int pageSize,
long totalElements,
int totalPages,
boolean isLast
) {
public static <T> PagedResponse<T> of(Page<T> page) {
return new PagedResponse<>(
page.getContent(),
page.getNumber() + 1, // Expose 1-based page numbers to clients!
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isLast()
);
}
}
Now, we update our controller to return PagedResponse<ProductDto> instead of the raw Spring Page:
@GetMapping("/category/{categoryId}")
public ResponseEntity<PagedResponse<ProductDto>> getProducts(
@PathVariable Long categoryId,
@PageableDefault(page = 0, size = 20) Pageable pageable) {
Page<ProductDto> products = productService.getProductsByCategory(categoryId, pageable);
return ResponseEntity.ok(PagedResponse.of(products));
}
The resulting JSON is clean, professional, and vastly reduces the payload size:
{
"data": [
{ "id": 101, "name": "Mechanical Keyboard", "price": 120.00 },
{ "id": 102, "name": "Wireless Mouse", "price": 45.50 }
],
"pageNumber": 1,
"pageSize": 20,
"totalElements": 1000,
"totalPages": 50,
"isLast": false
}
Defensive Programming: Limiting Page Sizes
Let’s talk about Java Security and API stability. What happens if a malicious user, or a poorly written script, calls your API with ?size=1000000? Spring Boot will happily pass that to Hibernate, which will attempt to load a million rows into your JVM, recreating the exact OutOfMemoryError we are trying to avoid.
By default, Spring Boot caps the page size at 2000. But for most applications, 2000 is still dangerously high. You should lower this globally in your application.yml or application.properties file to protect your Java Microservices.
spring:
data:
web:
pageable:
max-page-size: 100
default-page-size: 20
If a client requests size=500, Spring Boot will now silently clamp it down to 100. This simple Java Optimization technique saves you from unpredictable memory spikes during peak traffic.
Advanced Sorting: Handling Multiple Columns and nested Properties
Real-world spring boot rest api pagination usually involves complex sorting rules. Clients often need to sort by multiple columns, such as sorting by price descending, and then by name ascending to break ties.
Spring Boot handles this natively through the URL. The client simply passes multiple sort parameters:
?sort=price,desc&sort=name,asc
However, you must be extremely careful here. Spring Data will attempt to map the string passed in the sort parameter directly to the entity properties. If a client passes a property that doesn’t exist (e.g., ?sort=imaginaryColumn,asc), Spring will throw a PropertyReferenceException and return a 500 Internal Server Error.
To implement Clean Code Java practices, you should catch these mapping errors globally using an @ControllerAdvice class and translate them into a 400 Bad Request. Don’t let client typos trigger critical error alerts in your DevOps monitoring tools.
import org.springframework.data.mapping.PropertyReferenceException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(PropertyReferenceException.class)
public ResponseEntity<String> handlePropertyReferenceException(PropertyReferenceException ex) {
String errorMsg = String.format("Invalid sort parameter: '%s' is not a valid property.", ex.getPropertyName());
return new ResponseEntity<>(errorMsg, HttpStatus.BAD_REQUEST);
}
}
Performance Tuning: The Hidden Cost of Offset Pagination
As a senior developer, I have to warn you about the dark side of traditional pagination. The implementation we just built uses Offset Pagination. When you request page 10,000, Spring Data translates this to something like:
SELECT * FROM products WHERE category_id = 1 ORDER BY id DESC LIMIT 20 OFFSET 200000;
To execute this query, relational databases (like PostgreSQL or MySQL) cannot simply jump to row 200,000. They must scan and discard the first 200,000 rows before returning the 20 rows you actually want. For deep pages, this causes massive CPU and Disk I/O spikes.
Furthermore, standard Spring Data Page requests always fire a secondary COUNT(*) query to calculate the totalPages. Counting large tables is notoriously slow in MVCC databases like PostgreSQL.
If you are building an infinite scrolling feed (like Twitter or Instagram) or dealing with massive datasets, you should abandon Offset Pagination in favor of Keyset Pagination (also known as Cursor Pagination).
Instead of relying on an offset, Keyset Pagination uses the last seen value as a reference point. For example:
SELECT * FROM products WHERE category_id = 1 AND id < ? ORDER BY id DESC LIMIT 20;
This query can leverage database indexes perfectly, resulting in consistent O(1) fetch times regardless of how deep the user scrolls. While Spring Data JPA doesn’t have a magical 1-click annotation for Keyset Pagination like it does for Offset Pagination, you can implement it using custom JPQL queries, or by utilizing libraries like Blaze-Persistence which integrate seamlessly with Spring Boot.
For standard admin dashboards and standard web applications, Offset Pagination is perfectly fine. But for high-traffic Java REST API endpoints, keep Keyset pagination in your architectural toolbelt.
Testing Paginated Endpoints
Finally, we must talk about Java Testing. How do you test a service that returns a Page? Using JUnit and Mockito, it is straightforward. You can use PageImpl to mock the database response.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
private ProductRepository productRepository;
@InjectMocks
private ProductService productService;
@Test
void shouldReturnPaginatedDtos() {
// Arrange
Product product = new Product();
product.setId(1L);
product.setName("Test Product");
product.setPrice(new BigDecimal("10.00"));
PageRequest pageRequest = PageRequest.of(0, 10);
Page<Product> mockPage = new PageImpl<>(List.of(product), pageRequest, 1);
when(productRepository.findByCategoryId(1L, pageRequest)).thenReturn(mockPage);
// Act
Page<ProductDto> result = productService.getProductsByCategory(1L, pageRequest);
// Assert
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).name()).isEqualTo("Test Product");
assertThat(result.getTotalElements()).isEqualTo(1);
}
}
This ensures your DTO mapping and service logic remain intact without needing to spin up a full integration test with Testcontainers (though you should absolutely have those too).
Frequently Asked Questions
How do I change the default page size in Spring Boot?
You can change the default page size globally by adding spring.data.web.pageable.default-page-size=20 to your application.properties file. Alternatively, you can override it on a per-endpoint basis using the @PageableDefault(size = 50) annotation in your controller.
Why is my Spring Boot pagination returning 0-based pages?
Spring Data JPA natively implements pagination using 0-based indexing, meaning the first page is page 0. To make your API more front-end friendly, you should intercept the resulting Page object and map it to a custom response payload where you manually add +1 to the page number before sending it to the client.
Can I use pagination with native queries in Spring Data JPA?
Yes, you can pass a Pageable object to a method annotated with @Query(nativeQuery = true). However, you must also provide a separate count query using the countQuery attribute within the annotation, so Spring knows how to calculate the total number of pages.
How do I handle sorting on nested entity properties?
Spring Data supports traversing nested properties using dot notation in the URL. If your Product entity has a nested Manufacturer entity, the client can sort by the manufacturer’s name by passing ?sort=manufacturer.name,asc in the API request.
Conclusion
Mastering spring boot rest api pagination is a non-negotiable skill for any Java backend developer. By leveraging Spring Data JPA’s Pageable and Page interfaces, you can easily protect your database from massive queries. Remember the golden rules we covered today: never return raw entities, always map to clean DTOs (preferably Java records), strip away the bloated default JSON by creating a custom PagedResponse wrapper, and strictly limit your maximum page sizes to prevent memory exhaustion. If you apply these practices, your APIs will be resilient, scalable, and a joy for frontend teams to consume.
