Vaadin is a web framework for building full-stack web applications completely in Java, without using JavaScript or HTML. It has a great variety of ready UI components, built-in security and real-time communication, it enables Java developers to build data-rich enterprise applications without leaving the Java realm.
Today, we will spin up a beautiful Vaadin UI with Spring Boot from ground zero and look at some core components so that you can continue the exploration later on your own.
The code for the demo is available on GitHub.
Table of Contents
What We Will Build
I’ll use my NeuroWatch project as a demo. I like to call it Petclinic on Steroids: instead of pets and owners, it displays civilians with implants and enables real-time monitoring of implant health data.
It is built on Spring Boot and couples Spring Security, Kafka, and MongoDB. It gives us a perfect starting point for creating a login page, grid with filtering, an editor form secured by roles, and also a dashboard for real-time log processing.
In this article, we will look into the basics such as the login form, layouts, grids, and filters. In the next article, we will master user interaction, forms with validation, and live UI updates.
Prerequisites
- Docker and Docker Compose;
- Java 17 or newer. For instance, this project was built on Liberica JDK 25, a runtime recommended by Spring;
- Your favorite IDE.
Add Vaadin Dependencies
You can select the Vaadin dependency in Spring Initializr when building a new project. Alternatively, you can add Vaadin to the existing application. You will need vaadin-core, vaadin-spring-boot-starter, and a profile to build the frontend for production.
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-core</artifactId>
</dependency>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-spring-boot-starter</artifactId>
</dependency>
<profile>
<id>production</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<dependencies>
<dependency>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-core</artifactId>
<exclusions>
<exclusion>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-dev</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.version}</version>
<executions>
<execution>
<id>frontend</id>
<phase>compile</phase>
<goals>
<goal>prepare-frontend</goal>
<goal>build-frontend</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
The profile creates a deployable artifact where the frontend is compiled, bundled, minified, and stripped off dev mode features such as hot reload or dev debugging.
Consequently, to build the project, we will run:
./mvnw package -Pproduction
Create a Login View
We will have a main layout and a separate login view.
First, let’s create a LoginView class. It will extend Main, which is a Vaadin component that renders as an HTML <main> element. Implement BeforeEnterObserver so the view can intercept navigation before the router enters it.
public class LoginView extends Main implements BeforeEnterObserver {
}
Let’s add several useful class-level annotations.
- The
@Routeannotation registers this view at the /login route.autoLayout = falsetells Vaadin not to wrap this view in your app’s normal layout (nav bars, side menus, etc.). - The
@PageTitleannotation sets the browser tab title to “Login” when this view is active. - The
@AnonymousAllowedannotation allows unauthenticated users to access this route.
@Route(value = "login", autoLayout = false)
@PageTitle("Login")
@AnonymousAllowed
public class LoginView extends Main implements BeforeEnterObserver {
}
After that, add three class fields:
- A LOGIN_PATH constant used for the form’s action;
AuthenticationContextfrom Vaadin Spring Security, which gives you helper methods likeisAuthenticated();LoginForm, Vaadin’s ready-made form component. The out-of-the-box login form consists of a title, two input fields ("Username" and "Password"), and two buttons ("Log In" and "Forgot Password"). All of that with only creating a newLoginFormobject!
public static final String LOGIN_PATH = "login";
private final AuthenticationContext authenticationContext;
private final LoginForm login;
In the LoginView constructor, we need to inject the AuthenticationContext. Here, we will also instantiate the LoginForm.
Set the form’s POST target with setAction(). On submit, the browser posts credentials to /login, which Spring Security’s formLogin filter handles. Let’s also hide the built-in “Forgot password” button for simplicity sake.
The setSizeFull() method makes the root <main> fill the available space.
ContentDiv creates a wrapper <div> and places the LoginForm inside it, which is useful for styling and centering the form.
The add(contentDiv) method adds the <div> (with the login form inside) to the <main> component—i.e., it appears on the page.
LoginView(AuthenticationContext authenticationContext) {
this.authenticationContext = authenticationContext;
login = new LoginForm();
login.setAction(LOGIN_PATH);
login.setForgotPasswordButtonVisible(false);
setSizeFull();
addClassNames("login-view");
var contentDiv = new Div(login);
contentDiv.addClassNames("content-div");
add(contentDiv);
}
We need to override only one method, beforeEnter() that accepts the BeforeEnterEvent. It fires right before the navigation enters this view.
If the user is already logged in, we will not show the login page and forward them to the root route (/). Otherwise, we
- Check the query string for errors. Spring Security appends this when a login attempt fails.
- The
LoginForm.login.setError(true)flips the component into its “error” state and shows the “Invalid username or password” message.
@Override
public void beforeEnter(BeforeEnterEvent event) {
if (authenticationContext.isAuthenticated()) {
event.forwardTo("");
return;
}
if (event.getLocation().getQueryParameters().getParameters().containsKey("error")) {
login.setError(true);
}
}
With that, our login page with a form is ready!
Create a Main View
Let’s now create a main layout for our application. It extends the AppLayout that gives you a responsive shell with a top navigation bar and a left-side drawer that wraps around your actual views.
The @Layout annotation marks this class as a routing layout so views can be shown inside it. In practice, other @Routed views will use this as their parent layout.
MainLayout will extend AppLayout, which will give us:
- A top navigation bar area (Navbar),
- A side drawer (Drawer), and
- A place where the routed view content is displayed.
@Layout
@PermitAll
public class MainLayout extends AppLayout {
}
We will build the UI structure in the class constructor.
First, let’s create a DrawerToggle. This button opens/closes the left drawer with the vertical menu.
Then, create an H1 object. It will create a header text component that says “NeuroWatch.” You can add some in-line styles to the logo to make it nicer.
The addToNavbar() method adds the button and the logo into the top navigation bar area of AppLayout. That means every view using this layout will show a top bar with the hamburger and “NeuroWatch”.
public MainLayout() {
DrawerToggle toggle = new DrawerToggle();
H1 logo = new H1("NeuroWatch");
logo.getStyle().set("font-size", "1.5em").set("margin", "0");
addToNavbar(toggle, logo);
}
Next, we need to create several RouterLink objects. Each RouterLink navigates to a view class, which should be a Vaadin route, typically annotated with @Route. You can add as many links to other views as you want. They will always be available in the side drawer.
Finally, add the links to the Vertical layout and add this layout to the drawer with the addToDrawer() method.
public MainLayout() {
DrawerToggle toggle = new DrawerToggle();
H1 logo = new H1("NeuroWatch");
logo.getStyle().set("font-size", "1.5em").set("margin", "0");
addToNavbar(toggle, logo);
RouterLink civLink = new RouterLink("Civilians", CivilianView.class);
RouterLink logLink = new RouterLink("Implant Logs", ImplantLogView.class);
RouterLink logsLiveLink = new RouterLink("Live Logs", LiveLogsView.class);
addToDrawer(new VerticalLayout(civLink, logLink, logsLiveLink));
}
Create and Configure a Grid
Now, it’s time to create a CivilianView to display the data about civilians. In this section, we will learn how to create
- A grid to display data,
- Filters to filter items in a grid,
- A dialogue window with details on each civilian in a grid,
- An edit form that will be visible to admins only.
Let’s start with the grid. We will create a foundation now, and later, we will add additional UI elements to it.
Сreate a CivilianView class that extends VerticalLayout, which is a Vaadin view that lays children top-to-bottom. Then, add the root annotation that registers this view at the root path /. layout = MainLayout.class says, “render me inside MainLayout.” This way, we get the navbar and the drawer frame. The @PermitAll annotation means that anyone who has logged in can open this view.
@Route(value = "", layout = MainLayout.class)
@PermitAll
public class CivilianView extends VerticalLayout {
}
Inject CivilianService and add the Grid for Civilian with no auto-generated columns (false) as we will define them manually.
Also, add the CallbackDataProvider so the grid can ask for the current page of data and the total count. A CallbackDataProvider<T, F> is a Vaadin DataProvider implementation that loads data by calling your code (callbacks) whenever the UI needs it. We will build it later and use it to populate the grid with data.
private final CivilianService civilianService;
private final Grid<Civilian> grid = new Grid<>(Civilian.class, false);
private CallbackDataProvider<Civilian, Void> dataProvider;
In the constructor, we will build the DataProvider, set up the grid and add the Lumo utility classes. Lumo is Vaadin’s utility for CSS. For me, the name bears a strong resemblance to Lums from Rayman Legends, somehow. Anyway, we can add these utility classes to the root VerticalLayout so it looks how we want without writing custom CSS.
For instance, let’s add these classes with the addClassNames() method:
LumoUtility.BoxSizing.BORDERsetsbox-sizing: border-box. It means that padding and borders will be included in the component’s declared width/height.LumoUtility.Display.FLEXsetsdisplay: flex, turning the layout into a flex container.LumoUtility.FlexDirection.COLUMNsets flex-direction: column, which means that children will stack vertically. Filters, when we add them, will be the row on top, and grid will be below.LumoUtility.Padding.MEDIUMadds medium padding around the container.LumoUtility.Gap.SMALLadds a small gap between direct children. In our case, it is a space between the filters bar and the grid.LumoUtility.FlexWrap.WRAPsets flex-wrap: wrap. It means that if the container’s children can’t fit in one row/column, they’ll wrap instead of overflowing.
public CivilianView(CivilianService civilianService) {
this.civilianService = civilianService;
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX,
LumoUtility.FlexDirection.COLUMN,
LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL, LumoUtility.FlexWrap.WRAP);
}
Now, we need to build the CallBackProvider. I suggest we transfer this logic into a separate method:
public CivilianView(CivilianService civilianService) {
dataProvider = buildProvider();
}
private CallbackDataProvider<Civilian, Void> buildProvider() {
}
CallbackDataProvider uses two functional interfaces, FetchCallback and CountCallback. Consequently, it has two core responsibilities:
FetchCallbackreturns a Stream of items for the current visible page.CountCallbackreturns the total number of items so the grid can size the scrollbar and paging.
Vaadin calls these repeatedly as the user scrolls, sorts, filters, etc.
We need to create a provider with DataProvider.fromCallbacks(fetchCallback, countCallback).
For the FetchCallback, we call the serviceFetch() method, returning the collection of civilians, stream the collection, and return a Stream<Civilian>.
For the CountCallback, we return a total number of rows so the grid can paginate.
private CallbackDataProvider<Civilian, Void> buildProvider() {
return DataProvider.fromCallbacks(
/* fetch callback */
query -> {
int offset = query.getOffset();
int limit = query.getLimit();
return serviceFetch(offset, limit).stream();
},
/* count callback */
_ -> (int) serviceCount()
);
}
private Collection<Civilian> serviceFetch(int offset, int limit) {
return civilianService.getCivilians(offset, limit);
}
private long serviceCount() {
return civilianService.countCivilians();
}
The DataProvider is ready, the next step is to configure the grid. Let’s do that in a separate method as well:
public CivilianView(CivilianService civilianService) {
configureGrid();
}
Now, to the actual grid configuration.
First, let’s connect the grid to its data source. Bind our DataProvider with a setItems() method. This way, we are telling the grid to not use a fixed list but fetch rows from the provider. Our provider is a CallbackDataProvider, which means that the grid will call it as the user scrolls the page, asking for slices of data and total count.
grid.setItems(dataProvider);
Then, add two columns, where the values are read via the method references. The first column will display a national id, header text “National ID.” The second column will display a legal name, header text “Legal Name.”
grid.addColumn(Civilian::getNationalId).setHeader("National ID");
grid.addColumn(Civilian::getLegalName).setHeader("Legal Name");
With the addThemeVariants() method, we will enable the theme variant LUMO_WRAP_CELL_CONTENT. This way, a long cell text will wrap instead of overflowing.
The setSizeFull() method makes the layout, i.e., the view itself, fill the available space.
grid.addThemeVariants(GridVariant.LUMO_WRAP_CELL_CONTENT);
Put the grid into view with the add() method in the class constructor:
public CivilianView(CivilianService civilianService) {
this.civilianService = civilianService;
dataProvider = buildProvider();
configureGrid();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX,
LumoUtility.FlexDirection.COLUMN,
LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL, LumoUtility.FlexWrap.WRAP);
add(grid);
}
Run the application. After you login, you will see a grid with civilians and a side drawer with links to other pages.
Add Filter Search
Right now, we display all civilians. But in most cases, we need some sort of filtering.
First, let’s add some new UI elements. We will have three IntegerField fields for filtering by lot numbers, and also a TextField to search for a civilian by national ID. Let’s also add a button that will clear all filter fields when clicked.
@Route(value = "", layout = MainLayout.class)
@PermitAll
public class CivilianView extends VerticalLayout {
private final IntegerField lotGte = new IntegerField("Lot ≥");
private final IntegerField lotLte = new IntegerField("Lot ≤");
private final IntegerField lotN = new IntegerField("Lot #");
private final TextField nationalId = new TextField("National ID");
private final Button clear = new Button("Clear");
}
We will initialize the filters in the constructor, but configure them in a separate method. Also in the constructor, we will create a new HorizontalLayout and add the filters and the button there so that they are displayed in the horizontal row.
The setDefaultVerticalComponentAlignment(Alignment.END) aligns child components so their ends line up. This way, we align them with the Grid header.
We should also add the filters to view with the add() method. In this case, they will be displayed over the grid.
public CivilianView(CivilianService civilianService) {
configureFilters();
HorizontalLayout filters = new HorizontalLayout(lotGte, lotLte, lotN, nationalId, clear);
filters.setDefaultVerticalComponentAlignment(Alignment.END);
add(filters, grid);
}
Let’s move on to configuring the filters.
First, we need to add a listener. HasValue.ValueChangeListener creates one generic listener that calls the refresh() method whenever any filter value changes.
The wild generics enable the listener to handle value-change events from different field types, like IntegerField, TextField, etc.
private void configureFilters() {
HasValue.ValueChangeListener<? super AbstractField.ComponentValueChangeEvent<?, ?>> listener = _ -> refresh();
}
Next, let’s subscribe that listener to each filter field. As a result, changing any filter triggers the refresh() method which we will define as well.
Let’s also add a clickListener to the Clear button that will trigger clearing all filter fields. Clearing triggers value-change events too, so we will get a refresh automatically. We don’t need to explicitly call refresh() here, it’ll happen via the listeners as each field clears.
private void configureFilters() {
HasValue.ValueChangeListener<? super AbstractField.ComponentValueChangeEvent<?, ?>> listener = _ -> refresh();
lotGte.addValueChangeListener(listener);
lotLte.addValueChangeListener(listener);
lotN.addValueChangeListener(listener);
nationalId.addValueChangeListener(listener);
clear.addClickListener(_ -> {
lotGte.clear();
lotLte.clear();
lotN.clear();
nationalId.clear();
});
}
The refresh() method is super simple: we just call an in-build refreshAll() method of the DataProvider to update the provider when filters are applied or cleared. The provider will call the fetch/count again, and we will get new rows based on the filter values
private void refresh() {
dataProvider.refreshAll();
}
We remember that the buildProvider() method uses the serviceFetch() method to retrieve data. We need to improve it to add filtered search so that it decides which backend method to call based on the current filter values.
By our logic, only one filter will be applied at a time. So, we check whether a filter field has value, fetch civilians and return. Because the method uses an if-chain priority, a user can set multiple fields, but only one applies, namely, the first match.
private Collection<Civilian> serviceFetch(int offset, int limit) {
if (!lotN.isEmpty()) {
return civilianService.getCiviliansByLotNumber(offset, limit, lotN.getValue());
}
if (!lotGte.isEmpty()) {
return civilianService.getCiviliansByLotNumberGreaterOrEqual(offset, limit, lotGte.getValue());
}
if (!lotLte.isEmpty()) {
return civilianService.getCiviliansByLotNumberLessOrEqual(offset, limit, lotLte.getValue());
}
if (!nationalId.isEmpty()) {
return civilianService.getCiviliansByNationalId(offset, limit, nationalId.getValue());
}
return civilianService.getCivilians(offset, limit);
}
We should also update the serviceCount() method accordingly:
private long serviceCount() {
if (!lotN.isEmpty()) {
return civilianService.countByLotNumber(lotN.getValue());
}
if (!lotGte.isEmpty()) {
return civilianService.countByLotNumberGreaterOrEqual(lotGte.getValue());
}
if (!lotLte.isEmpty()) {
return civilianService.countByLotNumberLessOrEqual(lotLte.getValue());
}
if (!nationalId.isEmpty()) {
return civilianService.countByNationalId(nationalId.getValue());
}
return civilianService.countCivilians();
}
If you want to implement combined filtering, you need to change the serviceFetch() method to compute a single predicate and query with it.
Great, our CivilianView has filters now. You can search for a civilian by national ID or select all civilians who have implants with a lot number greater or lesser than or equal to a specified value.
Conclusion
In this article, we explored the core Vaadin UI components you need for a real app: locking things down with security rules, creating a reusable main layout with navigation, building a Grid with explicit columns, and wiring up filter inputs so the data refreshes smoothly.
The nice part is that layouts, routing, components, and data access patterns fit together naturally, and you can stay in Java end-to-end while still shipping a modern UI.
Next up, we’ll cover more Vaadin components you’ll use in real applications, like forms with validation, dialogs, tabs, notifications, and richer data handling patterns.







