Hilla and Kafka. Part 2: Producing messages

René Wilby | Jun 26, 2025 min read

Article series

This is the second part of a small article series about how to build Hilla web apps for Apache Kafka.

In this blog post, we will learn how to produce Kafka messages from a user input in a Hilla frontend and a KafkaTemplate in the Hilla backend.

Setup Kafka and Hilla project

Please read the first blog post of this article series to find out how to set up a local Kafka Broker and how to consume messages from the reservations topic in the corresponding Hilla web app.

Add KafkaTemplate to Hilla backend

In order to produce a new message for the reservations topic, we will use an auto-configured KafkaTemplate. This requires some additional configuration in the application.properties in the Hilla backend:

# Configure the producer
spring.kafka.producer.client-id=reservation-producer-client
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer

We use Spring Kafka’s built-in JsonSerializer to serialize a Reservation object into a message for the topic reservations.

The auto-configured KafkaTemplate can now be used in a KafkaProducerService.

package de.rwi.hillakafkaexample.kafka;

import org.springframework.kafka.core.KafkaTemplate;

import de.rwi.hillakafkaexample.reservation.Reservation;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;

@BrowserCallable
@AnonymousAllowed
public class KafkaProducerService {

    private final KafkaTemplate<String, Reservation> kafkaTemplate;

    public KafkaProducerService(KafkaTemplate<String, Reservation> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void produce(Reservation reservation) {
        kafkaTemplate.send("reservations", reservation);
    }
}

The method produce will receive a new reservation from the frontend and pass it to the KafkaTemplate.

Add form to Hilla frontend

Next up, we extend the existing ReservationsView in the Hilla frontend.

import { ActionOnLostSubscription } from '@vaadin/hilla-frontend';
import { useSignal } from '@vaadin/hilla-react-signals';
import {
  Button,
  DatePicker,
  Dialog,
  Grid,
  GridColumn,
  HorizontalLayout,
  Icon,
  Notification,
  TextField,
  VerticalLayout,
} from '@vaadin/react-components';
import { ViewToolbar } from 'Frontend/components/ViewToolbar';
import Reservation from 'Frontend/generated/de/rwi/hillakafkaexample/reservation/Reservation';
import { KafkaConsumerService, KafkaProducerService } from 'Frontend/generated/endpoints';
import { useEffect } from 'react';
import { useForm } from '@vaadin/hilla-react-form';
import ReservationModel from 'Frontend/generated/de/rwi/hillakafkaexample/reservation/ReservationModel';

export default function ReservationsView() {
  const reservations = useSignal<Reservation[]>();
  const subscriptionNotificationOpened = useSignal<boolean>(false);
  const subscriptionNotificationMessage = useSignal<string | undefined>(undefined);
  const newReservationDialogOpened = useSignal<boolean>(false);
  const { model, field, clear, submit, invalid } = useForm(ReservationModel, {
    onSubmit: async (reservation: Reservation) => {
      await KafkaProducerService.produce(reservation);
      closeNewReservationDialog();
    },
  });

  useEffect(() => {
    const reservationSubscription = KafkaConsumerService.getLatestReservation()
      .onNext((reservation: Reservation) => {
        reservations.value = [...(reservations.value ?? []), reservation];
      })
      .onError((message: string) => {
        subscriptionNotificationOpened.value = true;
        subscriptionNotificationMessage.value = message;
      })
      .onSubscriptionLost(() => ActionOnLostSubscription.RESUBSCRIBE);
    return () => reservationSubscription.cancel();
  }, []);

  const closeSubscriptionNotification = () => {
    subscriptionNotificationOpened.value = false;
    subscriptionNotificationMessage.value = undefined;
  };

  const closeNewReservationDialog = () => {
    newReservationDialogOpened.value = false;
    clear();
  };

  const retrySubscription = () => {
    window.location.reload();
  };

  return (
    <VerticalLayout theme="margin">
      <ViewToolbar title="Reservations" />
      <Grid items={reservations.value}>
        <GridColumn path={'id'} />
        <GridColumn path={'date'} />
        <GridColumn path={'customer'} />
      </Grid>
      <HorizontalLayout theme="margin" className="self-end">
        <Button theme="primary" onClick={() => (newReservationDialogOpened.value = true)}>
          New reservation
        </Button>
      </HorizontalLayout>
      <Dialog
        headerTitle={'New reservation'}
        opened={newReservationDialogOpened.value}
        onOpenedChanged={({ detail }) => {
          newReservationDialogOpened.value = detail.value;
        }}
        footer={
          <>
            <Button onClick={closeNewReservationDialog}>Cancel</Button>
            <Button theme="primary" disabled={invalid} onClick={submit}>
              Add
            </Button>
          </>
        }>
        <VerticalLayout style={{ alignItems: 'stretch', width: '18rem', maxWidth: '100%' }}>
          <TextField label="ID" {...field(model.id)} />
          <DatePicker label="Date" {...field(model.date)} />
          <TextField label="Customer" {...field(model.customer)} />
        </VerticalLayout>
      </Dialog>
      <Notification
        theme="warning"
        duration={0}
        position="middle"
        opened={subscriptionNotificationOpened.value}
        onOpenedChanged={(event) => {
          subscriptionNotificationOpened.value = event.detail.value;
        }}>
        <HorizontalLayout theme="spacing" style={{ alignItems: 'center' }}>
          <div>{subscriptionNotificationMessage.value ?? 'Failed to subscribe to reactive reservation endpoint'}</div>
          <Button theme="tertiary-inline" style={{ marginLeft: 'var(--lumo-space-xl)' }} onClick={retrySubscription}>
            Retry
          </Button>
          <Button theme="tertiary-inline icon" onClick={closeSubscriptionNotification} aria-label="Close">
            <Icon icon="lumo:cross" />
          </Button>
        </HorizontalLayout>
      </Notification>
    </VerticalLayout>
  );
}

We add a new Dialog component that contains a form to create a new reservation. The form is build using the useForm hook from Hilla. It provides all the necessary props and functions for things like validation, submitting and clearing the form (please check out the corresponding guide in the docs for more details). The onSubmit callback will call the produce method of the KafkaProducerService using the validated reservation that was entered in the form.

Manual testing

Now it’s time to see the additions in action. Ensure that the Kafka broker is running (docker compose -f docker/docker-compose.yaml up), start the Hilla app (for example with ./mvnw spring-boot:run) and visit http://localhost:8080 in your browser. Click the New reservation button below the Grid component and add a new reservation in the form in the dialog.

New reservation

The submitted reservation should appear immediately in the Grid component, once the dialog has been closed.

Summary

Thanks to Spring Kafka and Spring Boot’s auto-configuration capabilities, it’s simple to add a new message to a Kafka topic. In conjunction with Hilla’s type-safe browser callable services and first-class form support, the necessary additions to the frontend are also easy to implement.

The next article of this series will show how to consume and produce Kafka messages when you are using Kafka streams.

You can find the source code of the shown example Hilla app at GitHub.

Image Credits: