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 chirper
3.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:dev
Now 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