Chirper: Build a Full-Stack mini Twitter Clone with Quarkus, Kafka, and Qute
Discover how to combine Quarkus Dev Services, Hibernate Panache, Kafka, and Qute to build a modern, real-time Java web app with no Docker setup required.
In this mini tutorial, you'll build a micro Twitter clone called Chirper using Quarkus, PostgreSQL, Qute templates, Kafka, and Hibernate Panache. You’ll learn how to:
Persist and display chirps (tweets)
Automatically start PostgreSQL and Kafka using Quarkus Dev Services
Publish events to Kafka when users chirp
Render server-side HTML using Qute templates
Use REST and form-based controllers
Build a clean, functional UI with HTML and CSS
Everything works out of the box using mvn quarkus:dev with no Docker setup required.
Why This Matters
Quarkus gives Java developers a modern, batteries-included stack for building cloud-native applications. With live reload, zero-config services, and first-class support for reactive programming, it's ideal for building full-featured apps like Chirper.
By the end of this tutorial, you’ll have a working app that looks and behaves like a basic Twitter clone. Complete with timelines, user profiles, likes, and Kafka-powered chirp events.
Project Setup
Start by generating your Quarkus project. (Or grab the project from my Github repository)
mvn io.quarkus.platform:quarkus-maven-plugin:3.23.2:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=chirper \
-DclassName="com.example.chirper.ChirperResource" \
-Dpath="/chirps" \
-Dextensions="quarkus-rest,rest-qute,hibernate-orm-panache,jdbc-postgresql,messaging-kafka"
cd chirper3.23.2 is the Quarkus version as of writing this post. You can either completely skip the version in the command or update to the specific one you need.
Configuration
In src/main/resources/application.properties, configure Dev Services:
# PostgreSQL
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true
# Kafka
mp.messaging.incoming.chirps.connector=smallrye-kafka
mp.messaging.incoming.chirps.topic=chirps
mp.messaging.incoming.chirps.value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
mp.messaging.outgoing.chirp-events.connector=smallrye-kafka
mp.messaging.outgoing.chirp-events.topic=chirps
mp.messaging.outgoing.chirp-events.value.serializer=org.apache.kafka.common.serialization.StringSerializer
# Qute
quarkus.qute.dev-mode.type-check-exclude=.*
Define Your Data Model
User Entity
Create User.java:
@Entity
@Table(name = "users")
public class User extends PanacheEntity {
@Column(unique = true, nullable = false)
public String username;
@Column(nullable = false)
public String displayName;
public String bio;
@Column(nullable = false)
public LocalDateTime createdAt;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL)
public List<Chirp> chirps;
@PrePersist
void onCreate() {
createdAt = LocalDateTime.now();
}
public static User findByUsername(String username) {
return find("username", username).firstResult();
}
}Chirp Entity
Create Chirp.java:
@Entity
@Table(name = "chirps")
public class Chirp extends PanacheEntity {
@Column(nullable = false, length = 280)
public String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id", nullable = false)
public User author;
@Column(nullable = false)
public LocalDateTime createdAt;
public int likes = 0;
public int rechirps = 0;
@PrePersist
void onCreate() {
createdAt = LocalDateTime.now();
}
public static List<Chirp> findAllOrderedByDate() {
return list("ORDER BY createdAt DESC");
}
public static List<Chirp> findByAuthor(User author) {
return list("author", author);
}
}Build the Services
UserService
@ApplicationScoped
public class UserService {
@Transactional
public User createUser(String username, String displayName, String bio) {
var user = new User();
user.username = username;
user.displayName = displayName;
user.bio = bio;
user.persist();
return user;
}
public User findByUsername(String username) {
return User.findByUsername(username);
}
@Transactional
public User getOrCreateUser(String username) {
var user = findByUsername(username);
return user != null ? user : createUser(username, username, "New Chirper user!");
}
}ChirpService
@ApplicationScoped
public class ChirpService {
@Inject
@Channel("chirp-events")
Emitter<String> chirpEmitter;
@Transactional
public Chirp createChirp(User author, String content) {
if (content.length() > 280) throw new IllegalArgumentException("Chirp too long!");
var chirp = new Chirp();
chirp.author = author;
chirp.content = content;
chirp.persist();
chirpEmitter.send(String.format("New chirp by %s: %s", author.username, content));
return chirp;
}
public List<Chirp> getAllChirps() {
return Chirp.findAllOrderedByDate();
}
public List<Chirp> getChirpsByUser(User user) {
return Chirp.findByAuthor(user);
}
@Transactional
public void likeChirp(Long chirpId) {
var chirp = Chirp.findById(chirpId);
if (chirp != null) {
chirp.likes++;
chirp.persist();
}
}
}
Kafka Listener
@ApplicationScoped
public class ChirpEventListener {
private static final Logger LOG = Logger.getLogger(ChirpEventListener.class);
@Incoming("chirps")
public void handleChirpEvent(String event) {
LOG.infof("Received chirp event: %s", event);
}
}
Web Layer with Qute
ChirperResource
@Path("/")
@Produces(MediaType.TEXT_HTML)
public class ChirperResource {
@Inject Template index;
@Inject Template profile;
@Inject ChirpService chirpService;
@Inject UserService userService;
@GET
public TemplateInstance home() {
return index.data("chirps", chirpService.getAllChirps());
}
@POST
@Path("/chirp")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response postChirp(@FormParam("username") String username,
@FormParam("content") String content) {
var user = userService.getOrCreateUser(username);
chirpService.createChirp(user, content);
return Response.seeOther(URI.create("/")).build();
}
@POST
@Path("/like/{chirpId}")
public Response like(@PathParam("chirpId") Long chirpId) {
chirpService.likeChirp(chirpId);
return Response.seeOther(URI.create("/")).build();
}
@GET
@Path("/profile/{username}")
public TemplateInstance profile(@PathParam("username") String username) {
var user = userService.findByUsername(username);
if (user == null) throw new WebApplicationException(404);
return profile.data("user", user)
.data("chirps", chirpService.getChirpsByUser(user));
}
}Qute Templates
Grab them from the repository and place these under src/main/resources/templates/:
layout.html– the base layoutindex.html– timeline and formprofile.html– user page
Run It
mvn quarkus:devNow open http://localhost:8080
You should be able to:
Post chirps
Like chirps
View user profiles
See chirp events in your console logs (Kafka)
Browse the Dev UI: http://localhost:8080/q/dev
What's Next?
Add more features:
Write tests :)
User authentication (Keycloak, Quarkus OIDC)
Rechirps (retweets)
Replies and threads
Image upload support
WebSocket timeline updates
Search by keyword or hashtag
Wrap-Up
This Chirper app is more than just a Twitter clone. It's a simple blueprint for building modern, reactive, full-stack Java applications with Quarkus.
You’ve seen how:
Hibernate ORM and Panache simplify persistence
Qute delivers server-side rendering with live reload
Kafka integrates seamlessly via MicroProfile Reactive Messaging
Quarkus Dev Services eliminates boilerplate Docker setup
All from a single command: mvn quarkus:dev




