DEV Community

Vinicius Senger
Vinicius Senger

Posted on

Building a Full-Stack Java App with Quarkus — No React, No Angular, No Problem

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
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:

<!-- Qute templating with REST integration -->
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-rest-qute</artifactId>
</dependency>
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;

    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...
}
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<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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
}
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 {

    @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);
    }
}
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 <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();
}
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:

<!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>
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

<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>
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

<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>
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-1
Enter 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
Enter 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

  1. Qute is underrated. It's type-safe, fast, and integrates seamlessly with Quarkus CDI. Just @Inject Template myPage and you're rendering HTML.

  2. HTML forms still work. The Post-Redirect-Get pattern handles 90% of CRUD interactions without any JavaScript.

  3. Vanilla JS is enough. Modals, toggles, inline edits — you don't need 200KB of framework for this.

  4. One deployable artifact. Your UI, API, and business logic ship as a single JAR. Deploy it anywhere Java runs.

  5. 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?

Clone it, run ./mvnw compile quarkus:dev, and you're up in seconds.

Top comments (0)