Stateful Wizards in a Stateless World: Building Multi-Step Forms with Quarkus
A Hands-On Guide to Session Management, Validation, and CSRF Protection in Modern Cloud-Native Java Web Applications
Imagine guiding your users through a carefully orchestrated flow: Address input, order selection, some additional info, and finally a review. A full-blown multi-step wizard. No flaky JavaScript, no state flying around in query parameters, and no security gaps. Just server-side sanity.
In this tutorial, we’ll build a wizard form in Quarkus from scratch. Complete with session-scoped state, validation, CSRF protection, and a clean Qute-powered UI. You’ll end up with a production-ready foundation that can be reused for onboarding flows, checkout processes, or multi-stage registrations.
Let’s get into it.
Project Setup
Spin up your project with all the tools you’ll need:
quarkus create app org.acme:wizard-app \
--extension="rest-qute,rest-jackson,rest-csrf,hibernate-validator,quarkus-hibernate-reactive-panache,quarkus-reactive-pg-client,cache,scheduler" \
--no-code
cd wizard-app
Why these extensions?
quarkus-rest-qute
: To render our form views. (read more)quarkus-rest-csrf
: Handles CSRF token generation and validation. (read more)quarkus-hibernate-validator
: For validating form input using Jakarta Bean Validation. (read more)quarkus-hibernate-reactive-panache
: For simplified persistence with Reactive support. (read more)quarkus-reactive-pg-client
: To include the PostgreSQL database as dev service. (read more)quarkus-cache
: Caching mechanisms to speed up the application (read more)quarkus-scheduler
: To implement cleanup routines (read more)
The Maven command and the Quarkus CLI both accept the “short” form of extension names. You can skipp the “quarkus-” when adding them or using them.
I’ve shortened the code of the Java classes in this post to reflect the most important parts. You can get the full, working example from my Github repository.
Understanding State Management in Enterprise-Grade Wizard Flows
In enterprise applications, managing state across a multi-step form or wizard isn’t just about passing a few values between pages. It’s about ensuring security, consistency, and scalability across user sessions and potentially across multiple service instances.
Several patterns exist for handling wizard state:
Client-side storage: using hidden fields or browser storage. This is simple but risky. The data is exposed to the user, vulnerable to tampering, and unsuitable for anything sensitive.
Server-side storage: either traditional session mechanisms (
HttpSession
in Jakarta EE), in-memory stores like Redis, or persistent databases.Hybrid models: combine server- and client-side strategies with explicit tokens or identifiers.
Now here’s where Quarkus takes a deliberate stance.
Unlike traditional Java frameworks, Quarkus doesn’t offer HttpSession
out of the box. This isn’t an omission. It’s a design decision aligned with Quarkus’s modern architecture: stateless, reactive, and cloud-native by default. Relying on sticky sessions in a Kubernetes cluster breaks scalability, and blocking server threads to wait on session state goes against reactive principles.
Instead, Quarkus encourages you to own your state explicitly. That can mean storing state in an external, distributed store such as Redis or PostgreSQL. These approaches work well with non-blocking I/O and don’t compromise on scalability or fault tolerance.
To show you an example of high-performance, horizontally scalable enterprise setup, I combined Reactive Hibernate with Panache for persistent wizard state, optionally enhanced with caching. This gives you:
Non-blocking DB access, ideal for reactive workloads.
Strong data consistency guarantees.
Seamless failover and scale-out support.
Keeping Track of Wizard State
We need a way to retain user inputs across steps. We'll define the actual wizard state object, its JPA entity mapping, and a service to manage it in the database with caching.
Here’s our wizzard state: Create a package for it to live in and org.acme.wizard.model
and create the file WizardState.java
public class WizardState implements Serializable {
private AddressForm addressForm;
private OrderForm orderForm;
private AdditionalInfoForm additionalInfoForm;
private int currentStep;
public WizardState() {
// Initialize with empty forms to avoid NullPointerExceptions
this.addressForm = new AddressForm();
this.orderForm = new OrderForm();
this.additionalInfoForm = new AdditionalInfoForm();
this.currentStep = 1;
}
Each form step will bind to its respective class. Let’s create the Entity next:
Create the file WizardStateEntity.java
@Entity
@Table(name = "wizard_state")
public class WizardStateEntity {
@Id
@GeneratedValue
@UuidGenerator
String id;
@JdbcTypeCode(SqlTypes.JSON)
public WizardState wizardState; // The serialized WizardState
@Column(name = "current_step")
public int currentStep;
@Column(name = "created_at")
public Instant createdAt;
@Column(name = "updated_at")
public Instant updatedAt;
@Column(name = "expires_at")
public Instant expiresAt; // For scheduled cleanup
@PrePersist
protected void onCreate() {
// State expires in 2 hours
createdAt = Instant.now();
updatedAt = Instant.now();
expiresAt = Instant.now().plus(Duration.ofHours(2));
}
@PreUpdate
protected void onUpdate() {
// Re-extend expiration on update
updatedAt = Instant.now();
expiresAt = Instant.now().plus(Duration.ofHours(2));
}
}
Create the file WizardStateRepository.java
(Panache Repository) in the store
package.
@ApplicationScoped
public class WizardStateRepository implements PanacheRepository<WizardStateEntity> {
public Uni<WizardStateEntity> findById(String id) {
return find("id", id).firstResult();
}
public Uni<Long> deleteById(String id) {
return delete("id", id);
}
}
Create the file WizardStateStore.java
(Service Layer for Database & Caching) in the store
package.
WizardStateStore is the service class responsible for managing the persistence and retrieval of wizard state objects in our application. It uses a repository for database access and leverages caching for performance.
@ApplicationScoped
public class WizardStateStore {
@Inject
WizardStateRepository repository;
private static final String CACHE_NAME = "wizard-states";
@CacheResult(cacheName = CACHE_NAME)
public Uni<Optional<WizardState>> getWizardState(String wizardIdStr) {
return Panache.withTransaction(() -> repository.findById(wizardIdStr))
.onItem().transform(entity -> {
if (entity == null) {
return Optional.<WizardState>empty();
}
return Optional.of(entity.getWizardState());
})
.onFailure()
.invoke(e -> Log.errorf(e, "Error fetching wizard state from DB for ID: %s", wizardIdStr));
}
@CacheInvalidate(cacheName = CACHE_NAME)
public Uni<String> saveWizardState(String wizardIdStr, WizardState state) {
if (wizardIdStr == null || wizardIdStr.isEmpty()) {
// Parse the provided ID
return createNewWizardState(state);
}
return Panache.withSession(() -> repository.findById(wizardIdStr)
.onItem().transformToUni(entity -> {
WizardStateEntity entityToPersist = entity != null ? entity : new WizardStateEntity();
if (entity == null) {
entityToPersist.setId(wizardIdStr); // Set the ID if new entity
}
entityToPersist.setWizardState(state); // This sets currentStep and also sets wizardState
return repository.persist(entityToPersist); // Returns Uni<Void> for persist
})
.replaceWith(wizardIdStr) // Chain to return the wizardId string
)
.onFailure().invoke(e -> Log.errorf(e, "Failed to save wizard state for ID: %s", wizardIdStr));
}
@WithTransaction
public Uni<String> createNewWizardState(WizardState initialState) {
WizardStateEntity entity = new WizardStateEntity();
entity.setWizardState(initialState);
return repository.persist(entity)
.onItem().invoke(persistedEntity -> Log.infof("createNewWizardState %s", persistedEntity.getId()))
.onItem().transform(persistedEntity -> persistedEntity.getId());
}
@WithTransaction
@CacheInvalidate(cacheName = CACHE_NAME)
public Uni<Void> deleteWizardState(String wizardIdStr) {
return Panache.withSession(() -> repository.deleteById(wizardIdStr))
.onItem().invoke(deleted -> {
if (deleted != null && deleted > 0)
Log.debugf("Deleted wizard state for ID: %s from DB.", wizardIdStr);
else
Log.warnf("Wizard state with ID: %s not found for deletion.", wizardIdStr);
})
.onFailure().invoke(e -> Log.errorf(e, "Error deleting wizard state for ID: %s", wizardIdStr))
.replaceWithVoid();
}
}
Note the two different ways we are using transactions here. You can annotate a CDI business method that returns Uni
with the @WithTransaction
annotation. The method will be intercepted and the returned Uni
is triggered within a transaction boundary. Alternatively, you can use the Panache.withTransaction()
method for the same effect.
Lastly we need to implement some scheduled cleanup. Create the file WizardStateCleanup.java
in the cleanup
package.
@ApplicationScoped
public class WizardStateCleanup {
@Inject
WizardStateRepository wizardStateRepository;
// Run every 30 minutes to clean up expired wizard states
@Scheduled(every = "30m")
@WithTransaction
public Uni<Void> deleteExpiredWizardStates() {
Instant now = Instant.now();
// Panache delete method takes a query string and parameters
return wizardStateRepository.delete("expiresAt <= ?1", now)
.invoke(deletedCount -> {
if (deletedCount > 0) {
Log.infof("Cleaned up %d expired wizard states.", deletedCount);
}
})
.replaceWithVoid();
}
}
The Forms
We'll define data classes for each step of our wizard, annotated with Jakarta Bean Validation constraints. These will also hold the state of the form.
Create the package for the forms first: org.acme.wizard.forms
AddressForm
public class AddressForm implements Serializable {
@RestForm
@NotBlank(message = "Street is required")
@Size(min = 3, max = 100, message = "Street must be between 3 and 100 characters")
public String street;
@RestForm
@NotBlank(message = "City is required")
@Size(min = 2, max = 50, message = "City must be between 2 and 50 characters")
public String city;
@RestForm
@NotBlank(message = "Zip Code is required")
@Size(min = 5, max = 10, message = "Zip Code must be between 5 and 10 characters")
public String zipCode;
// Getters and setters omitted for brevity.
}
OrderForm
public class OrderForm implements Serializable {
@RestForm
@NotNull(message = "Quantity is required")
@Min(value = 1, message = "Quantity must be at least 1")
public Integer quantity;
@RestForm
@NotBlank(message = "Product name is required")
public String productName;
// Getters and setters omitted for brevity.
}
AdditionalInfoForm
public class AdditionalInfoForm implements Serializable {
@RestForm
@Size(max = 200, message = "Additional comments cannot exceed 200 characters")
public String comments;
// Getters and setters omitted for brevity.
}
Each one gets validated server-side using the Bean Validation API with Hibernate Validator.
Building the Wizard Controller
We’ll drive the whole thing from a REST resource. Every step gets a GET
method to render the page and a POST
to process the input.
Here's a slice of WizardResource.java
:
@POST
@Path("/step2")
public Uni<TemplateInstance> processStep2(@BeanParam OrderForm form, @RestForm("wizardId") String wizardId) {
return wizardStateStore.getWizardState(wizardId)
.onItem().transformToUni(optionalState -> {
if (optionalState.isEmpty()) {
// Redirect to error if wizard state is missing
return Uni.createFrom().item(Templates.error());
}
WizardState state = optionalState.get();
Set<ConstraintViolation<OrderForm>> violations = validator.validate(form);
if (!violations.isEmpty()) {
Set<String> errors = violations.stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.toSet());
return Uni.createFrom().item(Templates.step2(form, errors, 2, TOTAL_STEPS, wizardId));
}
state.setOrderForm(form);
state.setCurrentStep(3);
return wizardStateStore.saveWizardState(wizardId, state)
.onItem().transform(savedWizardId -> Templates.step3(state.getAdditionalInfoForm(), null, 3,
TOTAL_STEPS, savedWizardId));
});
}
Each step follows this pattern:
Validate input.
If errors exist, re-render with messages.
Otherwise, store the data in
WizardState
and move on.
The final /submit
endpoint logs the collected data and resets the session.
The Templates
These templates will define the UI for each wizard step, including the visual flow indicator and form fields. Grab the full templates from my repository!
Create src/main/resources/templates/WizardResource/step_indicator.html
:
<nav>
<ul class="step-indicator">
{#for i in totalSteps}
<li class="{#if i == currentStep}active{#else if i < currentStep}completed{/if}">
Step {i}
</li>
{/for}
</ul>
</nav>
<!-- styles go here -->
Create src/main/resources/templates/WizardResource/step1.html
:
<h1>Wizard - Step 1: Address Information</h1>
{#include WizardResource/step_indicator.html}
{#currentStep = currentStep}
{#totalSteps = totalSteps}
{/include}
{#if errors}
<div class="error">
<p>Please correct the following errors:</p>
<ul>
{#for error in errors}
<li>{error}</li>
{/for}
</ul>
</div>
{/if}
<form action="/wizard/step1" method="post">
<div class="form-group">
<label for="street">Street:</label>
<input type="text" id="street" name="street" value="{form.street ?: ''}">
</div>
<div class="form-group">
<label for="city">City:</label>
<input type="text" id="city" name="city" value="{form.city ?: ''}">
</div>
<div class="form-group">
<label for="zipCode">Zip Code:</label>
<input type="text" id="zipCode" name="zipCode" value="{form.zipCode ?: ''}">
</div>
<input type="hidden" name="wizardId" value="{wizardId}">
<input type="hidden" name="{inject:csrf.parameterName}" value="{inject:csrf.token}" />
<button type="submit">Next</button>
</form>
Create src/main/resources/templates/WizardResource/step2.html
:
<h1>Wizard - Step 2: Order Details</h1>
{#include WizardResource/step_indicator.html}
{#currentStep = currentStep}
{#totalSteps = totalSteps}
{/include}
{#if errors}
<div class="error">
<p>Please correct the following errors:</p>
<ul>
{#for error in errors}
<li>{error}</li>
{/for}
</ul>
</div>
{/if}
<form action="/wizard/step2" method="post">
<div class="form-group">
<label for="productName">Product Name:</label>
<input type="text" id="productName" name="productName" value="{form.productName ?: ''}">
</div>
<div class="form-group">
<label for="quantity">Quantity:</label>
<input type="number" id="quantity" name="quantity" value="{form.quantity ?: ''}">
</div>
<input type="hidden" name="wizardId" value="{wizardId}">
<input type="hidden" name="{inject:csrf.parameterName}" value="{inject:csrf.token}" />
<button type="button" class="back" onclick="location.href='/wizard/step/1/{wizardId}'">Back</button>
<button type="submit">Next</button>
</form>
Create src/main/resources/templates/WizardResource/step3.html
:
<h1>Wizard - Step 3: Additional Information</h1>
{#include WizardResource/step_indicator.html}
{#currentStep = currentStep}
{#totalSteps = totalSteps}
{/include}
{#if errors}
<div class="error">
<p>Please correct the following errors:</p>
<ul>
{#for error in errors}
<li>{error}</li>
{/for}
</ul>
</div>
{/if}
<form action="/wizard/step3" method="post">
<div class="form-group">
<label for="comments">Additional Comments (Optional):</label>
<textarea id="comments" name="comments" rows="5">{form.comments ?: ''}</textarea>
</div>
<input type="hidden" name="wizardId" value="{wizardId}">
<input type="hidden" name="{inject:csrf.parameterName}" value="{inject:csrf.token}" />
<button type="button" class="back" onclick="location.href='/wizard/step/2/{wizardId}'">Back</button>
<button type="submit">Next</button>
</form>
Create src/main/resources/templates/WizardResource/step4.html
:
<h1>Wizard - Step 4: Review Your Information</h1>
{#include WizardResource/step_indicator.html}
{#currentStep = currentStep}
{#totalSteps = totalSteps}
{/include}
<div class="review-section">
<h2>Address Information</h2>
<div class="review-item"><strong>Street:</strong> {state.addressForm.street}</div>
<div class="review-item"><strong>City:</strong> {state.addressForm.city}</div>
<div class="review-item"><strong>Zip Code:</strong> {state.addressForm.zipCode}</div>
</div>
<div class="review-section">
<h2>Order Details</h2>
<div class="review-item"><strong>Product Name:</strong> {state.orderForm.productName}</div>
<div class="review-item"><strong>Quantity:</strong> {state.orderForm.quantity}</div>
</div>
<div class="review-section">
<h2>Additional Information</h2>
<div class="review-item"><strong>Comments:</strong> {state.additionalInfoForm.comments ?: 'N/A'}</div>
</div>
<form action="/wizard/submit" method="post">
<input type="hidden" name="wizardId" value="{wizardId}">
<input type="hidden" name="{inject:csrf.parameterName}" value="{inject:csrf.token}" />
<button type="button" class="back" onclick="location.href='/wizard/step/3/{wizardId}'">Back</button>
<button type="submit">Complete Order</button>
</form>
Create src/main/resources/templates/WizardResource/success.html
:
<h1>Congratulations!</h1>
<p>Your wizard process has been completed successfully.</p>
<p><a href="/wizard/start">Start a new wizard</a></p>
Create src/main/resources/templates/WizardResource/error.html
:
<h1>There was an error. !</h1>
<p>Please <a href="/wizard/start">start a new wizard</a></p>
Explanation of Qute Templates:
{#include WizardResource/step_indicator.html}
: This includes our reusable step indicator component, passing thecurrentStep
andtotalSteps
parameters.{#if errors}
: Displays validation error messages if theerrors
set is not null or empty.value="{form.fieldName ?: ''}"
: This is crucial for pre-filling the form.form.fieldName
retrieves the value from theAddressForm
,OrderForm
, orAdditionalInfoForm
objects passed to the template. The?: ''
handles cases where the field might be null (e.g., on initial load).CSRF Protection (
<input type="hidden" name="{inject:csrf.parameterName}" value="{inject:csrf.token}">
):Quarkus's
rest-csrf
extension automatically exposes a CDI Beancsrf
object that can directly be used in Qute templates.csrf.parameterName
provides the name of the header/parameter where the CSRF token is expected (by default,csrf-token
).csrf.token
provides the actual, dynamically generated CSRF token for the current session.By including this hidden input, Quarkus will validate the token on form submissions, preventing CSRF attacks. If the token is missing or invalid, the request will be rejected.
"Back" Buttons: These are simple
button
elements with anonclick
event that redirects to the previous step's GET endpoint. This allows users to go back and modify previous steps, and thanks to the session-scopedWizardState
, their previously entered data will be pre-filled.
Configuration
Outside of the database and some smaller logging configurations for Hibernate Panache, there is little to do in the application.properties
:
# Database Configuration (PostgreSQL)
quarkus.datasource.db-kind=postgresql
# Hibernate ORM with Panache
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true # Set to true to see SQL queries in console
quarkus.hibernate-orm.log.format-sql=true # Format SQL queries for better readability
Running the Wizard
Fire it up:
./mvnw quarkus:dev
Navigate to http://localhost:8080/wizard/start
.
Go through the steps. Try invalid input. See the errors. Go back and forth—your data stays put. At the end, review everything and hit "Complete Order".
Boom. Your data’s handled, session’s reset, and you’re back to the start.
Final Words
This little project demonstrates how Quarkus handles multi-step forms like a pro. The session scope is lightweight and reliable, validation is declarative and powerful, CSRF protection just works, and Qute keeps the frontend simple.
From here, you could wire this to a database, send email confirmations, or add conditional branching logic based on previous inputs.
You’ve built a full-featured wizard the Quarkus way: Secure, maintainable, and Java all the way down.