Building a Full-Stack Java App with Quarkus — No React, No Angular, No Problem
<p>You don't need React. You don't need Angular. You don't need Vue, Svelte, or the JavaScript framework that launched last Tuesday.</p> <p>I built <strong>re:Money</strong> — a full-stack financial tracking application with a dashboard, pivot tables, CSV import, inline editing, modals, and filtering — using nothing but <strong>Quarkus</strong>, its built-in <strong>Qute</strong> template engine, plain <strong>CSS</strong>, and a sprinkle of <strong>vanilla JavaScript</strong>. The backend talks to <strong>DynamoDB</strong> and the whole thing runs as a single JAR.</p> <p>Let me show you how.</p> <h2> Why Skip the Front-End Framework? </h2> <p>For many internal tools, personal projects, and CRUD apps, a full SPA (single page application) framework adds:</p> <ul> <li>A separate build pipeli
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 accesssrc/main/java/org/reos/money/ ├── model/ # Domain POJOs ├── resource/ # REST + UI endpoints (JAX-RS) └── service/ # Business logic + DynamoDB accesssrc/main/resources/ ├── templates/ # Qute HTML templates ├── META-INF/resources/css/ # Static CSS └── application.properties`
Enter fullscreen mode
Exit fullscreen mode
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:
`
io.quarkus quarkus-rest-qute `
Enter fullscreen mode
Exit fullscreen mode
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;@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 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... }`
Enter fullscreen mode
Exit fullscreen mode
@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 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(); } }`
Enter fullscreen mode
Exit fullscreen mode
Then the concrete service adds business logic:
@ApplicationScoped public class EntryService extends AbstractService {@ApplicationScoped public class EntryService extends AbstractService {@Inject DynamoDbClient dynamoDB;
public List 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 findByFilters(String accountID, String category, Long startDate, Long endDate, String sortOrder, String excludeCategories, String description) { List 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. }`
Enter fullscreen mode
Exit fullscreen mode
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 {@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 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); } }`
Enter fullscreen mode
Exit fullscreen mode
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 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 {@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(); }`
Enter fullscreen mode
Exit fullscreen mode
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:
`
re:Money Dashboard
All Accounts {#for account in accounts}
{account}
{/for}
All Categories {#for category in categories}
{category}
{/for}
{#for entry in entries}
{/for}
Date Description Account Category Amount
{entry.date} {entry.description} {entry.accountID} {entry.category}
${entry.amount}
`
Enter fullscreen mode
Exit fullscreen mode
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
`
Add New Entry
Add Entry
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'); } `
Enter fullscreen mode
Exit fullscreen mode
The modal is pure CSS (.modal.active { display: flex; }). The form posts to the server. No state management library needed.
Inline Category Editing
`
{entry.category}
{#for cat in categories}
{cat}
{/for}
✏️ `
Enter fullscreen mode
Exit fullscreen mode
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-1aws 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-1Enter fullscreen mode
Exit fullscreen mode
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%dev.quarkus.dynamodb.endpoint-override=http://localhost:8000 dynamodb.table.account.data=entryEnter fullscreen mode
Exit fullscreen mode
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
Enter fullscreen mode
Exit fullscreen mode
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 myPage and 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 / deleteItem and 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.
DEV Community
https://dev.to/vsenger/building-a-full-stack-java-app-with-quarkus-no-react-no-angular-no-problem-j1mSign in to highlight and annotate this article

Conversation starters
Daily AI Digest
Get the top 5 AI stories delivered to your inbox every morning.
More about
modellaunchversionPRC Adapts Meta’s Llama for Military and Security AI Applications - The Jamestown Foundation
<a href="https://news.google.com/rss/articles/CBMilwFBVV95cUxOY2tYZU5lTERzTDZfRzZaaGRSX0NkbE9BYk10WjNlbUdFLVlHOUQzczh1YUx2NUhTS2x3U05mamE4a3duVHJpY2lEN1NaaDgxWHRENHZZSmdGcl9fT0F5ZGNIcGlVdlhJXzZBQjd6UHhNREs2ODJfeU5EdjFmdzlYZTRTMnBTTTZVbW5LNGpCYU9pREQ4X2tF?oc=5" target="_blank">PRC Adapts Meta’s Llama for Military and Security AI Applications</a> <font color="#6f6f6f">The Jamestown Foundation</font>
AI model from Google DeepMind reads recipe for life in our DNA - BBC
<a href="https://news.google.com/rss/articles/CBMiWkFVX3lxTFBBTlI5UTI2QVNpRUFYM2RMUXdwbXl6NDBoV3ZwNmVNYzJxS0l2MGRfNU5RcXVwVklEQUJQOVZKOE4tU09EYVlEaHhSWGtYOGM4cno5eXNDdXhjUQ?oc=5" target="_blank">AI model from Google DeepMind reads recipe for life in our DNA</a> <font color="#6f6f6f">BBC</font>
Mystery AI model suspected to be DeepSeek V4 is revealed to be from Xiaomi - The Japan Times
<a href="https://news.google.com/rss/articles/CBMiigFBVV95cUxNSDVKYTR0VERGVTlIVTdrVG8xVzE4VncyQXN1SlZsZDZoZU9RaEFQVXRITDFzSFFoWThwb1dUMGhSZ052ZVM5ZlE5b0lCNjdhdExsemFvMlVaY0RRRFBjTVR3MjN5Y0tsSzdDcU41QmdQLUR4QVBrOG9NTTlHVWJidHVySUZYQ3dsWVE?oc=5" target="_blank">Mystery AI model suspected to be DeepSeek V4 is revealed to be from Xiaomi</a> <font color="#6f6f6f">The Japan Times</font>
Knowledge Map
Connected Articles — Knowledge Graph
This article is connected to other articles through shared AI topics and tags.
More in Products
PRC Adapts Meta’s Llama for Military and Security AI Applications - The Jamestown Foundation
<a href="https://news.google.com/rss/articles/CBMilwFBVV95cUxOY2tYZU5lTERzTDZfRzZaaGRSX0NkbE9BYk10WjNlbUdFLVlHOUQzczh1YUx2NUhTS2x3U05mamE4a3duVHJpY2lEN1NaaDgxWHRENHZZSmdGcl9fT0F5ZGNIcGlVdlhJXzZBQjd6UHhNREs2ODJfeU5EdjFmdzlYZTRTMnBTTTZVbW5LNGpCYU9pREQ4X2tF?oc=5" target="_blank">PRC Adapts Meta’s Llama for Military and Security AI Applications</a> <font color="#6f6f6f">The Jamestown Foundation</font>
Mapping Israel’s AI infrastructure opportunity - CTech
<a href="https://news.google.com/rss/articles/CBMiakFVX3lxTE5VNUlHTkZReUtFNk16SjJZU3VDWUsxdjNxSmtTUGpYcFpZNGFmNmt3NWVLU3lNV0otRThvT2VhRHRESkstN3VYY29vNl8zX0p4Umo5YXBHT2EzdktoMXJKUDdEeUxQMHdfMXc?oc=5" target="_blank">Mapping Israel’s AI infrastructure opportunity</a> <font color="#6f6f6f">CTech</font>
Huawei Proposes Building an AI-Centric All-Optical Target Network to Enhance Service Experience - Huawei
<a href="https://news.google.com/rss/articles/CBMibkFVX3lxTFB3YWU2MEZQVVdlUlk1Vm5tRlJKTFN4WmhySXFGbGg2R3dPYnVKbmJSSVRwRGNUbGttWEE4b0VhWkxTWlp3OGE2UzdTbGh2RVZCQ085SHRDb0FzLXdMX18xUlp4V1pOYTd6eWFTZW5n?oc=5" target="_blank">Huawei Proposes Building an AI-Centric All-Optical Target Network to Enhance Service Experience</a> <font color="#6f6f6f">Huawei</font>
Israeli Startup Bold Raises $40 Million to Protect Devices With AI - Business Insider
<a href="https://news.google.com/rss/articles/CBMimAFBVV95cUxQWWt2cGFRZFhGUkV6QTVNWmFfZGVwME5TS21MMS13bDRUNUtCQmZIWk01ZnlhTXpWamcwdGQ0MTFISUk0czcyak42SDE3eVpWa0tLVWs1cG8yZmxXZlpOYjVPU3pTX1BCOEJDLUlZWUlZUVBOZGR2N2NpS1I4cGxXVVN2UUFhVGRoNUNCazI4ZE10M0Z2UnltMw?oc=5" target="_blank">Israeli Startup Bold Raises $40 Million to Protect Devices With AI</a> <font color="#6f6f6f">Business Insider</font>

Discussion
Sign in to join the discussion
No comments yet — be the first to share your thoughts!