You don't need React. You don't need Angular. You don't need Vue, Svelte, or the JavaScript framework that launched last Tuesday.
I built re:Money — a full-stack financial tracking application with a dashboard, pivot tables, CSV import, inline editing, modals, and filtering — using nothing but Quarkus, its built-in Qute template engine, plain CSS, and a sprinkle of vanilla JavaScript. The backend talks to DynamoDB and the whole thing runs as a single JAR.
Let me show you how.
Why Skip the Front-End Framework?
For many internal tools, personal projects, and CRUD apps, a full SPA (single page application) framework adds:
- A separate build pipeline (Node, npm, Webpack/Vite)
- A JSON API contract you have to maintain
- Client-side state management
- Hundreds of megabytes of
node_modules
With Quarkus + Qute, you get server-side rendering with type-safe templates, hot reload in dev mode, and a single mvn build. Your HTML is your UI. Your Java objects flow directly into your pages. Done.
The Stack
| Layer | Technology |
|---|---|
| Runtime | Java 21 |
| Framework | Quarkus 3.x |
| Templating | Qute (built into Quarkus) |
| Styling | Plain CSS |
| Interactivity | Vanilla JavaScript |
| Database | Amazon DynamoDB |
| Build | Maven |
One language. One build tool. One deployable artifact.
Project Structure
src/main/java/org/reos/money/
├── model/ # Domain POJOs
├── resource/ # REST + UI endpoints (JAX-RS)
└── service/ # Business logic + DynamoDB access
src/main/resources/
├── templates/ # Qute HTML templates
├── META-INF/resources/css/ # Static CSS
└── application.properties
The key insight: UI endpoints and API endpoints live side by side in the same Quarkus app. No separate front-end project.
Step 1: Add the Dependencies
You only need one extra dependency beyond the standard Quarkus REST setup:
<!-- Qute templating with REST integration -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-qute</artifactId>
</dependency>
That's it. Qute ships with Quarkus — quarkus-rest-qute just wires it into your JAX-RS resources so you can return TemplateInstance from your endpoints.
Step 2: Define Your Model
A plain Java class. No JPA annotations, no ORM magic — just fields and a factory method to map from DynamoDB's AttributeValue maps:
@RegisterForReflection
public class Entry {
String id;
public Long timestamp;
public String accountID;
public String description;
public String category;
public BigDecimal amount;
public BigDecimal balance;
public String date;
public Entry() {
this.id = UUID.randomUUID().toString();
}
public static Entry from(Map<String, AttributeValue> item) {
Entry entry = new Entry();
entry.id = item.get("id").s();
entry.setAccountID(item.get("accountID").s());
entry.setDescription(item.get("description").s());
entry.setAmount(new BigDecimal(item.get("amount").n()));
entry.setBalance(new BigDecimal(item.get("balance").n()));
entry.setDate(item.get("date").s());
entry.setTimestamp(Long.parseLong(item.get("timestamp").n()));
entry.setCategory(item.get("category").s());
return entry;
}
// getters and setters...
}
@RegisterForReflection is there for Quarkus native compilation support. The from() static factory keeps DynamoDB mapping logic close to the model.
Step 3: Build the Service Layer
The service layer handles all DynamoDB operations. A base class encapsulates the repetitive request-building:
public class AbstractService {
public static final String ENTRY_ID_COL = "id";
public static final String ENTRY_ACCOUNTID_COL = "accountID";
// ... other column constants
@ConfigProperty(name = "dynamodb.table.account.data")
String tableName;
protected ScanRequest scanRequest() {
return ScanRequest.builder()
.tableName(tableName)
.attributesToGet(ENTRY_ID_COL, ENTRY_ACCOUNTID_COL, /* ... */)
.build();
}
protected PutItemRequest putRequest(Entry entry) {
Map<String, AttributeValue> item = new HashMap<>();
item.put(ENTRY_ID_COL,
AttributeValue.builder().s(entry.getId()).build());
item.put(ENTRY_AMOUNT_COL,
AttributeValue.builder().n(entry.getAmount().toString()).build());
// ... map all fields
return PutItemRequest.builder()
.tableName(tableName).item(item).build();
}
protected DeleteItemRequest deleteRequest(String id) {
return DeleteItemRequest.builder()
.tableName(tableName)
.key(Map.of(ENTRY_ID_COL,
AttributeValue.builder().s(id).build()))
.build();
}
}
Then the concrete service adds business logic:
@ApplicationScoped
public class EntryService extends AbstractService {
@Inject
DynamoDbClient dynamoDB;
public List<Entry> findAll() {
return dynamoDB.scan(scanRequest()).items().stream()
.map(Entry::from)
.sorted(Comparator.comparing(Entry::getTimestamp))
.collect(Collectors.toList());
}
public Entry addEntry(Entry entry) {
dynamoDB.putItem(putRequest(entry));
return entry;
}
public List<Entry> findByFilters(String accountID, String category,
Long startDate, Long endDate, String sortOrder,
String excludeCategories, String description) {
List<Entry> entries = findAll();
if (accountID != null)
entries = entries.stream()
.filter(e -> accountID.equals(e.getAccountID()))
.collect(Collectors.toList());
if (category != null)
entries = entries.stream()
.filter(e -> category.equals(e.getCategory()))
.collect(Collectors.toList());
// ... date range, exclude, description filters
if ("desc".equalsIgnoreCase(sortOrder))
entries.sort(Comparator.comparing(Entry::getTimestamp).reversed());
else
entries.sort(Comparator.comparing(Entry::getTimestamp));
return entries;
}
// replaceCategory, deleteAccount, recalculateBalances, etc.
}
No Spring Data, no Hibernate — just the AWS SDK DynamoDB client injected by Quarkus.
Step 4: The UI Resource — Where the Magic Happens
This is the core pattern. Instead of returning JSON, your endpoint returns a TemplateInstance:
@Path("/ui")
public class ExpenseUIResource {
@Inject
Template dashboard; // Matches templates/dashboard.html
@Inject
EntryService entryService;
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance getDashboard(
@QueryParam("account") String account,
@QueryParam("category") String category,
@QueryParam("startDate") String startDate,
@QueryParam("endDate") String endDate,
@QueryParam("sortOrder") String sortOrder) throws Exception {
// Parse dates, apply filters...
List<Entry> entries = entryService.findByFilters(
account, category, start, end, order, null, null);
return dashboard
.data("entries", entries)
.data("accounts", entryService.listAccounts())
.data("categories", entryService.listCategories())
.data("selectedAccount", account)
.data("selectedCategory", category)
.data("sortOrder", order);
}
}
When you @Inject Template dashboard, Quarkus automatically looks for src/main/resources/templates/dashboard.html. You pass data with .data("key", value) and Qute renders it server-side. The browser gets plain HTML.
Handling Form Submissions
Forms use standard HTML <form> posts — no fetch(), no JSON serialization:
@POST
@Path("/entry")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response addEntry(
@FormParam("accountID") String accountID,
@FormParam("description") String description,
@FormParam("category") String category,
@FormParam("amount") BigDecimal amount,
@FormParam("date") String date) throws Exception {
Entry entry = new Entry();
entry.setAccountID(accountID);
entry.setDescription(description);
entry.setCategory(category);
entry.setAmount(amount);
entry.setDate(date);
entryService.addEntry(entry);
// Post-Redirect-Get pattern
return Response.seeOther(URI.create("/ui")).build();
}
The Post-Redirect-Get pattern prevents duplicate submissions on refresh. The browser submits a form, the server processes it, then redirects back to the dashboard. Classic web development — and it works perfectly.
Step 5: Qute Templates — Your View Layer
Qute templates look like HTML with simple expressions. Here's a simplified version of the dashboard:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>re:Money Dashboard</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<div class="container">
<!-- Filter form — plain HTML, no framework -->
<form method="get" class="filters">
<select name="account" onchange="this.form.submit()">
<option value="">All Accounts</option>
{#for account in accounts}
<option value="{account}"
{#if selectedAccount == account}selected{/if}>
{account}
</option>
{/for}
</select>
<select name="category" onchange="this.form.submit()">
<option value="">All Categories</option>
{#for category in categories}
<option value="{category}"
{#if selectedCategory == category}selected{/if}>
{category}
</option>
{/for}
</select>
<input type="datetime-local" name="startDate"
value="{selectedStartDate ?: ''}"
onchange="this.form.submit()">
</form>
<!-- Data table -->
<table>
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Account</th>
<th>Category</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
{#for entry in entries}
<tr>
<td>{entry.date}</td>
<td>{entry.description}</td>
<td>{entry.accountID}</td>
<td>{entry.category}</td>
<td class="{#if entry.amount < 0}negative{#else}positive{/if}">
${entry.amount}
</td>
</tr>
{/for}
</tbody>
</table>
</div>
</body>
</html>
Key Qute features used here:
-
{#for ... in ...}— loops over collections -
{#if ...}— conditional rendering -
{expression ?: 'default'}— elvis operator for defaults -
{entry.amount}— direct property access on Java objects
The onchange="this.form.submit()" on the selects gives you instant filtering without any JavaScript framework. The browser submits the form as a GET request, the server re-renders the page with filtered data. Simple.
Step 6: Adding Interactivity with Vanilla JS
You don't need React for modals, inline editing, or dynamic filters. Here's how re:Money handles it:
Modal for Adding/Editing Entries
<div id="entryModal" class="modal">
<div class="modal-content">
<h2 id="modalTitle">Add New Entry</h2>
<form method="post" action="/ui/entry" id="entryForm">
<input type="text" name="description" required>
<input type="number" name="amount" step="0.01" required>
<input type="datetime-local" name="date" required>
<button type="submit">Add Entry</button>
</form>
</div>
</div>
<script>
function openModal() {
document.getElementById('entryModal').classList.add('active');
}
function editEntry(id, accountID, description, category, amount, date) {
document.getElementById('modalTitle').textContent = 'Edit Entry';
document.getElementById('entryForm').action = '/ui/entry/' + id + '/update';
document.getElementById('descriptionUI').value = description;
document.getElementById('amountUI').value = amount;
document.getElementById('dateUI').value = date;
document.getElementById('entryModal').classList.add('active');
}
</script>
The modal is pure CSS (.modal.active { display: flex; }). The form posts to the server. No state management library needed.
Inline Category Editing
<td>
<span class="category-display" id="category-display-{entry.id}">
{entry.category}
</span>
<select class="category-edit" id="category-edit-{entry.id}"
style="display: none;"
onchange="updateCategory('{entry.id}', this.value)">
{#for cat in categories}
<option value="{cat}" {#if entry.category == cat}selected{/if}>
{cat}
</option>
{/for}
</select>
<button onclick="toggleCategoryEdit('{entry.id}')">✏️</button>
</td>
Click the pencil, the span hides, the select shows. Pick a new category, a small fetch() call updates it server-side. This is the only place we use fetch() — for a better UX on a single field update. Everything else is plain form submissions.
Step 7: DynamoDB Setup
Create the table:
aws dynamodb create-table \
--table-name entry \
--attribute-definitions AttributeName=id,AttributeType=S \
--key-schema AttributeName=id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
For local development, add --endpoint-url http://localhost:8000 and configure Quarkus:
%dev.quarkus.dynamodb.endpoint-override=http://localhost:8000
dynamodb.table.account.data=entry
The %dev. prefix means this config only applies in dev mode. In production, Quarkus uses your default AWS credentials.
Step 8: Run It
./mvnw compile quarkus:dev
Open http://localhost:8080/ui and you have a full working app. Quarkus dev mode gives you:
- Live reload — edit a Java file or a template, save, refresh the browser
-
Dev UI at
/q/dev/— inspect beans, config, endpoints - Zero restart — changes apply instantly
The Result
With this approach, re:Money has:
- 📊 Dashboard with multi-criteria filtering and sorting
- ➕ Add/Edit/Delete entries via modal forms
- 🗂️ Category management — rename, delete, inline edit
- 🏦 Account management — rename, delete, recalculate balances
- 📈 Pivot tables — cross-account category analysis
- 📁 CSV import — bulk data loading
- 💬 Chat interface — natural language queries (via Bedrock)
All in a single Quarkus application. No npm install. No package.json. No webpack config. No CORS issues. No separate deployment for the front-end.
When Should You Use This Approach?
✅ Good fit:
- Internal tools and admin panels
- Personal projects and prototypes
- CRUD-heavy applications
- Small team projects where everyone knows Java
- Apps where SEO matters (server-rendered HTML)
❌ Consider a SPA when:
- You need rich real-time interactivity (collaborative editing, drag-and-drop)
- Your front-end team is specialized in JavaScript/TypeScript
- You're building a complex single-page experience with heavy client-side state
Key Takeaways
Qute is underrated. It's type-safe, fast, and integrates seamlessly with Quarkus CDI. Just
@Inject Template myPageand you're rendering HTML.HTML forms still work. The Post-Redirect-Get pattern handles 90% of CRUD interactions without any JavaScript.
Vanilla JS is enough. Modals, toggles, inline edits — you don't need 200KB of framework for this.
One deployable artifact. Your UI, API, and business logic ship as a single JAR. Deploy it anywhere Java runs.
DynamoDB + Quarkus is a clean combo. The Quarkiverse extension handles client configuration. You write
putItem/scan/deleteItemand move on.
The web platform is more capable than we give it credit for. Sometimes the best front-end framework is no framework at all.
Get re:Money
Want to try it yourself or use it as a starting point for your own project?
- 📖 re:Money — Personal Financial Management Re-imagined with AI — full project overview on AWS Community
- 💻 Source code on GitHub
Clone it, run ./mvnw compile quarkus:dev, and you're up in seconds.
Top comments (0)