Build a Dynamic Car Finder App with Quarkus and Panache
A step-by-step guide to creating a real-world search and filter UI with Quarkus, PostgreSQL Dev Services, and PrimeVue.
Filtering large datasets efficiently is a common enterprise requirement. Whether you are building a product catalog, a vehicle inventory, or a searchable knowledge base, users expect fast and flexible filtering. With Quarkus and Panache, implementing such functionality becomes straightforward. Combined with Dev Services for PostgreSQL, you can focus on writing business logic instead of setting up infrastructure.
In this tutorial, you will build a complete car filtering application. It uses a Quarkus backend with Panache repositories for data access, JAX-RS for REST endpoints, and a simple PrimeVue-based frontend. The final result provides dynamic filtering by brand, dealership, color, features, year, and price, with pagination support.
Create the Quarkus Project
Start by generating a new Quarkus project with the required extensions. Open your terminal and run:
quarkus create app com.example:car-filter-app -x="rest-jackson,hibernate-orm-panache,jdbc-postgresql,hibernate-validator" --no-code
cd car-filter-app
This will create a folder named car-filter-app
. Open it in your IDE. Quarkus will manage PostgreSQL automatically in development mode through Dev Services, so there is no need to install a database manually.
And as usual, you can find the complete working example in my Github repository.
Configure the Application
Configure Quarkus to use PostgreSQL with Dev Services and auto-create the schema at startup. Open src/main/resources/application.properties
and add:
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true
With these settings, Quarkus will spin up a PostgreSQL container when you start the app in dev mode, automatically create the schema, and populate it with data from import.sql
.
Define the Entities
Create JPA entities in the package com.example.entity
. Panache simplifies boilerplate by extending PanacheEntity
.
Brand.java
:
package com.example.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
@Entity
public class Brand extends PanacheEntity {
public String name;
}
Dealership.java
:
package com.example.entity;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
@Entity
public class Dealership extends PanacheEntity {
public String name;
public String city;
}
Car.java
:
package com.example.entity;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.NamedAttributeNode;
import jakarta.persistence.NamedEntityGraph;
@Entity
@NamedEntityGraph(name = "Car.withBrandAndDealership", attributeNodes = {
@NamedAttributeNode("brand"),
@NamedAttributeNode("dealership")
})
public class Car extends PanacheEntity {
public String model;
@ManyToOne(fetch = FetchType.LAZY)
public Brand brand;
@ManyToOne(fetch = FetchType.LAZY)
public Dealership dealership;
public Integer productionYear;
public String color;
public BigDecimal price;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "car_features", joinColumns = @JoinColumn(name = "car_id"))
@Column(name = "feature")
public Set<String> features = new HashSet<>();
}
The NamedEntityGraph
annotation ensures efficient fetching of associated Brand
and Dealership
when loading cars.
Create the DTOs
Keep your API layer clean by separating entities from API objects. Create a package com.example.dto
and add:
CarFilter.java
:
package com.example.dto;
import java.math.BigDecimal;
import java.util.Set;
public class CarFilter {
public Set<Long> brandIds;
public Set<Long> dealershipIds;
public Set<String> colors;
public Set<String> features;
public Integer minYear;
public Integer maxYear;
public BigDecimal minPrice;
public BigDecimal maxPrice;
}
FilterOptionsDto.java
:
package com.example.dto;
import com.example.entity.Brand;
import com.example.entity.Dealership;
import java.util.List;
public record FilterOptionsDto(
List<Brand> brands,
List<Dealership> dealerships,
List<String> colors,
List<String> features
) {}
PagedResult.java
:
package com.example.dto;
import java.util.List;
public record PagedResult<T>(List<T> list, long totalCount) {}
Implement the Repository
Create a package com.example.repository
and add CarRepository.java
. This repository handles search logic with dynamic filters and pagination.
package com.example.repository;
import java.util.Collections;
import java.util.List;
import com.example.dto.CarFilter;
import com.example.dto.FilterOptionsDto;
import com.example.dto.PagedResult;
import com.example.entity.Brand;
import com.example.entity.Car;
import com.example.entity.Dealership;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Parameters;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
@ApplicationScoped
public class CarRepository implements PanacheRepository<Car> {
private final EntityManager entityManager;
public CarRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
public FilterOptionsDto getFilterOptions() {
List<Brand> brands = Brand.listAll(Sort.by("name"));
List<Dealership> dealerships = Dealership.listAll(Sort.by("name", "city"));
List<String> colors = entityManager
.createQuery("SELECT DISTINCT c.color FROM Car c ORDER BY c.color", String.class)
.getResultList();
List<String> features = entityManager
.createQuery("SELECT DISTINCT f FROM Car c JOIN c.features f ORDER BY f", String.class)
.getResultList();
return new FilterOptionsDto(brands, dealerships, colors, features);
}
public PagedResult<Car> search(CarFilter filter, int pageIndex, int pageSize) {
StringBuilder queryBuilder = new StringBuilder();
Parameters params = new Parameters();
addCondition(filter.brandIds, "brand.id IN :brandIds", "brandIds", queryBuilder, params);
addCondition(filter.dealershipIds, "dealership.id IN :dealershipIds", "dealershipIds", queryBuilder, params);
addCondition(filter.colors, "color IN :colors", "colors", queryBuilder, params);
if (filter.features != null && !filter.features.isEmpty()) {
queryBuilder.append("AND id IN (SELECT c.id FROM Car c JOIN c.features f WHERE f IN :features) ");
params.and("features", filter.features);
}
addRangeCondition(filter.minYear, "productionYear >= :minYear", "minYear", queryBuilder, params);
addRangeCondition(filter.maxYear, "productionYear <= :maxYear", "maxYear", queryBuilder, params);
addRangeCondition(filter.minPrice, "price >= :minPrice", "minPrice", queryBuilder, params);
addRangeCondition(filter.maxPrice, "price <= :maxPrice", "maxPrice", queryBuilder, params);
String conditions = queryBuilder.length() > 0 ? queryBuilder.substring(4) : "1=1";
long totalCount = find(conditions, params).count();
List<Car> pagedCars = find(conditions, params)
.page(Page.of(pageIndex, pageSize))
.list();
if (pagedCars.isEmpty()) {
return new PagedResult<>(Collections.emptyList(), 0);
}
List<Long> carIds = pagedCars.stream()
.map(car -> car.id)
.toList();
List<Car> cars = find("id IN ?1", carIds)
.withHint("jakarta.persistence.fetchgraph", entityManager.getEntityGraph("Car.withBrandAndDealership"))
.list();
return new PagedResult<>(cars, totalCount);
}
private void addCondition(Object value, String clause, String paramName, StringBuilder qb, Parameters params) {
if (value instanceof java.util.Collection && !((java.util.Collection<?>) value).isEmpty()) {
qb.append("AND ").append(clause).append(" ");
params.and(paramName, value);
}
}
private void addRangeCondition(Object value, String clause, String paramName, StringBuilder qb, Parameters params) {
if (value != null) {
qb.append("AND ").append(clause).append(" ");
params.and(paramName, value);
}
}
}
Core principles behind the filter implementation:
Nullable DTO fields = optional criteria
The incoming JSON maps to a plain Java object where every field can be null or empty, meaning “ignore this criterion.”
Dynamic query assembly via StringBuilder
A helper method appends "AND …" snippets only for criteria that are present, creating a single WHERE clause on-the-fly.
Named parameters, never string literals
Each value is fed to the query through Panache’s Parameters map, which handles typing and guards against SQL injection.
Two-phase paging
Query 1: SELECT COUNT(*) to know total items for the paginator.
Query 2: fetch just the requested page (LIMIT/OFFSET under the hood).
Entity graph instead of eager joins
A second select with a JPA entity graph pulls associated Brand and Dealership in one round-trip, eliminating N+1 issues while keeping the base query lean.
Minimal response envelope
A simple record returns { list, totalCount }; the REST layer puts list in the body and totalCount in a header.
Together these patterns give you an extensible, injection-safe, and performant search endpoint to which you can add a new filter by updating the DTO and a single helper line.
Build the API Resource
Create a package com.example.rest
and add CarResource.java
. It exposes endpoints for fetching filter options and searching cars.
package com.example.rest;
import com.example.dto.CarFilter;
import com.example.dto.FilterOptionsDto;
import com.example.dto.PagedResult;
import com.example.entity.Car;
import com.example.repository.CarRepository;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/cars")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CarResource {
private final CarRepository carRepository;
public CarResource(CarRepository carRepository) {
this.carRepository = carRepository;
}
@GET
@Path("/filter-options")
public FilterOptionsDto getFilterOptions() {
return carRepository.getFilterOptions();
}
@POST
@Path("/search")
public Response searchCars(
CarFilter filter,
@QueryParam("page") @DefaultValue("0") int pageIndex,
@QueryParam("size") @DefaultValue("10") int pageSize) {
PagedResult<Car> result = carRepository.search(filter, pageIndex, pageSize);
return Response.ok(result.list())
.header("X-Total-Count", result.totalCount())
.build();
}
}
Add Sample Data and Frontend
Add import.sql
to src/main/resources
with initial brands, dealerships, cars, and features.
-- Brands
INSERT INTO brand(id, name) VALUES (1, 'Volkswagen');
INSERT INTO brand(id, name) VALUES (2, 'BMW');
INSERT INTO brand(id, name) VALUES (3, 'Mercedes-Benz');
INSERT INTO brand(id, name) VALUES (4, 'Audi');
-- Dealerships
INSERT INTO dealership(id, name, city) VALUES (1, 'Auto-Haus München', 'Munich');
INSERT INTO dealership(id, name, city) VALUES (2, 'Premium Cars Berlin', 'Berlin');
INSERT INTO dealership(id, name, city) VALUES (3, 'Süd-West Automobile', 'Stuttgart');
-- Cars
-- ID, COLOR, MODEL, PRICE, PROD_YEAR, BRAND_ID, DEALER_ID
INSERT INTO car(id, color, model, price, productionYear, brand_id, dealership_id) VALUES (101, 'Black', 'Golf', 25000.00, 2022, 1, 1);
INSERT INTO car(id, color, model, price, productionYear, brand_id, dealership_id) VALUES (102, 'White', '3 Series', 45000.00, 2023, 2, 2);
INSERT INTO car(id, color, model, price, productionYear, brand_id, dealership_id) VALUES (103, 'Silver', 'C-Class', 48000.00, 2023, 3, 3);
INSERT INTO car(id, color, model, price, productionYear, brand_id, dealership_id) VALUES (104, 'Red', 'A4', 42000.00, 2022, 4, 1);
INSERT INTO car(id, color, model, price, productionYear, brand_id, dealership_id) VALUES (105, 'Black', 'Tiguan', 32000.00, 2021, 1, 2);
INSERT INTO car(id, color, model, price, productionYear, brand_id, dealership_id) VALUES (106, 'Blue', 'X5', 75000.00, 2024, 2, 3);
INSERT INTO car(id, color, model, price, productionYear, brand_id, dealership_id) VALUES (107, 'White', 'A6', 65000.00, 2024, 4, 2);
-- Car Features
-- CAR_ID, FEATURE
INSERT INTO car_features(car_id, feature) VALUES (101, 'Sunroof');
INSERT INTO car_features(car_id, feature) VALUES (101, 'Heated Seats');
INSERT INTO car_features(car_id, feature) VALUES (102, 'Sunroof');
INSERT INTO car_features(car_id, feature) VALUES (102, 'Sport Package');
INSERT INTO car_features(car_id, feature) VALUES (103, 'Heated Seats');
INSERT INTO car_features(car_id, feature) VALUES (104, 'LED Headlights');
INSERT INTO car_features(car_id, feature) VALUES (105, 'Sunroof');
INSERT INTO car_features(car_id, feature) VALUES (106, 'Sport Package');
INSERT INTO car_features(car_id, feature) VALUES (106, 'Heated Seats');
INSERT INTO car_features(car_id, feature) VALUES (107, 'LED Headlights');
Then create index.html
in src/main/resources/META-INF/resources/
containing the provided Vue 3 and PrimeVue code.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quarkus Car Search</title>
<link href="https://cdn.jsdelivr.net/npm/primeicons@7.0.0/primeicons.css" rel="stylesheet">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/primevue/umd/primevue.min.js"></script>
<script src="https://unpkg.com/@primeuix/themes/umd/aura.js"></script>
<style>
<!-- omitted for brevity -->
</style>
</head>
<body>
<div id="app">
<h1>Car Search</h1>
<p>Dynamic filtering with Quarkus Panache and PrimeVue.</p>
<div class="card">
<h2>Filters</h2>
<div class="filter-grid">
<div class="filter-item">
<label for="brands">Brands</label>
<p-multiselect v-model="filters.brandNames" :options="brandNames"
placeholder="Select Brands" display="chip"></p-multiselect>
</div>
<div class="filter-item">
<label for="dealers">Dealerships</label>
<p-multiselect v-model="filters.dealershipNames" :options="dealershipNames"
placeholder="Select Dealerships" display="chip"></p-multiselect>
</div>
<div class="filter-item">
<label for="colors">Colors</label>
<p-multiselect v-model="filters.colors" :options="filterOptions.colors" placeholder="Select Colors"
display="chip"></p-multiselect>
</div>
<div class="filter-item">
<label for="features">Features</label>
<p-multiselect v-model="filters.features" :options="filterOptions.features"
placeholder="Select Features" display="chip"></p-multiselect>
</div>
<div class="filter-item">
<label for="minYear">Min Year</label>
<p-inputnumber v-model="filters.minYear" :useGrouping="false"
placeholder="e.g. 2020"></p-inputnumber>
</div>
<div class="filter-item">
<label for="maxYear">Max Year</label>
<p-inputnumber v-model="filters.maxYear" :useGrouping="false"
placeholder="e.g. 2024"></p-inputnumber>
</div>
<div class="filter-item">
<label for="minPrice">Min Price</label>
<p-inputnumber v-model="filters.minPrice" mode="currency" currency="USD" locale="en-US"
placeholder="$20,000"></p-inputnumber>
</div>
<div class="filter-item">
<label for="maxPrice">Max Price</label>
<p-inputnumber v-model="filters.maxPrice" mode="currency" currency="USD" locale="en-US"
placeholder="$50,000"></p-inputnumber>
</div>
<div class="filter-actions">
<p-button label="Search" icon="pi pi-search" @click="searchCars"></p-button>
<p-button label="Reset" icon="pi pi-refresh" severity="secondary" @click="resetFilters"></p-button>
</div>
</div>
</div>
<div class="card">
<p-datatable :value="cars" :loading="loading" dataKey="id">
<template #header>
Found {{ pagination.totalRecords }} cars
</template>
<p-column field="model" header="Model" :sortable="true"></p-column>
<p-column field="brand.name" header="Brand"></p-column>
<p-column field="dealership.name" header="Dealership"></p-column>
<p-column field="productionYear" header="Year" :sortable="true"></p-column>
<p-column field="color" header="Color"></p-column>
<p-column field="price" header="Price" :sortable="true">
<template #body="slotProps">
{{ formatCurrency(slotProps.data.price) }}
</template>
</p-column>
</p-datatable>
<p-paginator :rows="pagination.rows" :totalRecords="pagination.totalRecords"
@page="onPageChange"></p-paginator>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted } = Vue;
const app = createApp({
setup() {
// Reactive state variables
const cars = ref([]);
const loading = ref(false);
const filterOptions = ref({ brands: [], dealerships: [], colors: [], features: [] });
const filters = ref({});
const pagination = ref({ page: 0, rows: 5, totalRecords: 0 });
// Fetch data to populate filter dropdowns
const loadFilterOptions = async () => {
const response = await fetch('/cars/filter-options');
filterOptions.value = await response.json();
};
// Main search function
const searchCars = async () => {
loading.value = true;
// Convert brand and dealership names back to IDs for backend
const transformedFilters = { ...filters.value };
// Convert brand names to IDs
if (transformedFilters.brandNames && transformedFilters.brandNames.length > 0) {
transformedFilters.brandIds = transformedFilters.brandNames.map(name => {
const brand = filterOptions.value.brands.find(b => b.name === name);
return brand ? brand.id : null;
}).filter(id => id !== null);
delete transformedFilters.brandNames;
}
// Convert dealership names to IDs
if (transformedFilters.dealershipNames && transformedFilters.dealershipNames.length > 0) {
transformedFilters.dealershipIds = transformedFilters.dealershipNames.map(nameCity => {
const dealership = filterOptions.value.dealerships.find(d => `${d.name} - ${d.city}` === nameCity);
return dealership ? dealership.id : null;
}).filter(id => id !== null);
delete transformedFilters.dealershipNames;
}
// Remove null/empty properties from the filter object before sending
const activeFilters = Object.entries(transformedFilters)
.filter(([key, value]) => value !== null && value !== '' && (!Array.isArray(value) || value.length > 0))
.reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {});
const response = await fetch(`/cars/search?page=${pagination.value.page}&size=${pagination.value.rows}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(activeFilters)
});
cars.value = await response.json();
pagination.value.totalRecords = parseInt(response.headers.get('X-Total-Count') || 0);
loading.value = false;
};
// Handle pagination changes
const onPageChange = (event) => {
pagination.value.page = event.page;
pagination.value.rows = event.rows;
searchCars();
};
// Reset all filters and search again
const resetFilters = () => {
filters.value = {};
pagination.value.page = 0; // Go back to the first page
searchCars();
};
// Helper to format currency
const formatCurrency = (value) => {
if (typeof value !== 'number') return '';
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value);
};
// Computed properties to create simple string arrays like Colors
const brandNames = computed(() => {
return filterOptions.value.brands.map(brand => brand.name);
});
const dealershipNames = computed(() => {
return filterOptions.value.dealerships.map(dealership => `${dealership.name} - ${dealership.city}`);
});
// Load initial data when the component is mounted
onMounted(() => {
loadFilterOptions();
searchCars();
});
return {
cars,
loading,
filters,
filterOptions,
brandNames,
dealershipNames,
pagination,
searchCars,
resetFilters,
onPageChange,
formatCurrency
};
}
});
// Register PrimeVue and its components
app.use(PrimeVue.Config, {
theme: { preset: PrimeUIX.Themes.Aura }
});
app.component('p-datatable', PrimeVue.DataTable);
app.component('p-column', PrimeVue.Column);
app.component('p-paginator', PrimeVue.Paginator);
app.component('p-multiselect', PrimeVue.MultiSelect);
app.component('p-inputnumber', PrimeVue.InputNumber);
app.component('p-button', PrimeVue.Button);
app.mount('#app');
</script>
</body>
</html>
This simple frontend interacts with the Quarkus REST API and displays filtered cars in a data table with pagination. While the definition of “simple” is a little odd for Java developers. PrimeVue is a very powerful way to quickly generate frontends and safes you a ton of time.
Run and Test
Start Quarkus in dev mode:
./mvnw quarkus:dev
Open http://localhost:8080 in your browser. You can now use the filters, search cars dynamically, and navigate through results using the paginator.
This example demonstrates how to build a flexible filtering API with Panache and a lightweight frontend without extra backend configuration. You can extend it by adding authentication, sorting, or exporting results to CSV or PDF.
For more details, explore Quarkus Panache documentation and PrimeVue components.