Server-side Data with Spring Boot
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.
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
Pageresponse 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
dataProviderplugin available
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 productsOr 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-webprovides the embedded Tomcat server and@RestControllersupport.spring-boot-starter-data-jpabrings in Hibernate and the Spring Data repository abstraction.postgresqlis scoped toruntime— it provides the JDBC driver but is not needed at compile time.flyway-coreandflyway-database-postgresqlmanage schema creation via versioned SQL migration files instead of Hibernate’sddl-auto.
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.PostgreSQLDialectspring.jpa.hibernate.ddl-auto=validate# Flyway runs migrations from src/main/resources/db/migration before the app starts.spring.flyway.enabled=truespring.flyway.locations=classpath:db/migrationWhat’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=validatetells 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=truetells Spring Boot to run pending migrations fromsrc/main/resources/db/migrationbefore the application context finishes starting. The migrationV1__create_products_table.sqlcreates theproductstable on the first run.
- The datasource URL, username, and password are read from environment variables (
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:
@Entityand@Table(name = "products")tell JPA to map this class to theproductstable.@Idand@GeneratedValue(strategy = GenerationType.IDENTITY)configure auto-increment. The generatedidvalue is what Handsontable references viadataProvider.rowId: 'id'.@Column(nullable = false)enforces database-level constraints onnameandsku.@Column(precision = 10, scale = 2)storespricewith two decimal places, matching thenumericcell 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.
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 ProductRepositoryextends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {}What’s happening:
JpaRepository<Product, Long>providessave,findById,deleteAllById, andcountmethods — everything needed for CRUD without writing any SQL.JpaSpecificationExecutor<Product>adds thefindAll(Specification, Pageable)overload. This is the key method used inProductServiceto apply server-side filters as JPA predicates.
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.*/@Configurationpublic class DataInitializer {@BeanCommandLineRunner 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:
CommandLineRunneris a Spring Boot callback that runs after the application context starts. Returning it from a@Beanmethod 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.pageSizeis 10, so 55 rows creates 6 pages. This makes the pagination controls visible and meaningful from the first load.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@Transactionalpublic 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: 1for the first page. Spring Data’sPageRequest.of()expects a 0-based index. Subtracting 1 before passing toPageRequest.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 convertsorderto aSort.Directionenum value and builds aSortobject. TheALLOWED_COLUMNSwhitelist rejects anysortPropvalue 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 rawString, and the service deserializes it with Jackson’sObjectMapper. Each entry becomes aLIKEpredicate applied to the matching column.Page response mapping
response.put("rows", result.getContent());response.put("totalRows", result.getTotalElements());Spring Data’s
Page<Product>containscontent(the row list),totalElements(the full count), and pagination metadata. Handsontable needs onlyrowsandtotalRows, so the service extracts those two values and discards the rest.@Transactionalon mutationsThe class-level
@Transactionalannotation wraps every public method in a single database transaction. If any step insideupdateRowsorremoveRowsthrows, the whole operation rolls back automatically. ThefindAllmethod overrides this with@Transactional(readOnly = true)to allow Hibernate to skip dirty-checking during reads.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 }*/@GetMappingpublic 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:
@RestControllercombines@Controllerand@ResponseBody, so every method return value is serialized to JSON automatically.@RequestMapping("/api/products")is the base path for all four endpoints.- The
@GetMappingmethod uses@RequestParamwithrequired = falsefor optional parameters. Spring returnsnullfor absent params, which the service handles with null checks. - The
@PostMapping,@PatchMapping, and@DeleteMappingmethods receive their payloads as@RequestBodyand return200 OKwith no body. Handsontable only checks for a non-error HTTP status on mutation responses.
Endpoint summary:
HTTP method Path Handsontable callback GET/api/productsfetchRowsPOST/api/products/create-rowsonRowsCreatePATCH/api/products/update-rowsonRowsUpdateDELETE/api/products/remove-rowsonRowsRemoveConfigure 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.*/@Configurationpublic class CorsConfig implements WebMvcConfigurer {@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/api/**").allowedOrigins("*").allowedMethods("GET", "POST", "PATCH", "DELETE");}}What’s happening:
WebMvcConfigureris a Spring MVC callback interface. ImplementingaddCorsMappingsis 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
allowedMethodskeeps the CORS headers narrow — only the four HTTP methods the Handsontable callbacks use are allowed.
Wire up Handsontable
Start the backend and the Vite dev server with
bash setup.sh(ormake setup), then openhttp://localhost:5173. The backend runs onhttp://localhost:8080inside 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:
buildUrlhelperbuildUrlassembles the query string forfetchRows. It skipsundefinedandnullvalues so that optional parameters —sortProp,sortOrder, andfilters— are only appended to the URL when they are actually set. PassingundefinedtoURLSearchParams.set()would append the literal string"undefined"instead of omitting the parameter.fetchRowsHandsontable calls
fetchRowswhenever the user changes the page, sorts a column, or applies a filter. The function:- Maps Handsontable’s parameter shape to the Spring Boot query parameter names (
sortProp,sortOrder). - Serializes the
filtersarray to a JSON string — the controller receives it as aStringquery parameter and the service deserializes it with Jackson. - Passes the
AbortSignaltofetchso the browser cancels in-flight requests when a faster interaction follows (e.g., the user jumps two pages ahead quickly). - Throws on a non-ok response so
notification: truedisplays an error toast automatically. - Returns
{ rows, totalRows }— Handsontable usestotalRowsto calculate the total number of pages.
onRowsCreate,onRowsUpdate,onRowsRemoveonRowsCreatemust return the array of rows created by the server (including server-assignedidvalues). 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 asCreateRowsPayload.Cell edits via
onRowsUpdateappear in the grid immediately (optimistic update). Each element sent to the server is{ id, changes }wherechangescontains only the columns the user modified — the service applies those changes selectively inProductService.updateRows(). If the server returns a non-2xx response or any callback throws, Handsontable rolls back the values and firesafterRowsMutationError.onRowsRemovesends an array ofidvalues matchingdataProvider.rowId. The controller deserializes them asList<Long>and passes them torepository.deleteAllById().beforeRowsMutationbeforeRowsMutationfires before any create, update, or remove operation. Returningfalsecancels the operation —onRowsRemoveis not called and no rows are deleted on the server.Because
beforeRowsMutationis synchronous and checks for a strict=== falsereturn, you cannot usewindow.confirm()or any async dialog. Instead, usenotification.showMessage()withvariant: 'warning'and two action buttons. Cancel the first attempt by returningfalse, then on Delete re-issue the remove viahot.getPlugin('dataProvider').removeRows(rowsRemove). TheremoveConfirmedflag lets the second pass through without re-prompting.notification: trueandemptyDataState: truenotification: trueenables the built-in error toast. WhenfetchRowsor 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 callsfetchRowsagain.emptyDataState: trueshows a placeholder message when the current filter combination returns zero rows, instead of leaving the grid blank.contextMenu: trueenables the right-click context menu with “Insert row above / below” and “Remove row” items.- Maps Handsontable’s parameter shape to the Spring Boot query parameter names (
How It Works — Complete Flow
- Initial load: Handsontable calls
fetchRowswithpage: 1,pageSize: 10, no sort, no filters. - Server receives:
GET /api/products?page=1&pageSize=10 - Service converts:
PageRequest.of(0, 10, Sort.by(ASC, "id"))— page index shifted by -1. - Spring Data queries:
SELECT * FROM products ORDER BY id ASC LIMIT 10. - Response mapping:
{ rows: [...10 products...], totalRows: 55 }returned to the grid. - User sorts by price descending: Handsontable calls
fetchRowswithsort: { prop: 'price', order: 'desc' }. - Server receives:
GET /api/products?page=1&pageSize=10&sortProp=price&sortOrder=desc - Service builds:
Sort.by(DESC, "price")and creates a newPageRequest. - User applies a filter: Handsontable calls
fetchRowswithfilters: [{ column: 'category', value: 'Electronics' }]. - Server receives:
GET /api/products?...&filters=[{"column":"category","value":"Electronics"}] - Service deserializes: Jackson parses the JSON string into
List<Map<String, Object>>, which becomes a JPALIKE '%electronics%'predicate. - User edits a cell: Handsontable calls
onRowsUpdatewith[{ id: 4, changes: { price: 599.00 } }]. - Server receives:
PATCH /api/products/update-rows— service finds the product by ID and updates only thepricefield. - User inserts a row: The user right-clicks and selects Insert row below.
onRowsCreatefires with{ position: 'below', referenceRowId: 4, rowsAmount: 1 }. Spring creates a blank row and returns it.dataProviderupdates its internal row map and Handsontable shows a “Row added” success notification. - User deletes rows: The user selects two rows and chooses Remove rows.
beforeRowsMutationintercepts the operation, returnsfalse, and shows a warning notification with Delete and Cancel action buttons. On Delete,onRowsRemovefires 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 thedataProviderplugin expects. - How to whitelist sort columns to prevent SQL injection through the
sortPropquery parameter. - How to deserialize Handsontable’s JSON filter array from a single query parameter using Jackson’s
ObjectMapper. - How to use
JpaSpecificationExecutorto apply dynamicLIKEpredicates without writing raw queries. - How to use
@Transactional(readOnly = true)on reads and@Transactionalon mutations for correct transaction boundaries. - How to configure CORS with
WebMvcConfigurerso the browser can reach the Spring Boot API from a different origin. - How
notification: trueandemptyDataState: trueimprove 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.propertiesand changingddl-autotovalidate. - Add
@Validto the controller DTOs and define Bean Validation constraints (e.g.@NotBlankonname,@Positiveonprice) 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/productspublic. - 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.