Spring BootPagination
Spring Boot

Pagination

Spring Data provides first-class pagination support through the Pageable abstraction and Page<T> return type. Controllers accept page, size, and sort parameters automatically; repositories execute COUNT + SELECT queries and return a Page containing the data slice plus total element and page counts. This entry covers setup, Pageable binding, Page responses, cursor-based pagination, and performance considerations.

Pageable Binding in Controllers

Spring MVC resolves a Pageable method parameter from the incoming request's query parameters automatically via PageableHandlerMethodArgumentResolver, which is registered by Spring Boot's auto-configuration. Use @PageableDefault to set sensible defaults when the client omits the parameters.
Java
// ── Request parameters resolved automatically ─────────────────────────
// GET /users?page=0&size=20&sort=createdAt,desc
//   page  → zero-based page index (default 0)
//   size  → page size (default 20)
//   sort  → field,direction — repeatable for multi-field sort

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @GetMapping
    public ResponseEntity<Page<UserResponse>> findAll(
            @PageableDefault(
                page = 0,
                size = 20,
                sort = "createdAt",
                direction = Sort.Direction.DESC
            ) Pageable pageable) {
        return ResponseEntity.ok(userService.findAll(pageable));
    }
}

// ── Global defaults via configuration ─────────────────────────────────
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(
            List<HandlerMethodArgumentResolver> resolvers) {
        PageableHandlerMethodArgumentResolver resolver =
            new PageableHandlerMethodArgumentResolver();
        resolver.setDefaultPageSize(20);
        resolver.setMaxPageSize(100);          // cap: clients cannot request >100
        resolver.setOneIndexedParameters(false); // keep zero-based (default)
        resolvers.add(resolver);
    }
}

Repository and Service Layer

JpaRepository extends PagingAndSortingRepository, so every Spring Data repository already supports Pageable. Pass it straight through from the controller to the repository. The resulting Page<T> contains both the data slice and the total count needed to calculate page navigation.
Java
// ── Repository ────────────────────────────────────────────────────────
public interface UserRepository extends JpaRepository<User, Long> {

    // Derived query — pagination applied automatically
    Page<User> findByStatus(UserStatus status, Pageable pageable);

    // JPQL with pagination
    @Query("SELECT u FROM User u WHERE u.department.id = :deptId")
    Page<User> findByDepartment(@Param("deptId") Long deptId, Pageable pageable);

    // Native query with pagination — countQuery is required
    @Query(value = "SELECT * FROM users WHERE role = :role",
           countQuery = "SELECT COUNT(*) FROM users WHERE role = :role",
           nativeQuery = true)
    Page<User> findByRoleNative(@Param("role") String role, Pageable pageable);
}

// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepo;

    public Page<UserResponse> findAll(Pageable pageable) {
        return userRepo.findAll(pageable)
                       .map(UserResponse::from);   // Page.map preserves metadata
    }

    public Page<UserResponse> findByStatus(UserStatus status, Pageable pageable) {
        return userRepo.findByStatus(status, pageable)
                       .map(UserResponse::from);
    }
}

Shaping the Page Response

Spring Data's Page<T> serializes to a verbose JSON envelope containing nested pageable, sort, and other internal objects. Flatten it into a clean, stable API contract using a custom wrapper record. This decouples your API from Spring Data's internal structure and makes the response easier to consume.
Java
// ── Custom page envelope ──────────────────────────────────────────────
public record PageResponse<T>(
    List<T> content,
    int page,
    int size,
    long totalElements,
    int totalPages,
    boolean first,
    boolean last
) {
    public static <T> PageResponse<T> from(Page<T> page) {
        return new PageResponse<>(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements(),
            page.getTotalPages(),
            page.isFirst(),
            page.isLast()
        );
    }
}

// ── Controller using the wrapper ──────────────────────────────────────
@GetMapping
public ResponseEntity<PageResponse<UserResponse>> findAll(
        @PageableDefault(size = 20, sort = "createdAt",
                         direction = Sort.Direction.DESC) Pageable pageable) {
    Page<UserResponse> page = userService.findAll(pageable);
    return ResponseEntity.ok(PageResponse.from(page));
}

// ── JSON output ───────────────────────────────────────────────────────
// {
//   "content": [...],
//   "page": 0,
//   "size": 20,
//   "totalElements": 243,
//   "totalPages": 13,
//   "first": true,
//   "last": false
// }

Cursor-Based Pagination

Offset pagination (page/size) degrades on large tables because the database must scan and discard all preceding rows. Cursor-based pagination uses the last-seen record's ID or timestamp as a seek point, producing constant-time queries regardless of depth. It trades random access for stable, efficient sequential navigation.
Java
// ── Repository — keyset / seek pagination ────────────────────────────
public interface UserRepository extends JpaRepository<User, Long> {

    // Seek forward from the last seen id
    @Query("""
        SELECT u FROM User u
        WHERE (:cursor IS NULL OR u.id > :cursor)
        ORDER BY u.id ASC
        LIMIT :size
        """)
    List<User> findNextPage(
        @Param("cursor") Long cursor,
        @Param("size")   int size);
}

// ── Cursor response envelope ───────────────────────────────────────────
public record CursorPage<T>(
    List<T> content,
    String  nextCursor,   // opaque token — clients treat as black box
    boolean hasMore
) {}

// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepo;

    public CursorPage<UserResponse> findPage(String encodedCursor, int size) {
        Long cursor = decodeCursor(encodedCursor);
        int fetch = size + 1;   // fetch one extra to detect hasMore

        List<User> rows = userRepo.findNextPage(cursor, fetch);
        boolean hasMore = rows.size() == fetch;
        List<UserResponse> content = rows.stream()
            .limit(size)
            .map(UserResponse::from)
            .toList();

        String nextCursor = hasMore
            ? encodeCursor(rows.get(size - 1).getId())
            : null;

        return new CursorPage<>(content, nextCursor, hasMore);
    }

    private Long decodeCursor(String token) {
        if (token == null) return null;
        return Long.parseLong(
            new String(Base64.getDecoder().decode(token)));
    }

    private String encodeCursor(Long id) {
        return Base64.getEncoder()
            .encodeToString(String.valueOf(id).getBytes());
    }
}

// ── Controller ────────────────────────────────────────────────────────
@GetMapping("/cursor")
public ResponseEntity<CursorPage<UserResponse>> findByCursor(
        @RequestParam(required = false) String cursor,
        @RequestParam(defaultValue = "20") @Max(100) int size) {
    return ResponseEntity.ok(userService.findPage(cursor, size));
}

Pagination Performance

Deep offset pagination is slow — OFFSET 10000 forces the database to read and discard 10,000 rows before returning your page. For large datasets, apply one of these strategies: cap the maximum page depth, use cursor pagination for sequential access, or use a deferred join to minimise the rows evaluated by the COUNT query.
Java
// ── 1. Cap maximum page depth ─────────────────────────────────────────
@GetMapping
public ResponseEntity<PageResponse<UserResponse>> findAll(
        @PageableDefault(size = 20) Pageable pageable) {

    int maxPage = 500;  // refuse requests beyond page 500
    if (pageable.getPageNumber() > maxPage) {
        throw new BadRequestException(
            "Page number must not exceed " + maxPage);
    }
    return ResponseEntity.ok(
        PageResponse.from(userService.findAll(pageable)));
}

// ── 2. Deferred join — avoids loading full rows for the COUNT ─────────
@Query(value = """
    SELECT u.* FROM users u
    INNER JOIN (
        SELECT id FROM users
        ORDER BY created_at DESC
        LIMIT :#{#pageable.pageSize} OFFSET :#{#pageable.offset}
    ) ids ON u.id = ids.id
    """,
    countQuery = "SELECT COUNT(*) FROM users",
    nativeQuery = true)
Page<User> findAllDeferred(Pageable pageable);

// ── 3. Slice<T> — skip the COUNT query entirely ───────────────────────
// Use when total count is not needed (infinite scroll, "load more" UI)
public interface UserRepository extends JpaRepository<User, Long> {
    Slice<User> findByStatus(UserStatus status, Pageable pageable);
}

// Slice has: content, hasNext(), hasPrevious() — but NO totalElements
@GetMapping("/scroll")
public ResponseEntity<SliceResponse<UserResponse>> scroll(
        @PageableDefault(size = 20) Pageable pageable) {
    Slice<User> slice = userRepo.findByStatus(UserStatus.ACTIVE, pageable);
    return ResponseEntity.ok(SliceResponse.from(
        slice.map(UserResponse::from)));
}