Hilla Documentation

Hilla Basics Tutorial

This tutorial teaches you the core concepts of Hilla while you build a full-stack application for managing to-do items. After completing the tutorial, you are ready to start experimenting on your own or continue to more advanced tutorials.

What You Need

Visual Studio Code is used in this tutorial. See the setup instructions on YouTube. You can use any IDE that supports Java and TypeScript development.

Create a Hilla Project

Use the Vaadin CLI to create a new project:

npx @vaadin/cli init --preset hilla-tutorial hilla-todo

The pre-configured starter project includes:

  • An empty Todo view

  • H2 database and Spring Data JPA dependencies

Importing the Hilla Project Into VS Code

Unzip the downloaded file and open the project in your IDE. The instructions in this tutorial assume you use VS Code.

Open the project by either:

  • navigating to the project folder and running code . (note the period), or

  • choosing File > Open…​ in VS Code and selecting the project folder.

We recommend installing the following plugins in VS Code for an optimal development experience:

VS Code should automatically suggest these for you when you open the project.

Project Structure and Architecture

Hilla projects are based on Spring Boot and use Maven for project management.

Hilla project structure

The two important folders be aware of are:


This is where your views and frontend code live.


This is where your Java backend code lives.

The key files in a Hilla application are:


The project configuration file, which defines dependencies.


The bootstrap page. You do not usually need to change this.


Defines routing.


Runs the Spring Boot application.

Defining the Data Model and Service Layer

Begin by setting up the data model and services for accessing the database.

You can do this in two steps:

  1. Define an entity.

  2. Create a repository for accessing the database.

This tutorial shows how to use an in-memory H2 database and JPA for persistence. The starter you downloaded already includes the needed dependencies in pom.xml.

Define a JPA entity class for the data model, by creating a new file, Todo.java, in src/main/java/com/example/application with the following content:

package com.example.application;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.validation.constraints.NotBlank;

@Entity 1
public class Todo {

  private Integer id;

  private boolean done = false;
  @NotBlank 2
  private String task;

  public Todo() {}

  public Todo(String task) {
    this.task = task;

  public Integer getId() {
    return id;

  public void setId(Integer id) {
    this.id = id;

  public boolean isDone() {
    return done;

  public void setDone(boolean done) {
    this.done = done;

  public String getTask() {
    return task;

  public void setTask(String task) {
    this.task = task;
  1. Turn the class into a JPA entity with an @Entity annotation.

  2. Add a @NotBlank Java bean validation annotation to enforce validity both in the view and on the server.

Next, create a repository for accessing the database. You only need to provide an interface with type information; Spring Data takes care of the implementation.

Create a new file, TodoRepository.java, in src/main/java/com/example/application, with the following contents:

package com.example.application;

import org.springframework.data.jpa.repository.JpaRepository;

public interface TodoRepository extends JpaRepository<Todo, Integer> {


You now have all the necessary backend code in place to start building a UI.

Run the project from the command line with the following command:


The first time you run the application, it may take up to a few minutes, as Hilla downloads all the dependencies and builds a frontend bundle. Subsequent builds do not download dependencies, so that they are much faster.

When the build has finished, you should see the application running on http://localhost:8080.

Create a Typed Server Endpoint

One of the key features of Hilla is type-safe server access through endpoints. When you define an @Endpoint, Hilla creates the needed REST-like endpoints, secures them, and generates TypeScript interfaces for all the data types and public methods used. Having full-stack type safety helps you stay productive through autocomplete and helps guard against accidentally breaking the UI when the data model changes on the server.

Create a new file, TodoEndpoint.java, in src/main/java/com/example/application:

package com.example.application;

import java.util.List;

import com.vaadin.flow.server.auth.AnonymousAllowed;
import dev.hilla.Endpoint;
import dev.hilla.Nonnull;

@Endpoint 1
@AnonymousAllowed 2
public class TodoEndpoint {
 private TodoRepository repository;

 public TodoEndpoint(TodoRepository repository) { 3
   this.repository = repository;

 public @Nonnull List<@Nonnull Todo> findAll() { 4
   return repository.findAll();

 public Todo save(Todo todo) {
   return repository.save(todo);
  1. Annotating a class with @Endpoint exposes it as a service for client-side views. All public methods of an endpoint are callable from TypeScript.

  2. By default, endpoint access requires an authenticated user. @AnonymousAllowed enables access for anyone. See Configuring Security for more information on endpoint security.

  3. Use Spring to automatically inject the TodoRepository dependency for database access.

  4. Using the @Nonnull annotation ensures that the TypeScript generator does not interpret these values as possibly undefined.

Save the file and ensure the change is loaded. You should see log output from the reload in the console. It should end with the message, Frontend compiled successfully.

If you did not have the server running, or if something failed, (re)start the server with the mvn command.

Building the Todo View Component

Hilla uses the Lit library for client-side views. Lit is a lightweight and highly performant library for building reactive components with declarative templates.

You can learn the basics of Lit in the article Lit basics.

Next, create a view for adding and viewing to-do items.

Open frontend/views/todo/todo-view.ts and replace its contents with the following:

import { html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@vaadin/vaadin-text-field';
import '@vaadin/vaadin-button';
import '@vaadin/vaadin-checkbox';
import { Binder, field } from '@hilla/form';
import Todo from 'Frontend/generated/com/example/application/Todo';
import TodoModel from 'Frontend/generated/com/example/application/TodoModel';
import { TodoEndpoint } from 'Frontend/generated/endpoints';
import { View } from '../view';

@customElement('todo-view') 2
export class TodoView extends View { 3
  1. Import the UI components, helpers, and generated TypeScript models required for building the view.

  2. Register the new component with the browser. This makes it available as <todo-view>. The routing in index.ts is already set up to show it when you navigate to the application.

  3. Define the component class that extends from View.

Defining the View State

Inside the TodoView class, define the view state as follows:

 private todos: Todo[] = []; 1
 private binder = new Binder(this, TodoModel); 2
  1. The list of Todo items is private and decorated with @state(), so Lit observes it for changes.

  2. A Hilla Binder is used to handle the form state for creating new Todo objects. TodoModel is automatically generated by Hilla. This describes the data types and validations that Binder needs. Read more about forms in Binding Data to Forms.

Defining CSS

Define some padding on the view. Web Components have a default display value of inline, which is rarely what you want. Set it to block instead. You can define styles in the themes/hilla-todo/styles.css file. Read more about CSS in Styling With CSS.

 todo-view {
  display: block;
  padding: var(--lumo-space-m) var(--lumo-space-l); 1
  1. The padding property is defined using the spacing properties to be consistent with the rest of the app.

Defining the HTML Template

Define a render() method that returns an html template literal inside the class.

render() {
 return html`


Add the following code within the html template:

return html`
   <div class='form'>
    ></vaadin-text-field> 1
      @click=${this.createTodo 2}
      ?disabled=${this.binder.invalid 3}
  1. The Text Field component is bound to the task property of a Todo using ${field(this.binder.model.task)}. You can read more about forms in Binding Data to Forms.

  2. The click event of the Add button is bound to the createTodo() method.

  3. The button is disabled if the form is invalid.

Right underneath the previous <div>, add the following code:

return html`
   <div class="todos">
    ${this.todos.map((todo) => html` 1
        <div class="todo">
            ?checked=${todo.done 2}
            @checked-changed=${(e: CustomEvent) => 3
              this.updateTodoState(todo, e.detail.value)}
  1. The existing todo items are shown by mapping the todos array to Lit templates. The template for a single Todo contains a checkbox and the task text.

  2. Bind the checked boolean attribute to the done property on the todo.

  3. Call the updateTodoState() method, with the todo and the new value, whenever the checked value changes.

Updating the View State and Calling the Backend

Below the render() method in the TodoView class, add a connectedCallback() lifecycle callback to initialize the view when it is attached to the DOM.

async connectedCallback() { 1
 super.connectedCallback(); 2
 this.todos = await TodoEndpoint.findAll(); 3
  1. Use an async function to make it easier to handle asynchronous code.

  2. Remember to call the superclass method.

  3. The getTodos() method is automatically generated by Hilla based on the method in TodosEndpoint.java. The method was imported in the head of the file. The await keyword waits for the server response without blocking the UI.

Below the connectedCallback(), add another method to handle the creation of a new Todo.

async createTodo() {
 const createdTodo = await this.binder.submitTo(TodoEndpoint.save); 1
 if (createdTodo) {
   this.todos = [...this.todos, createdTodo]; 3
   this.binder.clear(); 3
  1. Use binder to submit the form to TodoEndpoint. The Binder validates the input before posting it, and the server revalidates it.

  2. Update the state with a new array that includes the saved Todo. This re-renders the view.

  3. Clear the form input.

Finally, add a method for updating the todo state right below createTodo():

updateTodoState(todo: Todo, done: boolean) {
 todo.done = done;
 const updatedTodo = { ...todo }; 1
 this.todos = this.todos.map((t) => (t.id === todo.id ? updatedTodo : t)); 2
 TodoEndpoint.save(updatedTodo); 3
  1. Create a new Todo with the updated done state.

  2. Update the local todos array with the new state. The map operator creates a new array where the changed todo is swapped out. This re-renders the view.

  3. Save the updated todo to the server.

Complete View Code

The completed view code is as follows:

import { html } from 'lit';

import { customElement, state } from 'lit/decorators.js';

import '@vaadin/vaadin-text-field';
import '@vaadin/vaadin-button';
import '@vaadin/vaadin-checkbox';
import { Binder, field } from '@hilla/form';
import Todo from 'Frontend/generated/com/example/application/Todo';
import TodoModel from 'Frontend/generated/com/example/application/TodoModel';
import { TodoEndpoint } from 'Frontend/generated/endpoints';
import { View } from '../view';

export class TodoView extends View {
  private todos: Todo[] = [];
  private binder = new Binder(this, TodoModel);

  render() {
    return html`
      <div class="form">
        <vaadin-text-field ${field(this.binder.model.task)}></vaadin-text-field>
        <vaadin-button theme="primary" @click=${this.createTodo} ?disabled=${this.binder.invalid}>
      <div class="todos">
          (todo) => html`
            <div class="todo">
                @checked-changed=${(e: CustomEvent) => this.updateTodoState(todo, e.detail.value)}></vaadin-checkbox>

  async connectedCallback() {
    this.todos = await TodoEndpoint.findAll();

  async createTodo() {
    const createdTodo = await this.binder.submitTo(TodoEndpoint.save);
    if (createdTodo) {
      this.todos = [...this.todos, createdTodo];

  updateTodoState(todo: Todo, done: boolean) {
    todo.done = done;
    const updatedTodo = { ...todo };
    this.todos = this.todos.map((t) => (t.id === todo.id ? updatedTodo : t));

Run the Completed Application

Start your server with the ./mvnw command if you do not already have it running.

Open http://localhost:8080 in your browser. You should now have a fully functional to-do application. Notice that when you refresh the browser, it keeps the same todo items, as they are persisted in the database.

Optional: Deploy the Application to Heroku

The following steps are optional. You can follow them to deploy your application to Heroku, a cloud deployment platform that offers a free tier that does not require a credit card.

Preparing the Application for Production

It’s important to build a separate production-optimized version of the application before deploying it. In development mode, Hilla has a live-reload widget, debug logging, and uses a quick, but unoptimized, frontend build that includes source maps for easy debugging. Unoptimized frontend bundles can contain several megabytes of JavaScript.

The pom.xml build includes a production profile configuration that prepares an optimized build that’s ready for production.

Using a PostgreSQL Database in Production

During development, the application has used an in-memory H2 database. It is convenient and works well for a single user. In production, you want to use something more robust and persistent. Heroku’s free tier supports PostgreSQL, so can configure your application to use that.

First, add the PostgreSQL dependency in the production profile of pom.xml:

 <!-- Omitted -->

Next, configure how JPA should handle schema generation. Add the following two properties to the end of application.properties.

Avoid data loss

This setup recreates the database on every deployment. If you are working with real data, you should use ddl-auto=none and instead use a database migration tool like Liquibase or Flyway so you can evolve the database schema without losing data.

Building a Production-Optimized JAR

Build the application with the production profile:

mvn clean package -Pproduction

This builds a production-optimized JAR file in the target folder.

Creating a Heroku Account and Installing Heroku CLI

Complete the following steps to create a Heroku account and install the Heroku CLI.

  1. Go to https://signup.heroku.com/, create a new account, and verify your email.

  2. Go to https://devcenter.heroku.com/articles/heroku-cli and follow the instructions for installing the CLI on your operating system.

Deploying a Hilla Application to Heroku

Use the Heroku CLI to create and deploy your application.

  1. Log in:

    heroku login
  2. Configure the correct Java version:

    echo "java.runtime.version=11" > system.properties
  3. Install the Heroku Java plugin:

    heroku plugins:install java
  4. Create a new app. Replace APPNAME with a name of your choice. APPNAME is part of the URL, like https://APPNAME.herokuapp.com, so choose a name that’s unique and easy to remember.

    heroku create APPNAME
  5. Create a secret key for your application:

    heroku config:set APP_SECRET=$(openssl rand -base64 32)
  6. Enable the PostgreSQL plugin for the newly created app:

    heroku addons:create heroku-postgresql -a APPNAME
  7. Deploy the production-optimized JAR file you created in the previous section.

    heroku deploy:jar target/fusioncrmtutorial-1.0-SNAPSHOT.jar -a APPNAME
  8. Open the application in your browser.

    heroku open
  9. View the application logs and see if anything goes wrong.

    heroku logs --tail

Congratulations on finishing the tutorial!

The following are some helpful links for you to continue your learning:

If you get stuck or need help, please reach out to the Hilla Community on Discord.