Spring Boot & React Full-Stack Development
Hilla is a full-stack web framework for Java. It can help you build better business applications faster by combining a Spring Boot backend with a React frontend. Plus, it has more than forty UI components that you can utilize for a more professional application.
In this tutorial, you’ll learn the basics of Hilla development by building a full-stack CRM application. The code snippets are optimized so you may copy and paste them to have a working application up and running quickly.
You can find the complete source code for this tutorial on GitHub.
Requirements
To follow along on this tutorial and to build a full-stack CRM application, you’ll need only a few things in place:
-
Java 17 or later;
-
Node 18 or later;
-
An IDE that supports Java and TypeScript — IntelliJ Ultimate and VS Code are good choices; and
-
Intermediate Java skills.
Download Project Starter
To begin, you’ll need to download the Project Starter. You can do that by clicking on the button here.
You could instead use the Hilla CLI to create a new project by entering the following from the command line:
npx @hilla/cli init --preset=hilla-crm-tutorial hilla-crm
Project Structure
The Starter is a standard Hilla project with a test database, and repositories for accessing it. Here’s a quick overview of the relevant parts of the project:
-
frontend
contains the React frontend-
views
-
MainLayout.tsx
is the parent layout that includes the navigation bar -
contacts
-
ContactsView.tsx
is the view we are implementing
-
-
-
routes.tsx
is routing setup for the app
-
-
src/main/java
is the Spring Boot backend-
com.example.application
-
Application.java
is the main Spring Boot application class -
data
contains all the database-access-related code-
Contact.java
andCompany.java
are the JPA entities -
ContactRepository.java
andCompanyRepository.java
are repositories for accessing the database
-
-
services
is where we will create a backend service for the application
-
-
-
main/resources/data.sql
is an SQL script that populates the database with demo data on each start
Run the Hilla Application
There are two ways to run an application: you can use your IDE to run Application.java
— this is recommended since it supports debugging; or you can run the default Maven goal from the command-line (i.e., mvn
).
Hilla will automatically reload changes to frontend and backend code when saving. IntelliJ requires you to build manually the project after making changes to Java code, while VS Code will build automatically the project on save.
Create a Browser-Callable Java Service
Hilla makes it possible to call Java classes from TypeScript with the @BrowserCallable
annotation. To do this, create a CRMService.java
class in the com.example.application.services
package. The service handles both listing and saving Contacts. Add the following code to the class:
package com.example.application.services;
import com.example.application.data.Company;
import com.example.application.data.CompanyRepository;
import com.example.application.data.Contact;
import com.example.application.data.ContactRepository;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
@AnonymousAllowed
@BrowserCallable
public class CRMService {
private final CompanyRepository companyRepository;
private final ContactRepository contactRepository;
public CRMService(CompanyRepository companyRepository, ContactRepository contactRepository) {
this.companyRepository = companyRepository;
this.contactRepository = contactRepository;
}
public record ContactRecord(
Long id,
@NotNull
@Size(min = 1, max = 50)
String firstName,
@NotNull
@Size(min = 1, max = 50)
String lastName,
@NotNull
@Email
String email,
@NotNull
CompanyRecord company
) {
}
public record CompanyRecord(
@NotNull
Long id,
String name
) {
}
private ContactRecord toContactRecord(Contact c) {
return new ContactRecord(
c.getId(),
c.getFirstName(),
c.getLastName(),
c.getEmail(),
new CompanyRecord(
c.getCompany().getId(),
c.getCompany().getName()
)
);
}
private CompanyRecord toCompanyRecord(Company c) {
return new CompanyRecord(
c.getId(),
c.getName()
);
}
public List<CompanyRecord> findAllCompanies() {
return companyRepository.findAll().stream()
.map(this::toCompanyRecord).toList();
}
public List<ContactRecord> findAllContacts() {
List<Contact> all = contactRepository.findAllWithCompany();
return all.stream()
.map(this::toContactRecord).toList();
}
public ContactRecord save(ContactRecord contact) {
var dbContact = contactRepository.findById(contact.id).orElseThrow();
var company = companyRepository.findById(contact.company.id).orElseThrow();
dbContact.setFirstName(contact.firstName);
dbContact.setLastName(contact.lastName);
dbContact.setEmail(contact.email);
dbContact.setCompany(company);
var saved = contactRepository.save(dbContact);
return toContactRecord(saved);
}
}
-
The
@BrowserCallable
annotation makes all public methods in the service available to call from TypeScript. -
@AnonymousAllowed
turns off access control for this service. Check out the security section to learn how Hilla uses Spring Security to secure server access. -
The service injects
ContactRepository
andCompanyRepository
in the constructor for database access. -
This defines DTOs for the view as Java Records, including validation annotations that you want to enforce, both in the UI and the service.
-
The service defines the CRUD methods needed for the CRM.
Now, you’ll have to build the application. Hilla will generate the needed TypeScript for accessing the service.
Listing Contacts in a Data Grid
With the backend completed, you can start building the UI. Change the contents of Frontend/views/contacts/ContactsView.tsx
to the following:
import ContactRecord from 'Frontend/generated/com/example/application/services/CRMService/ContactRecord';
import { useEffect } from 'react';
import { useSignal } from '@vaadin/hilla-react-signals';
import { CRMService } from 'Frontend/generated/endpoints';
import { Grid } from '@vaadin/react-components/Grid';
import { GridColumn } from '@vaadin/react-components/GridColumn';
export default function ContactsView() {
const contacts = useSignal<ContactRecord[]>([]);
const selected = useSignal<ContactRecord | null | undefined>(undefined);
useEffect(() => {
CRMService.findAllContacts().then(found => contacts.value = found);
}, []);
return (
<div className="p-m flex gap-m">
<Grid
items={contacts.value}
onActiveItemChanged={e => selected.value = e.detail}
selectedItems={[selected.value]}>
<GridColumn path="firstName"/>
<GridColumn path="lastName"/>
<GridColumn path="email" autoWidth/>
<GridColumn path="company.name" header="Company name"/>
</Grid>
</div>
);
}
-
This calls
CRMService.findAllContacts
in a ReactuseEffect
. It ensures the call only happens once by passing an empty dependency array. When the async call finishes, the contacts are updated into the contacts state. -
The contacts are bound to a
<Grid>
component that defines columns for each property you want to display in the grid. -
The selected grid row is stored in the selected state variable. In the next step, you’ll bind the selected contact to a form for editing.
Reload your browser, and you should now see a data grid displaying all of the contacts created using the example data of main/resources/data.sql
.
Create a Form for Editing Contacts
For a complete CRM, users need to be able to edit contacts. Create a new component ContactForm.tsx
in frontend/views/contacts
:
import { useEffect } from 'react';
import { useForm } from '@vaadin/hilla-react-form';
import { useSignal } from '@vaadin/hilla-react-signals';
import { Button } from '@vaadin/react-components/Button';
import { EmailField } from '@vaadin/react-components/EmailField';
import { Select, SelectItem } from '@vaadin/react-components/Select';
import { TextField } from '@vaadin/react-components/TextField';
import ContactRecord from 'Frontend/generated/com/example/application/services/CRMService/ContactRecord';
import ContactRecordModel from 'Frontend/generated/com/example/application/services/CRMService/ContactRecordModel';
import { CRMService } from 'Frontend/generated/endpoints';
interface ContactFormProps {
contact?: ContactRecord | null;
onSubmit?: (contact: ContactRecord) => Promise<void>;
}
export default function ContactForm({contact, onSubmit}: ContactFormProps) {
const companies = useSignal<SelectItem[]>([]);
const {field, model, submit, reset, read} = useForm(ContactRecordModel, { onSubmit } );
useEffect(() => {
read(contact);
}, [contact]);
useEffect(() => {
getCompanies();
}, []);
async function getCompanies() {
const companies = await CRMService.findAllCompanies();
const companyItems = companies.map(company => {
return {
label: company.name,
value: company.id + ''
};
});
companies.value = companyItems;
}
return (
<div className="flex flex-col gap-s items-start">
<TextField label="First name" {...field(model.firstName)} />
<TextField label="Last name" {...field(model.lastName)} />
<EmailField label="Email" {...field(model.email)} />
<Select label="Company" items={companies.value} {...field(model.company.id)} />
<div className="flex gap-m">
<Button onClick={submit} theme="primary">Save</Button>
<Button onClick={reset}>Reset</Button>
</div>
</div>
)
}
-
The form component takes in a contact and
onSubmit
callback method as properties. -
The Hilla
useForm
hook uses the automatically generatedContactRecordModel
to configure a form based on the validation rules you defined in Java. -
The UI fields are bound to the form with
{…field(model.property)}
. Hilla will manage the form value and validations. -
Use an effect to read the passed-in contact into the form any time it changes.
-
Use an effect to fetch all companies from
CRMService
and convert them to objects with label-value pairs for the select component.
Change ContactsView.tsx
with the following content:
import ContactRecord from 'Frontend/generated/com/example/application/services/CRMService/ContactRecord';
import { useEffect } from 'react';
import { useSignal } from '@vaadin/hilla-react-signals';
import { CRMService } from 'Frontend/generated/endpoints';
import { Grid } from '@vaadin/react-components/Grid';
import { GridColumn } from '@vaadin/react-components/GridColumn';
import ContactForm from 'Frontend/views/contacts/ContactForm';
export default function ContactsView() {
const contacts = useSignal<ContactRecord[]>([]);
const selected = useSignal<ContactRecord | null | undefined>(undefined);
useEffect(() => {
CRMService.findAllContacts().then(found => contacts.value = found);
}, []);
async function onContactSaved(contact: ContactRecord) {
const saved = await CRMService.save(contact)
if (contact.id) {
contacts.value = contacts.value.map(current => current.id === saved.id ? saved : current);
} else {
contacts.value = [...contacts.value, saved];
}
selected.value = saved;
}
return (
<div className="p-m flex gap-m">
<Grid
items={contacts.value}
onActiveItemChanged={e => setSelected(e.detail.value)}
selectedItems={[selected.value]}>
<GridColumn path="firstName"/>
<GridColumn path="lastName"/>
<GridColumn path="email"/>
<GridColumn path="company.name" header="Company name"/>
</Grid>
{selected.value &&
<ContactForm contact={selected.value} onSubmit={onContactSaved}/>
}
</div>
);
}
-
The form is conditionally rendered if there is a selected item.
-
On submission, the updated contact is saved to
CRMService
. -
If the saved contact had an id (i.e., an existing contact), update the contacts state by swapping the updated contact.
-
If the contact is new, create a new contacts array and append the new contact.
-
Finally, select the newly saved item.
Refresh your browser, and try the application. You should now have a fully functional, full-stack application for listing and editing contacts. Verify that the changes are persisted in the database by refreshing your browser after making a change.
Build for Production
If you want to share your application with others, you’ll need to create a production build. It’ll generate an optimized build and turn off development-time debugging.
Note
| Your application has an in-memory database populated with demo data on each start. Remove the data initializer and change the database to a persistent database like PostgreSQL, MySQL, MariaDB, or something similar for a real production application. |
Create a production-ready JAR in the target folder with the following Maven command:
mvn package -Pproduction
The resulting JAR file is a standard Spring Boot application that you can run or deploy anywhere Java applications are supported.
Alternatively, you can use Spring Boot’s built-in buildpacks support to create a Docker image:
mvn spring-boot:build-image -Pproduction
Hilla also supports compiling GraalVM native images to optimize further the startup time or the memory consumption.
You can find the complete source code for this tutorial on GitHub.
c48fcda0-21e9-4c27-b639-6fa5fe21bb7d