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, Spring Data JPA, H2 (in-memory), 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 an H2 in-memory database with 55 product rows on startup
Before you begin
- Java 17 or later and Maven or Gradle 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,h2,validation \-d type=maven-project \-d language=java \-d bootVersion=3.2.0 \-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><!-- H2 in-memory database -- zero setup, no installation needed --><dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency><!-- Bean Validation for request DTOs --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</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.h2is scoped toruntimeso it is available during development but excluded from production builds.spring-boot-starter-validationenables@Validand@RequestBodyvalidation annotations on DTOs.
Configure the H2 database
Create or update
src/main/resources/application.properties:@code
What’s happening:
jdbc:h2:mem:productscreates an in-memory database namedproducts. The data exists only while the application is running.DB_CLOSE_DELAY=-1keeps the database open for the lifetime of the JVM. Without it, H2 closes the connection pool after the first connection is released.ddl-auto=create-droptells Hibernate to generate the schema from your entities on startup and drop it on shutdown. This is appropriate for a recipe but you should switch tovalidateornonein production.h2.console.enabled=trueexposes the H2 web console athttp://localhost:8080/h2-consoleso you can inspect the data during development.
Create the Product entity
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
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
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
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
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
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
With the server running on
http://localhost:8080, configure Handsontable to use thedataProviderplugin. The complete frontend code is in the files below.What’s happening:
buildUrlhelperfunction buildUrl(base, params) {const url = new URL(base, window.location.origin);for (const [key, value] of Object.entries(params)) {if (value !== undefined && value !== null) {url.searchParams.set(key, String(value));}}return url.toString();}buildUrlassembles 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.fetchRowsfetchRows: async ({ page, pageSize, sort, filters }, { signal }) => {const url = buildUrl('/api/products', {page,pageSize,sortProp: sort?.prop,sortOrder: sort?.order,filters: filters ? JSON.stringify(filters) : undefined,});const res = await fetch(url, { signal });const json = await res.json();return { rows: json.rows, totalRows: json.totalRows };},Handsontable 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.
- Serializes the
filtersarray to a JSON string — the controller receives it as aStringquery parameter and the service deserializes it. - Passes the
AbortSignaltofetchso the browser cancels in-flight requests when a faster interaction follows (e.g., the user jumps two pages ahead quickly). - Returns
{ rows, totalRows }— Handsontable usestotalRowsto calculate the total number of pages.
onRowsCreateonRowsCreate: async (payload) => {await fetch('/api/products/create-rows', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(payload),});},Handsontable passes a
payloadobject withposition,referenceRowId, androwsAmount. The controller accepts this asCreateRowsPayload. After the request completes, Handsontable callsfetchRowsagain to reload the current page with the newly created rows.onRowsUpdateonRowsUpdate: async (rows) => {await fetch('/api/products/update-rows', {method: 'PATCH',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(rows),});},Handsontable batches all cell edits from a single user action into one array. Each element is
{ id, changes }wherechangescontains only the columns the user modified. The server applies those changes selectively inProductService.updateRows().onRowsRemoveonRowsRemove: async (rowIds) => {await fetch('/api/products/remove-rows', {method: 'DELETE',headers: { 'Content-Type': 'application/json' },body: JSON.stringify(rowIds),});},Handsontable passes an array of
idvalues matchingdataProvider.rowId. The controller deserializes them asList<Long>and passes them torepository.deleteAllById().notification: trueandemptyDataState: truenotification: true,emptyDataState: true,notification: 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.
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.
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.