I Implemented the Same Business Web UI in Vanilla HTML / Vue / React / Thymeleaf and Compared Them — Differences Across 4 Stacks and How to Choose

Vanilla HTML Vue3 React Thymeleaf Spring Boot TypeScript Java Frontend Comparison Library Selection

What You’ll Learn from This Article

  • What actually differs in the code when the same business web UI spec is implemented in 4 stacks — Vanilla HTML / Vue 3 / React / Thymeleaf (with code examples)
  • Differences in project structure and build (package.json / Vite / pom.xml / plain HTML)
  • Side-by-side comparison of API client, state management, form handling, error display, and deployment
  • A decision framework for “which stack to pick in which situation”

Who This Is For

  • Anyone unsure which to use among Vanilla / Vue / React / Thymeleaf when picking a framework
  • Anyone who’s used each one individually, but wants to know what actually differs when they’re lined up against the same spec
  • Anyone who wants to compare stacks with a business web UI (form + API call + result display) in mind

Environment

ItemVersion
Vanilla HTMLNo build required (module script)
Vue3.x + Vite 5.x + TypeScript 5.x + Vue Router 4.x
React18.x + Vite 5.x + TypeScript 5.x + React Router 6.x
ThymeleafSpring Boot 3.x + Java 21 + Thymeleaf 3.x

1. Introduction

Honestly, I wasn’t planning to write a comparison article at first

While developing the backend for banklink-service, I found myself wanting “a screen where I can check whether the API is working,” so I threw one together on the spot in Vanilla HTML — the fastest thing to build. It’s a throwaway UI that’s the picture of “as long as it moves, that’s fine.”

As I kept developing, my plan was “eventually swap it for a proper Vue or React setup and turn it into a real development template.” I intended to start the actual frontend implementation from there.

But the moment I actually sat down to do it, I realized something.

For this amount of processing (form + API call + result display), implementing it across all 4 stacks, Thymeleaf included, wouldn’t actually take that long. And if I kept the functionality identical across all of them, wouldn’t that make a pretty good subject for comparing the differences between the technologies side by side?

In terms of development progress, this was a complete detour. Before I knew it, “build one template” had quietly turned into “implement it in parallel across 4 stacks and compare them.”

But once I’d written it all up, I found I’d sorted out my own decision-making criteria for technology selection, and it seemed like it could also help anyone else stuck on the same question — so I decided to leave it as an article. This article is the byproduct of that enjoyable detour.


So, on to the main topic

When building a business web UI, “is Vanilla HTML / Vue / React / Thymeleaf the right call” is one of the first decisions you run into. Each one gets discussed plenty on its own, but articles that actually put real code for all four side by side against the same spec are surprisingly rare.

So, using banklink-service (a personal practice project — a banking API wrapper service), I implemented a business UI to the exact same spec in parallel across 4 stacks. This article is the record of that comparison.

🔗 Related article: banklink-service’s backend design (requirements → basic design → detailed design → PoC, the design decisions across 5 domains, and the DDD / ArchUnit / financial-grade quality implementation) is covered in a separate article. This article is the 4-stack frontend comparison that connects to that backend. → Design-First Banking API: From 5-Domain Requirements to PoC Validation and a Production Roadmap

This article’s stance This isn’t an article presenting “the one correct answer.” The goal is to provide material for judging which stack is right for your project, by comparing code that implements the same requirements across all 4 stacks.


2. Common Spec (kept fully identical across the 4 stacks)

All 4 implementations satisfy the following spec.

Features

  • 6 pages: Home / Account / Loan / Foreign Currency / Investment / KYC
  • Multiple API operation sections per page (e.g., the account page has 5 sections: “list, get balance, deposit, withdraw, transaction history”)
  • The Bearer Token input field at the top of the screen sets the API auth token
  • Navigation lets you move between pages
  • Pressing a button hits the API and renders the result on screen

Backend it connects to

  • The same Spring Boot API (/api/v1/accounts, /api/v1/loans, …)
  • Responses are JSON; errors are unified as an HTTP status + body structure

What was NOT kept identical (the points of differentiation)

  • CSS appearance (intentionally different for the external UI vs. internal UI)
  • Internal implementation (following each framework’s own conventions)

In other words, I built a comparison baseline where “the screen and functionality are the same, only the internals differ across 4 versions.”

The "Account" screen this article dives into — the Vanilla HTML external UI version. All 4 stacks have the same functionality

The account screen (above) has 5 sections: “get account list, get balance, deposit, withdraw, transaction history.” This same screen with the same functionality, implemented separately across 4 stacks, is what this article compares.


3. Overview of the 4 Stacks’ Structure

First, a quick look at each one’s “minimal setup.”

Vanilla HTML

banklink-web-vanilla-html/
├─ index.html         ← top page
├─ accounts.html      ← account page
├─ loans.html         ← loan page
├─ ... (4 other pages)
├─ common-external.js ← token management + shared binding
└─ shared/
    ├─ api/client.js  ← fetch wrapper
    └─ common.js      ← bindAction / renderResponse

No build tools. .html can be opened directly in a browser (or served via nginx). JS is imported with <script type="module">.

Vue

banklink-web-vue/
├─ vite.config.ts
├─ index.html         ← SPA entry
└─ src/
    ├─ main.ts         ← createApp + mount
    ├─ App.vue         ← layout + RouterView
    ├─ router/index.ts ← Vue Router config
    ├─ api/client.ts   ← fetch wrapper (TypeScript)
    └─ views/
        ├─ HomeView.vue
        ├─ AccountsView.vue   ← account page
        ├─ ... (4 other pages)

npm run build generates static files into dist/ → served via nginx.

React

banklink-web-react/
├─ vite.config.ts
├─ index.html
└─ src/
    ├─ main.tsx           ← createRoot + render
    ├─ App.tsx            ← all 6 pages consolidated into one file (since it's small)
    └─ ... (shared/api/client.ts)

Similar to Vue, but all pages are written into App.tsx (minimizing the component count).

Thymeleaf

banklink-web-thymeleaf/
├─ pom.xml
└─ banklink-external-web-thymeleaf/
    └─ src/main/
        ├─ java/com/y104autumn/banklink/external/
        │   ├─ BanklinkExternalApplication.java
        │   ├─ controller/
        │   │   ├─ ExternalTopPageController.java
        │   │   ├─ AccountsController.java
        │   │   └─ ... (4 other pages)
        │   ├─ service/
        │   │   ├─ AccountsService.java   ← calls the API via RestClient
        │   │   └─ ...
        │   └─ form/
        │       ├─ AccountsForm.java      ← for @ModelAttribute
        │       └─ ...
        └─ resources/templates/
            ├─ index.html
            ├─ accounts.html              ← with th:field
            └─ ...

mvn package produces an executable jar → started with java -jar or Docker.

Comparing the Number of Config Files

Whole projectFiles needed for one page
Vanilla HTML~10 files1 (accounts.html only)
Vue~15 files1 (AccountsView.vue)
React~5 files1 (a function inside App.tsx)
Thymeleaf~25 files3 (Controller + Service + Form + template)

Thymeleaf has a lot of boilerplate for splitting MVC into three layers. It has the most files, but each file’s role is clear.

The 6 Pages I Built (reference screenshots)

The 5 pages besides the account screen all have the same functionality across all 4 stacks. For reference, here are the screens from the Vanilla HTML version.

Top screen (external UI) — token input + navigation + links to each page

Loan screen — API operation sections such as application, balance inquiry, payment simulation

Foreign currency screen — operations such as rate lookup and executing an exchange

Investment screen — operations such as portfolio and buy/sell orders

KYC screen — operations such as identity verification status and document submission

Differentiating the External UI and Internal UI Look

I took the policy of “same functionality, but intentionally different appearance between external UI and internal UI.” The internal UI (closer to a staff terminal) looks like this.

Internal UI top screen (Vanilla HTML version) — intentionally differentiated to look closer to a staff terminal

This article’s comparison uses the external UI as its subject, but each stack implements both an external and internal set, structured so that swapping just the CSS covers both.


4. Differences in Project Structure and Build

Vanilla HTML — no build config

No package.json, no tsconfig.json. Just open the file directly in a browser, or serve it statically with nginx.

# Development: open directly in a browser
open accounts.html

# Production: place it in nginx's document root
nginx -c nginx-external.conf

No dependency packages, no build process, no node_modules.

Vue — Vite + TypeScript

// package.json (main parts)
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.4.0",
    "vue-router": "^4.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "typescript": "^5.5.0",
    "vite": "^5.3.0",
    "vue-tsc": "^2.0.0"
  }
}
npm install      # creates node_modules
npm run dev      # hot-reload dev server at http://localhost:5173
npm run build    # generates static files into dist/

React — Vite + TypeScript (the same Vite as Vue)

{
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build"
  },
  "dependencies": {
    "react": "^18.3.0",
    "react-dom": "^18.3.0",
    "react-router-dom": "^6.24.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.3.0",
    "typescript": "^5.5.0",
    "vite": "^5.3.0"
  }
}

Almost the same feel as Vue to operate. The only difference is @vitejs/plugin-vue vs. @vitejs/plugin-react.

Thymeleaf — Maven + Spring Boot

<!-- pom.xml (main parts) -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.4.5</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
</dependencies>
mvn clean package      # generates target/*.jar
java -jar target/banklink-external-web-thymeleaf.jar  # start it

Requires a JVM. The jar generated under target/ has Tomcat embedded too, so no extra application server is needed.

How the Build Time Feels

StackInitial install etc.Production buildStartup time
Vanilla HTML0 seconds0 secondsInstant
Vue30–60s (npm install)5–15showever long nginx takes to start
React30–60s5–15showever long nginx takes to start
Thymeleaf30–120s (Maven dependency download)20–40s (mvn package)JVM startup (5–15s)

5. Implementing the Same Screen (the Account Page) in 4 Stacks

This is the heart of this article. Let’s go through code that implements the exact same 5 sections — get account list, get balance, deposit, withdraw, transaction history — written 4 different ways.

The Vanilla HTML Version

HTML and JS live together in the same accounts.html (as a module via <script type="module">).

<!-- accounts.html (excerpt: account list and deposit parts) -->
<section class="section-card">
  <h2>Get Account List</h2>
  <button id="accounts-list-btn">GET /api/v1/accounts</button>
  <div id="accounts-list-response"></div>
</section>

<section class="section-card">
  <h2>Deposit</h2>
  <label>accountId<input id="deposit-account-id" value="ACC-0001" /></label>
  <label>amount<input id="deposit-amount" type="number" value="10000" /></label>
  <label>Idempotency-Key<input id="deposit-key" value="dep-key-001" /></label>
  <button id="accounts-deposit-btn">POST /api/v1/accounts/{id}/deposit</button>
  <div id="accounts-deposit-response"></div>
</section>

<script type="module">
  import { bindAction, numberValue, value } from "./common-external.js";

  // maps a button id to the id of the element that displays its response
  bindAction("accounts-list-btn", "accounts-list-response", () => ({
    method: "GET",
    path: "/api/v1/accounts",
  }));

  bindAction("accounts-deposit-btn", "accounts-deposit-response", () => ({
    method: "POST",
    path: `/api/v1/accounts/${value("deposit-account-id")}/deposit`,
    idempotencyKey: value("deposit-key"),
    body: {
      amount: numberValue("deposit-amount"),
      currency: value("deposit-currency"),
      reference: value("deposit-reference"),
    },
  }));
</script>

Characteristics:

  • HTML elements are identified by id → referenced from JS via document.getElementById (done inside the bindAction function)
  • Input values are read on demand with the value("input-id") helper (not reactive — it reads “the value at the moment of the click”)
  • Results are rendered as a string via innerHTML

The Vue Version

<!-- AccountsView.vue (script setup + template) -->
<script setup lang="ts">
import { inject, ref } from "vue";
import type { Ref } from "vue";
import { requestApi } from "../api/client";

const token = inject<Ref<string>>("token")!;
function fmt(r: unknown) { return JSON.stringify(r, null, 2); }

// account list
const listRes = ref<string>("");
const listStatus = ref<number | null>(null);
async function getAccounts() {
  const r = await requestApi({ method: "GET", path: "/api/v1/accounts", token: token.value });
  listStatus.value = r.status; listRes.value = fmt(r.body);
}

// deposit
const depId = ref("ACC-0001"), depAmount = ref(10000),
      depKey = ref("dep-key-001"), depCurrency = ref("JPY"), depRef = ref("TEST-DEP-001");
const depRes = ref(""); const depStatus = ref<number | null>(null);
async function deposit() {
  const r = await requestApi({
    method: "POST",
    path: `/api/v1/accounts/${depId.value}/deposit`,
    token: token.value,
    idempotencyKey: depKey.value,
    body: { amount: depAmount.value, currency: depCurrency.value, reference: depRef.value },
  });
  depStatus.value = r.status; depRes.value = fmt(r.body);
}
</script>

<template>
  <section class="section-card">
    <h2>Get Account List</h2>
    <button @click="getAccounts">GET /api/v1/accounts</button>
    <pre v-if="listStatus !== null">HTTP {{ listStatus }}\n{{ listRes }}</pre>
  </section>

  <section class="section-card">
    <h2>Deposit</h2>
    <label>accountId<input v-model="depId" /></label>
    <label>amount<input v-model.number="depAmount" type="number" /></label>
    <label>Idempotency-Key<input v-model="depKey" /></label>
    <button @click="deposit">POST /api/v1/accounts/{id}/deposit</button>
    <pre v-if="depStatus !== null">HTTP {{ depStatus }}\n{{ depRes }}</pre>
  </section>
</template>

Characteristics:

  • Declares reactive variables with ref(), two-way bound with v-model
  • Gets the shared token from the parent with inject<Ref<string>>("token")
  • Conditionally branches the response display with v-if, embeds variables with {{ }}

The React Version

// the AccountsPage function inside App.tsx (excerpt)
function AccountsPage({ token }: PageProps) {
  const [listResponse, setListResponse] = useState<ApiResult | null>(null);
  const [depositResponse, setDepositResponse] = useState<ApiResult | null>(null);

  const [depositAccountId, setDepositAccountId] = useState("ACC-0001");
  const [depositAmount, setDepositAmount] = useState("10000");
  const [depositKey, setDepositKey] = useState("dep-key-001");
  const [depositCurrency, setDepositCurrency] = useState("JPY");
  const [depositReference, setDepositReference] = useState("TEST-DEP-001");

  return (
    <div className="page-grid">
      <Section title="Get Account List">
        <button
          onClick={async () =>
            setListResponse(await requestApi({ method: "GET", path: "/api/v1/accounts", token }))
          }
        >
          GET /api/v1/accounts
        </button>
        <ResponsePanel response={listResponse} />
      </Section>

      <Section title="Deposit">
        <label>accountId
          <input value={depositAccountId} onChange={e => setDepositAccountId(e.target.value)} />
        </label>
        <label>amount
          <input type="number" value={depositAmount} onChange={e => setDepositAmount(e.target.value)} />
        </label>
        <label>Idempotency-Key
          <input value={depositKey} onChange={e => setDepositKey(e.target.value)} />
        </label>
        <button
          onClick={async () =>
            setDepositResponse(await requestApi({
              method: "POST",
              path: `/api/v1/accounts/${depositAccountId}/deposit`,
              token,
              idempotencyKey: depositKey,
              body: {
                amount: Number(depositAmount),
                currency: depositCurrency,
                reference: depositReference,
              },
            }))
          }
        >
          POST /api/v1/accounts/{"{id}"}/deposit
        </button>
        <ResponsePanel response={depositResponse} />
      </Section>
    </div>
  );
}

Characteristics:

  • A useState declared for every input field and every response (more declarations than Vue’s ref)
  • Every input needs its own onChange={e => setX(e.target.value)} handler written out (there’s no sugar syntax like Vue’s v-model)
  • Async handlers for buttons can be written directly inline inside JSX

The Thymeleaf Version

A 3-layer structure: template + controller + service + form object.

<!-- accounts.html (excerpt: account list and deposit parts) -->
<section class="section-card">
  <h2>Get Account List (server-side Form)</h2>
  <form id="accounts-form" th:action="@{/api/v1/accounts}" th:object="${accountsForm}" method="post">
    <input type="hidden" th:field="*{authorization}" />
    <button type="submit" class="action-button">POST /api/v1/accounts</button>
  </form>
  <div th:if="${accountsForm != null and accountsForm.apiResponse != null}" class="response-box">
    <p><strong>Status:</strong> <span th:text="${accountsForm.apiResponse.statusCode}">0</span></p>
    <pre th:text="${accountsForm.apiResponse.body}"></pre>
  </div>
</section>

<section class="section-card">
  <h2>Deposit</h2>
  <form th:action="@{/api/v1/accounts/deposit}" th:object="${accountsForm}" method="post">
    <input type="hidden" th:field="*{authorization}" />
    <label>accountId<input th:field="*{depositAccountId}" /></label>
    <label>amount<input th:field="*{depositAmount}" type="number" /></label>
    <label>Idempotency-Key<input th:field="*{depositIdempotencyKey}" /></label>
    <button type="submit" class="action-button">POST /api/v1/accounts/deposit</button>
  </form>
</section>
// AccountsController.java
@Controller
@RequiredArgsConstructor
public class AccountsController {
    private final AccountsService accountsService;

    @PostMapping("/api/v1/accounts")
    public String listAccounts(
            @ModelAttribute("accountsForm") AccountsForm form,
            Model model) {
        applyToken(form.getAuthorization());
        form.setApiResponse(accountsService.listAccounts());
        model.addAttribute("accountsForm", form);
        return "accounts";  // re-renders accounts.html
    }

    @PostMapping("/api/v1/accounts/deposit")
    public String deposit(
            @ModelAttribute("accountsForm") AccountsForm form,
            Model model) {
        applyToken(form.getAuthorization());
        Map<String, Object> body = Map.of(
            "amount", form.getDepositAmount(),
            "currency", form.getDepositCurrency(),
            "reference", form.getDepositReference()
        );
        form.setApiResponse(accountsService.deposit(
            form.getDepositAccountId(), body, form.getDepositIdempotencyKey()));
        model.addAttribute("accountsForm", form);
        return "accounts";
    }
}
// AccountsForm.java (excerpt)
@Data
public class AccountsForm {
    private String authorization;
    private String depositAccountId;
    private Integer depositAmount;
    private String depositCurrency;
    private String depositReference;
    private String depositIdempotencyKey;
    private ExternalApiResponse apiResponse;
    // ... (other fields)
}

Characteristics:

  • Each operation follows the traditional web flow of HTTP POST → server-side processing → page re-render
  • th:field="*{depositAmount}" two-way binds an input to a Form Object field (Spring passes the value both ways automatically)
  • Results are also rendered into HTML server-side → client-side JS is kept to a minimum

Comparing Line Counts (the whole account page)

Lines of codeLanguage / Files
Vanilla HTML~130 linesHTML + JS (1 file)
Vue~130 linesTypeScript + Template (1 file)
React~350 linesTypeScript JSX (1 function)
Thymeleaf~250 linesHTML + Java (spread across 5 files)

Why React is so much longer: each input field’s useState declaration and onChange handler, and each button’s async callback, all have to be written out individually. Without sugar syntax like Vue’s v-model, the amount of code tends to grow.


6. Differences in the API Client

All of them hit the same backend API (/api/v1/accounts, etc.), but the client implementation differs subtly.

Vanilla HTML / Vue / React — JavaScript’s fetch

// the Vue version's client.ts
export interface ApiResult {
  status: number;
  body: unknown;
}

export async function requestApi(opts: {
  method: string;
  path: string;
  token: string;
  idempotencyKey?: string;
  body?: unknown;
}): Promise<ApiResult> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${opts.token}`,
  };
  if (opts.idempotencyKey) {
    headers["Idempotency-Key"] = opts.idempotencyKey;
  }

  try {
    const res = await fetch(opts.path, {
      method: opts.method,
      headers,
      body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
    });
    const text = await res.text();
    const responseBody = text ? JSON.parse(text) : null;
    return { status: res.status, body: responseBody };
  } catch (e) {
    return { status: 0, body: String(e) };
  }
}

Vanilla / Vue / React are nearly identical. The only difference is whether type annotations exist (Vanilla is .js, Vue / React are .ts).

Thymeleaf — Spring Boot’s RestClient

// AccountsService.java
@Service
@RequiredArgsConstructor
public class AccountsService {
    private final RestClient restClient;
    private String token;

    public ExternalApiResponse listAccounts() {
        ResponseEntity<String> response = restClient
            .get()
            .uri("/api/v1/accounts")
            .header("Authorization", "Bearer " + token)
            .retrieve()
            .toEntity(String.class);

        return new ExternalApiResponse(
            response.getStatusCode().value(),
            true,
            response.getBody()
        );
    }
}

Java’s RestClient (Spring 6.1+) with its builder-style API. Compared to fetch, the types are stricter and IDE autocomplete works better.

Commonalities and Differences

AspectVanilla/Vue/ReactThymeleaf
LanguageJavaScript / TypeScriptJava
HTTP clientfetch (standard)RestClient (Spring standard)
Error handlingtry/catch + status codetry/catch + HttpClientErrorException
Type safetyTypeScript type annotationsType-safe by default in Java
Where the network call happensBrowser → APIServer → API

That last row, “where the network call happens,” is the most architecturally important difference.

  • SPA-style stacks (Vanilla/Vue/React) hit the API directly from the browser → CORS config is needed / API credentials reach the client
  • Thymeleaf has the server hit the API → no CORS needed / credentials stay contained on the server

7. Differences in State Management and Data Binding

How each stack holds onto “the on-screen input values, the fetched response, and the token” is where each one’s personality shows.

Vanilla HTML — the DOM is the source of state

// input value: always pulled from the DOM
const accountId = document.getElementById("balance-account-id").value;

// displaying a response: write the string straight into innerHTML
document.getElementById("balance-response").innerHTML = formatResponse(result);

// token: saved to localStorage
localStorage.setItem("banklink_token", token);

There’s no concept of “state.” You always go read the value from the DOM or localStorage. It’s simple, but keeping things in sync gets painful as the app grows.

Vue — reactive declarations with ref

const balanceId = ref("ACC-0001");    // a reactive variable holding a string value
const balanceRes = ref("");           // write {{ balanceRes }} in the template and it updates automatically

// on the template side, writing <input v-model="balanceId" />
// automatically reflects user input into balanceId.value (two-way binding)

The model is “declare a reactive variable → the template follows automatically.” The author doesn’t have to think about keeping state in sync.

React — declaring state hooks with useState

const [balanceId, setBalanceId] = useState("ACC-0001");
const [balanceRes, setBalanceRes] = useState<ApiResult | null>(null);

// on the template side: value and handler are passed separately
<input value={balanceId} onChange={e => setBalanceId(e.target.value)} />

The model is “declare a variable/setter pair → update via the setter → re-render.” There’s no sugar syntax like Vue’s v-model, so an onChange has to be written for each input field, which increases the amount of code.

Thymeleaf — a Form Object centralizes state on the server

@Data
public class AccountsForm {
    private String authorization;
    private String balanceAccountId;
    private ExternalApiResponse apiResponse;
    // ... other fields
}
<input th:field="*{balanceAccountId}" />

The server-side Form Object is the “single source of truth” for state. Every POST packs the input values into the Form Object, the Controller processes it → the result gets packed back into the Form Object and re-rendered. It’s the classic web model of “no state held on the client.”

Which Fits Which Situation

App characteristicsWell-suited stack
Small, single-screen, almost no stateVanilla HTML
Heavy use of reactive UI, lots of formsVue
Lots of component reuse, ecosystem mattersReact
Want state to live on the server (business-system style)Thymeleaf

8. Differences in Form Handling

How the deposit form (5 fields: accountId / amount / currency / reference / Idempotency-Key) is implemented.

Vanilla HTML

// give each input an id
<input id="deposit-account-id" value="ACC-0001" />

// read all the values together at click time
const body = {
  accountId: document.getElementById("deposit-account-id").value,
  amount: Number(document.getElementById("deposit-amount").value),
  // ...
};

Writing a helper function value() reduces the repetition, but the concept of a “form” only exists on the HTML/JS side.

Vue — done in 5 lines with v-model

<label>accountId<input v-model="depId" /></label>
<label>amount<input v-model.number="depAmount" type="number" /></label>
<label>currency<input v-model="depCurrency" /></label>
<label>reference<input v-model="depRef" /></label>
<label>Idempotency-Key<input v-model="depKey" /></label>

v-model.number converts to a numeric type automatically. The least amount of code to write.

React — useState + onChange per field

const [depositAccountId, setDepositAccountId] = useState("ACC-0001");
const [depositAmount, setDepositAmount] = useState("10000");
const [depositCurrency, setDepositCurrency] = useState("JPY");
// ... and so on for each field

<input value={depositAccountId} onChange={e => setDepositAccountId(e.target.value)} />
<input type="number" value={depositAmount} onChange={e => setDepositAmount(e.target.value)} />
// ...

5 fields = useState × 5 + onChange × 5. An external library like react-hook-form can cut this down, but this article compares “plain React.”

Thymeleaf — th:field binds automatically to the Form Object

<form th:action="@{/api/v1/accounts/deposit}" th:object="${accountsForm}" method="post">
  <input type="hidden" th:field="*{authorization}" />
  <label>accountId<input th:field="*{depositAccountId}" /></label>
  <label>amount<input th:field="*{depositAmount}" type="number" /></label>
  <label>Idempotency-Key<input th:field="*{depositIdempotencyKey}" /></label>
  <button type="submit">Submit</button>
</form>

A single th:field auto-sets the name, id, and value attributes, and ties the field to the server-side Form Object. It feels close to Vue’s v-model.


9. Differences in Error and Loading Display

The display pattern when an API call fails.

Vanilla HTML

function renderResponse(targetId, response) {
  const target = document.getElementById(targetId);
  const badgeClass = response.status >= 200 && response.status < 300 ? "ok" : "err";
  target.innerHTML = `
    <div class="response-panel">
      <span class="badge ${badgeClass}">HTTP ${response.status}</span>
      <pre>${escapeHtml(JSON.stringify(response.body, null, 2))}</pre>
    </div>
  `;
}

Escaping has to be done manually. Either write your own escapeHtml function, or use textContent.

Vue / React — automatic escaping + conditional rendering

<!-- Vue -->
<div v-if="status !== null" class="response-panel">
  <span :class="status < 400 ? 'badge-ok' : 'badge-err'">HTTP {{ status }}</span>
  <pre>{{ response }}</pre>
</div>
// React
{response && (
  <div className="response-panel">
    <span className={response.status < 400 ? "badge-ok" : "badge-err"}>HTTP {response.status}</span>
    <pre>{JSON.stringify(response.body, null, 2)}</pre>
  </div>
)}

Embedding a value with {{ }} or {} gets it auto-escaped. There’s no need to think about XSS.

Thymeleaf — th:text auto-escapes

<div th:if="${accountsForm.apiResponse != null}" class="response-box">
  <p>Status: <span th:text="${accountsForm.apiResponse.statusCode}"></span></p>
  <pre th:text="${accountsForm.apiResponse.body}"></pre>
</div>

th:text also auto-escapes. Using th:utext un-escapes it (an XSS risk).


10. Differences in Startup and Deployment Setup

Vanilla HTML — served statically by nginx

# nginx-external.conf
server {
  listen 8080;
  root /app/banklink-external-web-vanilla-html;
  index index.html;

  location /api/ {
    proxy_pass http://banklink-api:8080;  # reverse-proxied to the API
  }
}
FROM nginx:alpine
COPY banklink-web-vanilla-html /app/
COPY nginx-external.conf /etc/nginx/conf.d/default.conf

The minimal setup. HTML/JS/CSS served as-is.

Vue / React — Vite build → served by nginx

# build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build   # generates dist/

# serve stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-external.conf /etc/nginx/conf.d/default.conf

The build output gets placed into nginx. The only difference from Vanilla is the added build stage.

Thymeleaf — an executable Spring Boot jar

FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests

FROM eclipse-temurin:21-jre-alpine
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Requires a JVM. The container is dozens of MB plus the JVM (around 200 MB).

Comparing Image Sizes

Container image sizeStartup time
Vanilla HTML (nginx)~25 MB< 1 second
Vue (served via nginx)~30 MB< 1 second
React (served via nginx)~30 MB< 1 second
Thymeleaf (Spring Boot + JVM)~200 MB5–15 seconds

If minimizing size is the priority, the nginx-served group (the first three) has the advantage. Spring Boot is heavier, but in exchange it can handle server-side logic, API proxying, and auth integration all within the same process.


11. Side-by-Side Comparison Table

AspectVanilla HTMLVue 3ReactThymeleaf
LanguageJSTSTS (TSX)Java
BuildNot neededViteViteMaven
Learning costLowMediumMedium–HighMedium (low if you already know Java)
Lines of code (account page)~130~130~350~250 (spread out)
State modelDOMReactive refuseStateForm Object (server)
Form bindingManualv-modelonChange per fieldth:field
XSS auto-escapeManualAutomaticAutomaticAutomatic
Where API calls happenBrowserBrowserBrowserServer
Where credentials livelocalStoragelocalStoragelocalStorageServer session
CORS requiredYesYesYesNo
Dependency package count0~10~10~20 (Maven)
Container image~25 MB~30 MB~30 MB~200 MB
Startup timeInstantInstantInstant5–15s (JVM)
Dynamic UIWeakStrongStrongWeak (assumes reload)
EcosystemNoneMid-sizedHugeThe Spring ecosystem

12. How to Choose — Strengths, Weaknesses, and Decision Points for the 4 Stacks

Vanilla HTML

Strengths:

  • Zero dependencies, zero build, zero learning cost (just HTML/JS basics)
  • Minimal delivery cost, minimal container image
  • Unbeatable for “build a working demo in 30 minutes”

Weaknesses:

  • State management breaks down as the app grows (the limits of direct DOM manipulation)
  • No TypeScript type safety
  • Bad fit for reactive UI

When to choose it:

  • API verification tools, internal test forms, simple dashboards
  • “I don’t want a framework, I just want a screen”
  • A prototype you plan to replace with an SPA later

Vue

Strengths:

  • Sugar syntax like v-model means less code than React
  • Template syntax is close to HTML, easy to read even for beginners
  • Single-file components (.vue) fit logic/template/style into one file

Weaknesses:

  • Smaller ecosystem than React
  • Fewer hiring/job listings than React

When to choose it:

  • Business SPAs with lots of forms and input UI
  • “If you’re stuck deciding on a framework, try Vue first”
  • Projects where the web engineering hiring pool is mostly domestic

React

Strengths:

  • A huge ecosystem (UI libraries, state management, testing, mobile)
  • Plays well with TypeScript (rich type definitions)
  • The most job postings in the hiring market

Weaknesses:

  • Tends to need more lines than Vue to write the same functionality
  • Learning to use useState, useEffect, useMemo, useCallback correctly is its own cost
  • Understanding function component re-renders is essential

When to choose it:

  • Large-scale SPAs, or with Next.js / React Native in view
  • Organizations that prioritize engineer hiring
  • You want to reuse UI libraries (MUI / Mantine / shadcn, etc.)

Thymeleaf

Strengths:

  • Client-side JS can be kept to a minimum (business-system style)
  • Credentials and API tokens stay contained on the server (an advantage under strict security requirements)
  • Full use of the Spring Security / Spring Boot ecosystem
  • Java engineers can build web UIs with skills they already have

Weaknesses:

  • Every page transition needs a server round trip (feels sluggish next to an SPA)
  • Not suited to reactive UI
  • The JVM container image is heavy

When to choose it:

  • Business systems / internal admin screens
  • Security requirements mean you don’t want to expose API keys to the client
  • Organizations with lots of Java engineers, already on Spring Boot
  • “Submit a form, show the result” is enough — you don’t need a rich UI

Decision Flow

Q1. Does the UI need to be reactive?
   ├─ No (submit-a-form-and-show-the-result is enough)
   │   ├─ Want to aggregate state server-side → Thymeleaf
   │   └─ Want to keep it lightweight → Vanilla HTML
   └─ Yes (reactive UI is needed)
       ├─ Ecosystem/hiring matters → React
       └─ Want less code / a lower learning cost → Vue

13. Summary and Takeaways

  • The 4 stacks aren’t “correct vs. incorrect” — which one has the advantage shifts with the project’s requirements. Lining up the same spec across all of them is what makes the differences visible.
  • Looking only at lines of code, Vanilla / Vue come out low and React comes out high. But React more than makes up for it in hiring market and ecosystem.
  • Thymeleaf isn’t obsolete — it still often fits the security and operational requirements of business systems today.
  • The conclusion is: “pick your framework by working backward from the requirements.” Picking based on trends bites you a few years later.
  • Running all 4 implementations in parallel made it clear that the difference in build tooling (Vite vs. Maven vs. none) makes a huge difference to the experience on day one of a project. At the prototype stage, the Vanilla / Vite side is overwhelmingly faster.

I hope this comparison serves as one piece of material when you’re making that choice.

Feel free to send a message

Job offers, project referrals, feedback, questions — anything is welcome. I sincerely hope to connect with people who share high ambitions. I will keep taking on the challenges I have staked my life on. Thank you very much.