Skip to content

This tutorial shows how to connect Handsontable’s dataProvider plugin to a Spring Boot 3 backend. You will build a product catalog grid that loads data from a REST API with server-side pagination, sorting, and filtering, and that persists row create, update, and delete operations to a JPA-managed H2 database.

View full example on GitHub

Difficulty: Intermediate Time: ~45 minutes Stack: Spring Boot 3.3, Spring Data JPA, PostgreSQL 16, Flyway, Handsontable dataProvider

What You’ll Build

A product catalog data grid that:

  • Fetches paginated rows from a Spring Boot REST API on every page change
  • Sorts and filters rows on the server — the browser never loads the full dataset
  • Creates, updates, and deletes rows via dedicated endpoints
  • Converts Handsontable’s 1-based page index to Spring Data’s 0-based PageRequest
  • Maps Spring Data’s Page response to the { rows, totalRows } shape Handsontable expects
  • Seeds a PostgreSQL database with 55 product rows on startup

Before you begin

  • Docker and Docker Compose installed
  • Node.js 18 or later and npm 9 or later installed
  • Basic familiarity with Spring Boot and JPA
  • A Handsontable project with the dataProvider plugin available
  1. Create the Spring Boot project

    Use Spring Initializr to generate a new project with the required dependencies:

    Terminal window
    curl https://start.spring.io/starter.zip \
    -d dependencies=web,data-jpa,flyway,postgresql \
    -d type=maven-project \
    -d language=java \
    -d bootVersion=3.3.5 \
    -d javaVersion=21 \
    -d groupId=com.example \
    -d artifactId=products \
    -d name=products \
    -o products.zip && unzip products.zip -d products

    Or add the following to an existing pom.xml:

    <dependencies>
    <!-- REST endpoints -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- JPA + Hibernate ORM -->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <!-- PostgreSQL JDBC driver -->
    <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
    </dependency>
    <!-- Flyway -- manages schema migrations -->
    <dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
    </dependency>
    <dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
    </dependency>
    </dependencies>

    What’s happening:

    • spring-boot-starter-web provides the embedded Tomcat server and @RestController support.
    • spring-boot-starter-data-jpa brings in Hibernate and the Spring Data repository abstraction.
    • postgresql is scoped to runtime — it provides the JDBC driver but is not needed at compile time.
    • flyway-core and flyway-database-postgresql manage schema creation via versioned SQL migration files instead of Hibernate’s ddl-auto.
  2. Configure the database

    Create or update src/main/resources/application.properties:

    Properties
    # PostgreSQL datasource -- override DATABASE_URL/DB_USERNAME/DB_PASSWORD via environment variables
    # when running inside Docker Compose (see docker-compose.yml).
    spring.datasource.url=${DATABASE_URL:jdbc:postgresql://localhost:5432/products}
    spring.datasource.username=${DB_USERNAME:postgres}
    spring.datasource.password=${DB_PASSWORD:postgres}
    spring.datasource.driver-class-name=org.postgresql.Driver
    # Let Hibernate validate the schema -- Flyway owns DDL creation.
    spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
    spring.jpa.hibernate.ddl-auto=validate
    # Flyway runs migrations from src/main/resources/db/migration before the app starts.
    spring.flyway.enabled=true
    spring.flyway.locations=classpath:db/migration

    What’s happening:

    • The datasource URL, username, and password are read from environment variables (DATABASE_URL, DB_USERNAME, DB_PASSWORD) with sensible local defaults. In the Docker Compose setup these are injected automatically.
    • ddl-auto=validate tells Hibernate to verify that the schema matches the entity mapping on startup, but to never modify the database. Flyway owns all DDL changes.
    • flyway.enabled=true tells Spring Boot to run pending migrations from src/main/resources/db/migration before the application context finishes starting. The migration V1__create_products_table.sql creates the products table on the first run.
  3. Create the Product entity

    Java
    package com.example.products;
    import jakarta.persistence.*;
    import java.math.BigDecimal;
    /**
    * JPA entity for a product in the catalog.
    *
    * Spring Data JPA maps each field to a column in the H2 `products` table.
    * The `id` field is auto-generated and acts as the row identifier that
    * Handsontable references via `dataProvider.rowId: 'id'`.
    */
    @Entity
    @Table(name = "products")
    public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false)
    private String name;
    @Column(nullable = false, unique = true)
    private String sku;
    private String category;
    /** Stored with two decimal places -- matches the numeric column in Handsontable. */
    @Column(precision = 10, scale = 2)
    private BigDecimal price;
    private Integer stock;
    // --- Getters and setters ---
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getSku() { return sku; }
    public void setSku(String sku) { this.sku = sku; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public Integer getStock() { return stock; }
    public void setStock(Integer stock) { this.stock = stock; }
    }

    What’s happening:

    • @Entity and @Table(name = "products") tell JPA to map this class to the products table.
    • @Id and @GeneratedValue(strategy = GenerationType.IDENTITY) configure auto-increment. The generated id value is what Handsontable references via dataProvider.rowId: 'id'.
    • @Column(nullable = false) enforces database-level constraints on name and sku.
    • @Column(precision = 10, scale = 2) stores price with two decimal places, matching the numeric cell type in the frontend column definition.

    Why keep the entity minimal? Each field maps directly to a column the Handsontable grid displays. Adding only what the grid needs keeps the API response small and the mapping code concise.

  4. Add the repository interface

    Java
    package com.example.products;
    import org.springframework.data.jpa.repository.JpaRepository;
    import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
    /**
    * Spring Data repository for Product entities.
    *
    * Extending JpaSpecificationExecutor enables the `findAll(Specification, Pageable)`
    * method used in ProductService to apply server-side filters.
    */
    public interface ProductRepository
    extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
    }

    What’s happening:

    • JpaRepository<Product, Long> provides save, findById, deleteAllById, and count methods — everything needed for CRUD without writing any SQL.
    • JpaSpecificationExecutor<Product> adds the findAll(Specification, Pageable) overload. This is the key method used in ProductService to apply server-side filters as JPA predicates.
  5. Seed the database

    Java
    package com.example.products;
    import org.springframework.boot.CommandLineRunner;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import java.math.BigDecimal;
    import java.util.List;
    /**
    * Seeds the H2 database with 55 product rows on application startup.
    *
    * The guard `if (repository.count() == 0)` prevents duplicate inserts if
    * the bean is somehow called more than once during testing.
    */
    @Configuration
    public class DataInitializer {
    @Bean
    CommandLineRunner seedDatabase(ProductRepository repository) {
    return args -> {
    if (repository.count() == 0) {
    repository.saveAll(List.of(
    product("Laptop Pro 15", "SKU-0001", "Electronics", new BigDecimal("1299.99"), 45),
    product("Wireless Keyboard", "SKU-0002", "Accessories", new BigDecimal("89.99"), 120),
    product("Ergonomic Mouse", "SKU-0003", "Accessories", new BigDecimal("59.99"), 85),
    product("4K Monitor 27\"", "SKU-0004", "Electronics", new BigDecimal("549.00"), 30),
    product("USB-C Hub 7-in-1", "SKU-0005", "Accessories", new BigDecimal("49.99"), 200),
    product("Mechanical Keyboard", "SKU-0006", "Accessories", new BigDecimal("149.00"), 65),
    product("Webcam HD 1080p", "SKU-0007", "Electronics", new BigDecimal("79.99"), 95),
    product("Desk Lamp LED", "SKU-0008", "Office", new BigDecimal("39.99"), 150),
    product("Standing Desk Mat", "SKU-0009", "Office", new BigDecimal("29.99"), 180),
    product("Cable Management Kit", "SKU-0010", "Accessories", new BigDecimal("19.99"), 300),
    product("Noise-Cancelling Headset","SKU-0011", "Electronics", new BigDecimal("199.99"), 55),
    product("Laptop Stand Aluminium", "SKU-0012", "Accessories", new BigDecimal("69.99"), 110),
    product("Smart Plug 4-Pack", "SKU-0013", "Smart Home", new BigDecimal("34.99"), 250),
    product("Portable SSD 1TB", "SKU-0014", "Storage", new BigDecimal("119.99"), 75),
    product("Wireless Charger Pad", "SKU-0015", "Accessories", new BigDecimal("29.99"), 160),
    product("Screen Privacy Filter", "SKU-0016", "Accessories", new BigDecimal("44.99"), 90),
    product("USB Microphone", "SKU-0017", "Electronics", new BigDecimal("89.99"), 40),
    product("Laptop Sleeve 15\"", "SKU-0018", "Accessories", new BigDecimal("24.99"), 200),
    product("Dual Monitor Arm", "SKU-0019", "Office", new BigDecimal("129.99"), 35),
    product("Keyboard Wrist Rest", "SKU-0020", "Accessories", new BigDecimal("17.99"), 220),
    product("External Numpad", "SKU-0021", "Accessories", new BigDecimal("32.99"), 115),
    product("HDMI 2.1 Cable 2m", "SKU-0022", "Cables", new BigDecimal("14.99"), 400),
    product("USB-A to USB-C Adapter", "SKU-0023", "Cables", new BigDecimal("9.99"), 500),
    product("Thunderbolt 4 Dock", "SKU-0024", "Electronics", new BigDecimal("279.00"), 20),
    product("Bluetooth Speaker", "SKU-0025", "Electronics", new BigDecimal("59.99"), 85),
    product("Fingerprint Reader", "SKU-0026", "Security", new BigDecimal("49.99"), 60),
    product("Document Scanner", "SKU-0027", "Office", new BigDecimal("189.00"), 25),
    product("Label Printer", "SKU-0028", "Office", new BigDecimal("99.00"), 45),
    product("Desk Organiser Set", "SKU-0029", "Office", new BigDecimal("24.99"), 175),
    product("Monitor Calibrator", "SKU-0030", "Electronics", new BigDecimal("139.99"), 18),
    product("Ergonomic Chair Cushion", "SKU-0031", "Office", new BigDecimal("49.99"), 130),
    product("Green Screen 180x200", "SKU-0032", "Electronics", new BigDecimal("74.99"), 40),
    product("Ring Light 18\"", "SKU-0033", "Electronics", new BigDecimal("59.99"), 55),
    product("Pen Tablet Small", "SKU-0034", "Electronics", new BigDecimal("79.99"), 70),
    product("Drawing Glove 2-Pack", "SKU-0035", "Accessories", new BigDecimal("7.99"), 350),
    product("SD Card 256GB", "SKU-0036", "Storage", new BigDecimal("34.99"), 300),
    product("MicroSD 128GB + Adapter", "SKU-0037", "Storage", new BigDecimal("19.99"), 400),
    product("NAS Drive 4TB", "SKU-0038", "Storage", new BigDecimal("99.99"), 22),
    product("USB Flash Drive 64GB", "SKU-0039", "Storage", new BigDecimal("12.99"), 500),
    product("Fibre Patch Cable 1m", "SKU-0040", "Cables", new BigDecimal("8.99"), 600),
    product("8-Port Network Switch", "SKU-0041", "Networking", new BigDecimal("39.99"), 80),
    product("Wi-Fi 6 Router", "SKU-0042", "Networking", new BigDecimal("149.99"), 35),
    product("Powerline Adapter Kit", "SKU-0043", "Networking", new BigDecimal("59.99"), 60),
    product("PoE Injector 30W", "SKU-0044", "Networking", new BigDecimal("24.99"), 90),
    product("IP Camera Outdoor", "SKU-0045", "Security", new BigDecimal("89.99"), 45),
    product("Smart Doorbell", "SKU-0046", "Smart Home", new BigDecimal("129.99"), 30),
    product("Motion Sensor 3-Pack", "SKU-0047", "Smart Home", new BigDecimal("39.99"), 110),
    product("Smart Light Bulb E27", "SKU-0048", "Smart Home", new BigDecimal("14.99"), 400),
    product("Smart Power Strip", "SKU-0049", "Smart Home", new BigDecimal("44.99"), 85),
    product("CO2 Air Quality Monitor", "SKU-0050", "Smart Home", new BigDecimal("69.99"), 50),
    product("Laptop Pro 16", "SKU-0051", "Electronics", new BigDecimal("1499.99"), 20),
    product("Tablet Stand Adjustable", "SKU-0052", "Accessories", new BigDecimal("22.99"), 140),
    product("Laptop Cooling Pad", "SKU-0053", "Accessories", new BigDecimal("35.99"), 100),
    product("Privacy Webcam Cover", "SKU-0054", "Accessories", new BigDecimal("4.99"), 700),
    product("Anti-Glare Screen Wipes", "SKU-0055", "Accessories", new BigDecimal("6.99"), 800)
    ));
    }
    };
    }
    private Product product(String name, String sku, String category, BigDecimal price, int stock) {
    Product p = new Product();
    p.setName(name);
    p.setSku(sku);
    p.setCategory(category);
    p.setPrice(price);
    p.setStock(stock);
    return p;
    }
    }

    What’s happening:

    • CommandLineRunner is a Spring Boot callback that runs after the application context starts. Returning it from a @Bean method registers it automatically.
    • The if (repository.count() == 0) guard prevents duplicate rows if the bean runs more than once during testing.
    • repository.saveAll(List.of(...)) inserts all 55 rows in a single batch rather than 55 separate INSERT statements.

    Why 55 rows? The default pagination.pageSize is 10, so 55 rows creates 6 pages. This makes the pagination controls visible and meaningful from the first load.

  6. Build the service

    Java
    package com.example.products;
    import com.fasterxml.jackson.core.type.TypeReference;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import org.springframework.data.domain.Page;
    import org.springframework.data.domain.PageRequest;
    import org.springframework.data.domain.Pageable;
    import org.springframework.data.domain.Sort;
    import org.springframework.data.jpa.domain.Specification;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    import jakarta.persistence.criteria.Predicate;
    import java.math.BigDecimal;
    import java.util.*;
    /**
    * Service layer for all product data operations.
    *
    * Responsibilities:
    * - Convert Handsontable's 1-based page index to Spring Data's 0-based index.
    * - Map HOT sort parameters ({ prop, order }) to a Spring Sort object.
    * - Deserialize HOT's JSON filter array and apply it as a JPA Specification.
    * - Handle all three CRUD mutations in a single @Transactional boundary.
    */
    @Service
    @Transactional
    public class ProductService {
    /**
    * Columns that may be used in ORDER BY and WHERE clauses.
    *
    * Whitelisting prevents SQL injection via unsanitised sortProp / filter column values.
    */
    private static final Set<String> ALLOWED_COLUMNS = Set.of(
    "id", "name", "sku", "category", "price", "stock"
    );
    private final ProductRepository repository;
    private final ObjectMapper objectMapper;
    public ProductService(ProductRepository repository, ObjectMapper objectMapper) {
    this.repository = repository;
    this.objectMapper = objectMapper;
    }
    /**
    * Returns a page of products plus the total row count.
    *
    * @param page 1-based page number (sent by Handsontable).
    * @param pageSize Rows per page.
    * @param sortProp Column name to sort by, or null for default (id ASC).
    * @param sortOrder "asc" or "desc", or null.
    * @param filtersJson JSON array of { column, value } filter objects, or null.
    * @return Map with keys "rows" (List<Product>) and "totalRows" (long).
    */
    @Transactional(readOnly = true)
    public Map<String, Object> findAll(int page, int pageSize,
    String sortProp, String sortOrder,
    String filtersJson) {
    Sort sort = buildSort(sortProp, sortOrder);
    // Handsontable sends page 1 for the first page.
    // Spring Data PageRequest.of() expects 0-based index -- subtract 1.
    Pageable pageable = PageRequest.of(page - 1, pageSize, sort);
    Specification<Product> spec = buildFilters(filtersJson);
    Page<Product> result = repository.findAll(spec, pageable);
    // Map Spring Data's Page object to the shape Handsontable expects:
    // { rows: [...], totalRows: N }
    Map<String, Object> response = new HashMap<>();
    response.put("rows", result.getContent());
    response.put("totalRows", result.getTotalElements());
    return response;
    }
    /**
    * Creates rowsAmount empty product rows.
    *
    * Each new row gets a placeholder name and a UUID-derived SKU to satisfy
    * the unique SKU constraint.
    */
    public void createRows(CreateRowsPayload payload) {
    List<Product> newProducts = new ArrayList<>();
    for (int i = 0; i < payload.getRowsAmount(); i++) {
    Product p = new Product();
    p.setName("New Product");
    p.setSku("SKU-NEW-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
    p.setCategory("Uncategorized");
    p.setPrice(BigDecimal.ZERO);
    p.setStock(0);
    newProducts.add(p);
    }
    repository.saveAll(newProducts);
    }
    /**
    * Applies the changes from each UpdateRowPayload to the matching product.
    *
    * Only the keys present in the `changes` map are updated -- if the user
    * only edits the price column, name/sku/etc. are left untouched.
    */
    public void updateRows(List<UpdateRowPayload> rows) {
    for (UpdateRowPayload row : rows) {
    repository.findById(row.getId()).ifPresent(product -> {
    Map<String, Object> changes = row.getChanges();
    if (changes.containsKey("name")) {
    product.setName((String) changes.get("name"));
    }
    if (changes.containsKey("sku")) {
    product.setSku((String) changes.get("sku"));
    }
    if (changes.containsKey("category")) {
    product.setCategory((String) changes.get("category"));
    }
    if (changes.containsKey("price")) {
    Object val = changes.get("price");
    product.setPrice(val == null ? null : new BigDecimal(val.toString()));
    }
    if (changes.containsKey("stock")) {
    Object val = changes.get("stock");
    product.setStock(val == null ? null : Integer.parseInt(val.toString()));
    }
    repository.save(product);
    });
    }
    }
    /**
    * Deletes all products whose IDs are in rowIds.
    *
    * deleteAllById() executes a single batch DELETE, avoiding N+1 queries.
    */
    public void removeRows(List<Long> rowIds) {
    repository.deleteAllById(rowIds);
    }
    // --- Private helpers ---
    /**
    * Builds a Spring Sort from HOT's { prop, order } parameters.
    *
    * Falls back to id ASC when sortProp is absent or not in the whitelist.
    */
    private Sort buildSort(String sortProp, String sortOrder) {
    if (sortProp == null || !ALLOWED_COLUMNS.contains(sortProp)) {
    return Sort.by(Sort.Direction.ASC, "id");
    }
    Sort.Direction direction = "desc".equalsIgnoreCase(sortOrder)
    ? Sort.Direction.DESC
    : Sort.Direction.ASC;
    return Sort.by(direction, sortProp);
    }
    /**
    * Deserializes HOT's JSON filter array and converts it to a JPA Specification.
    *
    * HOT sends filters as a JSON array -- e.g.:
    * [{"column":"category","value":"Electronics"}]
    *
    * Each filter becomes a LIKE predicate on the matching column.
    * Columns not in ALLOWED_COLUMNS are silently ignored.
    */
    private Specification<Product> buildFilters(String filtersJson) {
    if (filtersJson == null || filtersJson.isBlank()) {
    return Specification.where(null);
    }
    try {
    List<Map<String, Object>> filters = objectMapper.readValue(
    filtersJson,
    new TypeReference<>() {}
    );
    return (root, query, builder) -> {
    List<Predicate> predicates = new ArrayList<>();
    for (Map<String, Object> filter : filters) {
    String column = (String) filter.get("column");
    String value = String.valueOf(filter.get("value"));
    if (column != null && ALLOWED_COLUMNS.contains(column)) {
    predicates.add(builder.like(
    builder.lower(root.get(column).as(String.class)),
    "%" + value.toLowerCase() + "%"
    ));
    }
    }
    return builder.and(predicates.toArray(new Predicate[0]));
    };
    } catch (Exception e) {
    // If the filter JSON is malformed, return all rows unfiltered.
    return Specification.where(null);
    }
    }
    }

    What’s happening:

    This is the core of the backend integration. The service translates between Handsontable’s data model and Spring Data’s abstractions.

    Page index conversion

    Pageable pageable = PageRequest.of(page - 1, pageSize, sort);

    Handsontable sends page: 1 for the first page. Spring Data’s PageRequest.of() expects a 0-based index. Subtracting 1 before passing to PageRequest.of() is the single conversion point — the rest of the code uses Spring’s model.

    Sort mapping

    Sort.Direction direction = "desc".equalsIgnoreCase(sortOrder)
    ? Sort.Direction.DESC
    : Sort.Direction.ASC;
    return Sort.by(direction, sortProp);

    Handsontable sends { prop: 'price', order: 'desc' }. The service converts order to a Sort.Direction enum value and builds a Sort object. The ALLOWED_COLUMNS whitelist rejects any sortProp value that is not a known column name, preventing SQL injection.

    Filter deserialization

    List<Map<String, Object>> filters = objectMapper.readValue(
    filtersJson,
    new TypeReference<>() {}
    );

    Handsontable sends filters as a JSON array in a single query parameter — for example [{"column":"category","value":"Electronics"}]. The controller receives this as a raw String, and the service deserializes it with Jackson’s ObjectMapper. Each entry becomes a LIKE predicate applied to the matching column.

    Page response mapping

    response.put("rows", result.getContent());
    response.put("totalRows", result.getTotalElements());

    Spring Data’s Page<Product> contains content (the row list), totalElements (the full count), and pagination metadata. Handsontable needs only rows and totalRows, so the service extracts those two values and discards the rest.

    @Transactional on mutations

    The class-level @Transactional annotation wraps every public method in a single database transaction. If any step inside updateRows or removeRows throws, the whole operation rolls back automatically. The findAll method overrides this with @Transactional(readOnly = true) to allow Hibernate to skip dirty-checking during reads.

  7. Create the REST controller

    Java
    package com.example.products;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.bind.annotation.*;
    import java.util.List;
    import java.util.Map;
    /**
    * REST controller exposing the four endpoints consumed by Handsontable's
    * dataProvider plugin.
    *
    * GET /api/products -- fetchRows (pagination + sort + filter)
    * POST /api/products/create-rows -- onRowsCreate
    * PATCH /api/products/update-rows -- onRowsUpdate
    * DELETE /api/products/remove-rows -- onRowsRemove
    */
    @RestController
    @RequestMapping("/api/products")
    public class ProductController {
    private final ProductService productService;
    public ProductController(ProductService productService) {
    this.productService = productService;
    }
    /**
    * Returns a page of products with the total row count.
    *
    * Query parameters sent by Handsontable:
    * - page 1-based page number (default: 1)
    * - pageSize rows per page (default: 10)
    * - sortProp column to sort by (optional)
    * - sortOrder "asc" or "desc" (optional)
    * - filters JSON array of { column, value } objects (optional)
    *
    * Response shape:
    * { "rows": [...], "totalRows": 55 }
    */
    @GetMapping
    public ResponseEntity<Map<String, Object>> getProducts(
    @RequestParam(defaultValue = "1") int page,
    @RequestParam(defaultValue = "10") int pageSize,
    @RequestParam(required = false) String sortProp,
    @RequestParam(required = false) String sortOrder,
    @RequestParam(required = false) String filters
    ) {
    Map<String, Object> result = productService.findAll(page, pageSize, sortProp, sortOrder, filters);
    return ResponseEntity.ok(result);
    }
    /**
    * Creates one or more empty product rows.
    *
    * The request body matches CreateRowsPayload:
    * { "position": "below", "referenceRowId": 7, "rowsAmount": 1 }
    */
    @PostMapping("/create-rows")
    public ResponseEntity<Void> createRows(@RequestBody CreateRowsPayload payload) {
    productService.createRows(payload);
    return ResponseEntity.ok().build();
    }
    /**
    * Applies cell edits to existing product rows.
    *
    * The request body is an array of UpdateRowPayload:
    * [{ "id": 3, "changes": { "price": 899.99, "stock": 12 } }]
    */
    @PatchMapping("/update-rows")
    public ResponseEntity<Void> updateRows(@RequestBody List<UpdateRowPayload> rows) {
    productService.updateRows(rows);
    return ResponseEntity.ok().build();
    }
    /**
    * Deletes the products with the given IDs.
    *
    * The request body is a JSON array of Long IDs: [4, 17, 23]
    */
    @DeleteMapping("/remove-rows")
    public ResponseEntity<Void> removeRows(@RequestBody List<Long> rowIds) {
    productService.removeRows(rowIds);
    return ResponseEntity.ok().build();
    }
    }

    What’s happening:

    • @RestController combines @Controller and @ResponseBody, so every method return value is serialized to JSON automatically.
    • @RequestMapping("/api/products") is the base path for all four endpoints.
    • The @GetMapping method uses @RequestParam with required = false for optional parameters. Spring returns null for absent params, which the service handles with null checks.
    • The @PostMapping, @PatchMapping, and @DeleteMapping methods receive their payloads as @RequestBody and return 200 OK with no body. Handsontable only checks for a non-error HTTP status on mutation responses.

    Endpoint summary:

    HTTP methodPathHandsontable callback
    GET/api/productsfetchRows
    POST/api/products/create-rowsonRowsCreate
    PATCH/api/products/update-rowsonRowsUpdate
    DELETE/api/products/remove-rowsonRowsRemove
  8. Configure CORS

    Java
    package com.example.products;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.CorsRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    /**
    * Allows browsers on any origin to call /api/** endpoints.
    *
    * In production, replace `allowedOrigins("*")` with the actual frontend URL
    * to prevent cross-site request abuse.
    */
    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
    registry
    .addMapping("/api/**")
    .allowedOrigins("*")
    .allowedMethods("GET", "POST", "PATCH", "DELETE");
    }
    }

    What’s happening:

    • WebMvcConfigurer is a Spring MVC callback interface. Implementing addCorsMappings is the idiomatic way to configure CORS globally without annotations on every controller.
    • allowedOrigins("*") is safe for a local development recipe. In production, replace "*" with the exact frontend origin (e.g. "https://your-app.com") to prevent cross-site request abuse.
    • Explicitly listing allowedMethods keeps the CORS headers narrow — only the four HTTP methods the Handsontable callbacks use are allowed.
  9. Wire up Handsontable

    Start the backend and the Vite dev server with bash setup.sh (or make setup), then open http://localhost:5173. The backend runs on http://localhost:8080 inside Docker; Vite proxies all /api/* requests to it so no CORS configuration is needed in the browser. The complete frontend code is in the files below.

    Example source not found.

    What’s happening:

    buildUrl helper

    buildUrl assembles the query string for fetchRows. It skips undefined and null values so that optional parameters — sortProp, sortOrder, and filters — are only appended to the URL when they are actually set. Passing undefined to URLSearchParams.set() would append the literal string "undefined" instead of omitting the parameter.

    fetchRows

    Handsontable calls fetchRows whenever the user changes the page, sorts a column, or applies a filter. The function:

    1. Maps Handsontable’s parameter shape to the Spring Boot query parameter names (sortProp, sortOrder).
    2. Serializes the filters array to a JSON string — the controller receives it as a String query parameter and the service deserializes it with Jackson.
    3. Passes the AbortSignal to fetch so the browser cancels in-flight requests when a faster interaction follows (e.g., the user jumps two pages ahead quickly).
    4. Throws on a non-ok response so notification: true displays an error toast automatically.
    5. Returns { rows, totalRows } — Handsontable uses totalRows to calculate the total number of pages.

    onRowsCreate, onRowsUpdate, onRowsRemove

    onRowsCreate must return the array of rows created by the server (including server-assigned id values). Handsontable uses the returned rows to update its internal row map so that subsequent updates and deletes reference the correct primary keys. It also shows a “Row added” success notification with the generated IDs. The controller accepts the payload as CreateRowsPayload.

    Cell edits via onRowsUpdate appear in the grid immediately (optimistic update). Each element sent to the server is { id, changes } where changes contains only the columns the user modified — the service applies those changes selectively in ProductService.updateRows(). If the server returns a non-2xx response or any callback throws, Handsontable rolls back the values and fires afterRowsMutationError.

    onRowsRemove sends an array of id values matching dataProvider.rowId. The controller deserializes them as List<Long> and passes them to repository.deleteAllById().

    beforeRowsMutation

    beforeRowsMutation fires before any create, update, or remove operation. Returning false cancels the operation — onRowsRemove is not called and no rows are deleted on the server.

    Because beforeRowsMutation is synchronous and checks for a strict === false return, you cannot use window.confirm() or any async dialog. Instead, use notification.showMessage() with variant: 'warning' and two action buttons. Cancel the first attempt by returning false, then on Delete re-issue the remove via hot.getPlugin('dataProvider').removeRows(rowsRemove). The removeConfirmed flag lets the second pass through without re-prompting.

    notification: true and emptyDataState: true

    notification: true enables the built-in error toast. When fetchRows or a mutation callback throws or the server returns a non-2xx status, Handsontable shows a dismissible error message. Fetch failures also add a Refetch action that calls fetchRows again.

    emptyDataState: true shows a placeholder message when the current filter combination returns zero rows, instead of leaving the grid blank.

    contextMenu: true enables the right-click context menu with “Insert row above / below” and “Remove row” items.

How It Works — Complete Flow

  1. Initial load: Handsontable calls fetchRows with page: 1, pageSize: 10, no sort, no filters.
  2. Server receives: GET /api/products?page=1&pageSize=10
  3. Service converts: PageRequest.of(0, 10, Sort.by(ASC, "id")) — page index shifted by -1.
  4. Spring Data queries: SELECT * FROM products ORDER BY id ASC LIMIT 10.
  5. Response mapping: { rows: [...10 products...], totalRows: 55 } returned to the grid.
  6. User sorts by price descending: Handsontable calls fetchRows with sort: { prop: 'price', order: 'desc' }.
  7. Server receives: GET /api/products?page=1&pageSize=10&sortProp=price&sortOrder=desc
  8. Service builds: Sort.by(DESC, "price") and creates a new PageRequest.
  9. User applies a filter: Handsontable calls fetchRows with filters: [{ column: 'category', value: 'Electronics' }].
  10. Server receives: GET /api/products?...&filters=[{"column":"category","value":"Electronics"}]
  11. Service deserializes: Jackson parses the JSON string into List<Map<String, Object>>, which becomes a JPA LIKE '%electronics%' predicate.
  12. User edits a cell: Handsontable calls onRowsUpdate with [{ id: 4, changes: { price: 599.00 } }].
  13. Server receives: PATCH /api/products/update-rows — service finds the product by ID and updates only the price field.
  14. User inserts a row: The user right-clicks and selects Insert row below. onRowsCreate fires with { position: 'below', referenceRowId: 4, rowsAmount: 1 }. Spring creates a blank row and returns it. dataProvider updates its internal row map and Handsontable shows a “Row added” success notification.
  15. User deletes rows: The user selects two rows and chooses Remove rows. beforeRowsMutation intercepts the operation, returns false, and shows a warning notification with Delete and Cancel action buttons. On Delete, onRowsRemove fires with [4, 7]. Spring deletes both rows.

What you learned

  • How to convert Handsontable’s 1-based page index to Spring Data’s 0-based PageRequest.of(page - 1, pageSize, sort).
  • How to map Spring Data’s Page<T> response to the { rows, totalRows } shape the dataProvider plugin expects.
  • How to whitelist sort columns to prevent SQL injection through the sortProp query parameter.
  • How to deserialize Handsontable’s JSON filter array from a single query parameter using Jackson’s ObjectMapper.
  • How to use JpaSpecificationExecutor to apply dynamic LIKE predicates without writing raw queries.
  • How to use @Transactional(readOnly = true) on reads and @Transactional on mutations for correct transaction boundaries.
  • How to configure CORS with WebMvcConfigurer so the browser can reach the Spring Boot API from a different origin.
  • How notification: true and emptyDataState: true improve the user experience when the server is slow or returns no results.

Next steps

  • Replace H2 with a persistent database (PostgreSQL, MySQL) by swapping the datasource in application.properties and changing ddl-auto to validate.
  • Add @Valid to the controller DTOs and define Bean Validation constraints (e.g. @NotBlank on name, @Positive on price) to return structured error responses when the user saves invalid data.
  • Secure the API with Spring Security: require authentication for mutation endpoints while keeping GET /api/products public.
  • Compare with the Laravel recipe to see the same Handsontable frontend wired to a PHP backend using the same endpoint shapes.
  • Compare with the Symfony recipe to see the same Handsontable frontend wired to a PHP/Symfony backend using the same endpoint shapes.