Comparing the same business web UI implemented using Vanilla HTML / Vue / React / Thymeleaf — Differences among the 4 stacks and selection guidelines
What you can learn from this article
- What are the differences in code when implementing Business Web UI with the same specifications using four stacks: Vanilla HTML / Vue 3 / React / Thymeleaf (with code examples)
- Differences in project configuration/build (package.json / Vite / pom.xml / plain HTML)
- Comparison by perspective of API client, state management, form processing, error display, and deployment
- Judgment axis for “which stack to choose in which situation”
Target audience
- Those who are wondering which framework to use among Vanilla / Vue / React / Thymeleaf
- Even if you have used each product individually, you want to know what the differences are when they are lined up with the same specifications.
- Those who want to compare stacks on the premise of creating a business-related web UI (form + API call + result display)
Operating environment
| Item | Version |
|---|---|
| Vanilla HTML | No build required (module script) |
| Vue | 3.x + Vite 5.x + TypeScript 5.x + Vue Router 4.x |
| React | 18.x + Vite 5.x + TypeScript 5.x + React Router 6.x |
| Thymeleaf | Spring Boot 3.x + Java 21 + Thymeleaf 3.x |
1. Introduction
To be honest, I wasn’t originally planning on writing a comparison article.
While developing the backend for banklink-service, I thought, I want a screen where I can check whether the API is working,'' so I randomly assembled it using Vanilla HTML, which is the quickest way to create it on the spot. **It's a throwaway UI that looks like a picture of "just move for now"**.When proceeding with development, I thought, I’ll eventually replace it with Vue or React and make it a full-fledged development template.” I was planning to start the main implementation of the front end from there.
**However, the moment I tried to move my hand, I suddenly noticed something. **
With this amount of processing (form + API call + displaying results), it won’t take much time to implement it using all 4 stacks, including Thymeleaf. Moreover, if they all have the same functions, wouldn’t it be a good subject to compare the differences in each technology side by side? ** and.
As far as development work goes, this is completely off track. Before I knew it, my plan to “create one template” turned into “implementing in parallel with 4 stacks and comparing them.”
However, when I wrote it down, I was able to sort out the decision-making criteria for selecting a technology myself, and it also seemed like it would be useful for people who were unsure about the same situation, so I decided to leave it as an article. **This article is a byproduct of that “fun digression.” **
So, the main topic
When creating a business-related web UI, the first decision you have to make is “Which of Vanilla HTML / Vue / React / Thymeleaf is the best choice?” Each one is often talked about on its own, but there are surprisingly few articles that explain “What’s the difference when you line up four of them with the same specs?” with actual code.
Therefore, we implemented a business UI with the exact same specifications using 4 stacks in parallel using the banking API wrapper service banklink-service (a personal practice project). This article is a record of that comparison.> 🔗 Related articles: banklink-service’s backend design (requirements definition → basic design → detailed design → PoC, 5 domain design decisions, DDD / ArchUnit / financial quality implementation) is summarized in a separate article. This article is a comparison of 4 stacks on the front end side that connects to that back end.
Stance of this article This is not an article that presents “one correct answer.” The purpose is to provide material for comparing code written in 4 stacks for the same requirements and determining which one is appropriate for your project.
2. Common specifications (assuming 4 stacks are fully aligned)
All 4 implementations meet the following specifications:
Features
- Page 6: Home / Account / Loan / Foreign currency / Investment / KYC
- Multiple API operation sections on each page (Example: Account page has 5 sections: “List acquisition, Balance acquisition, Deposit, Withdrawal, Transaction history”)
- Set the API authentication token from the Bearer Token input field at the top of the screen.
- Navigation allows you to go back and forth between pages
- Press the button to access the API and display the result on the screen.
Backend to connect to
- Same Spring Boot API (
/api/v1/accounts,/api/v1/loans, …) - Responses are JSON, errors are unified with HTTP status + body structure
Points that are not aligned (points of differentiation)
- CSS appearance (intentionally changed in external UI/internal UI)
- Internal implementation (according to framework characteristics)
In other words, we created a comparison base of “same screen and functions, only 4 different contents”.
The account screen (above) has 5 sections: “Get account list, get balance, deposit, withdraw, and transaction history.” This same screen and same function implemented separately in 4 stacks is the subject of comparison in this article.
3. 4 stack configuration overview
First, let’s take a look at each “minimum configuration”.
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 + binding common
└─ shared/
├─ api/client.js ← fetch wrapper
└─ common.js ← bindAction / renderResponse
No build tools. .html can be opened directly in the browser (or distributed with nginx). Import JS 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 settings ├─ api/client.ts ← fetch wrapper (TypeScript) └─ views/ ├─ HomeView.vue ├─ AccountsView.vue ← Accounts page ├─ … (4 other pages)
Generate static files in `dist/` with `npm run build` → Distribute with nginx.
### React
banklink-web-react/ ├─vite.config.ts ├─ index.html └─ src/ ├─ main.tsx ← createRoot + render ├─ App.tsx ← Consolidate all 6 pages into one file (because it is small) └─ … (shared/api/client.ts)
It is similar to Vue, but all pages are written in `App.tsx` (minimizing the number of components).
### 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 ← API call with RestClient
│ │ └─ ...
│ └─ form/
│ ├─ AccountsForm.java ← For @ModelAttribute
│ └─ ...
└─ resources/templates/
├─ index.html
├─ accounts.html ← with th:field
└─ ...
Generate jar that can be executed with mvn package → Start with java -jar or Docker.
Comparison of number of configuration files| | Entire project | Files required for one-page implementation |
|---|---|---|
| Vanilla HTML | Approximately 10 files | 1 (accounts.html only) |
| Vue | Approximately 15 files | 1 (AccountsView.vue) |
| React | Approximately 5 files | 1 (functions in App.tsx) |
| Thymeleaf | Approximately 25 files | 3 (Controller + Service + Form + template) |
Thymeleaf has a lot of standard code for dividing MVC into three layers. Although it has the largest number of files, the role of each file is clear.
6 implemented pages (reference screenshots)
All 5 pages other than the account screen have the same functions in all 4 stacks. For your reference, the screens of the Vanilla HTML version are listed below.





Visual differentiation of external UI and internal UI
We have adopted the policy of “functions are the same, but the appearance is intentionally different between external UI and internal UI”, and the internal UI (closer to business terminals) looks like this.
The comparison in this article is based on external UI, but each stack implements two sets: external/internal, and the structure is such that it can support both by replacing only the CSS.
4. Differences in project configuration/build
Vanilla HTML — no build settings
Neither package.json nor tsconfig.json. Just open the file directly in your browser or serve it statically with nginx.
# Development: Open directly in browser
open accounts.html
# Production: Place in nginx document root
nginx -c nginx-external.conf
No dependent packages, no build process, no node_modules.
Vue — Vite + TypeScript
// package.json (main part)
{
"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"
}
}
```````bash
npm install # create node_modules
Hot reload development with npm run dev # http://localhost:5173
npm run build # generate static files in dist/
React — Vite + TypeScript (Same as Vue, Vite)
{
"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 operational feel as Vue. The only difference is @vitejs/plugin-vue vs @vitejs/plugin-react.
Thymeleaf — Maven + Spring Boot
<!-- pom.xml (main part) -->
<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 # generate target/*.jar
java -jar target/banklink-external-web-thymeleaf.jar # start
Requires JVM. The jar generated by target/ also has Tomcat embedded, so no additional application server is required.
Comparison of build time experience
| Stack | Initial install etc. | Production build | Startup time |
|---|---|---|---|
| Vanilla HTML | 0 seconds | 0 seconds | Instant |
| Vue | 30-60 seconds (npm install) | 5-15 seconds | nginx startup minutes |
| React | 30-60 seconds | 5-15 seconds | nginx startup minutes |
| Thymeleaf | 30 to 120 seconds (Maven-dependent DL) | 20 to 40 seconds (mvn package) | JVM startup minutes (5 to 15 seconds) |
5. Implement the same screen (account page) with 4 stacks
This is the focus of this article. Let’s look at the code written in 4 different ways for the same 5 sections: “account list acquisition, balance acquisition, deposit, withdrawal, transaction history” in order.### Vanilla HTML version
HTML and JS coexist in the same accounts.html (module in <script type="module">).
<!-- accounts.html (excerpt of account list and deposit part) -->
<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";// Mapping button id and response display destination id
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>
Features:
- Identify HTML element with
id→ Reference withdocument.getElementByIdfrom JS (done inbindActionfunction) - Input values are obtained each time using the
value("input-id")helper (reads the “value at the time of click” instead of reactively) - The result is drawn as a string with
innerHTML
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>
Features:
- Declare reactive variables with
ref(), two-way binding withv-model - Get shared token from parent with
inject<Ref<string>>("token") - Conditionally branch response display with
v-if, embed variables with{{ }}
React version
// AccountsPage function of 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="Acquisition of 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>
);
}
````**Features**:
- Declare the status with `useState` for each input field and each response (**more declarations than `ref` of Vue**)
- You need to write an event handler for `onChange={e => setX(e.target.value)}` every time (there is no sugar syntax like `v-model` in Vue)
- You can write async handlers for buttons directly inside `JSX`
### Thymeleaf version
3-layer structure: template + controller + service + form object.
```html
<!-- accounts.html (excerpt of account list and deposit part) -->
<section class="section-card">
<h2>Acquisition of 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-render 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";
}
}
```````java
// 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)
}
Features:
- Traditional web flow of HTTP POST → Server processing → Page redraw for each operation
- Two-way binding between Form Object field and input using
th:field="*{depositAmount}"(Spring automatically passes the value) - Results are also rendered as HTML on the server side → JS on the client side is minimal
Comparison of number of lines of code (entire account page)
| Lines of code | Language/File | |
|---|---|---|
| Vanilla HTML | Approximately 130 lines | HTML + JS (1 file) |
| Vue | Approximately 130 lines | TypeScript + Template (1 file) |
| React | Approximately 350 lines | TypeScript JSX (1 function) |
| Thymeleaf | Approximately 250 lines | HTML + Java (spread over 5 files) |
6. Differences between API clients
They all hit the same backend API (/api/v1/accounts, etc.), but the client implementations are slightly different.
Vanilla HTML / Vue / React — JavaScript fetch
// Vue version 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) };
}
}
Almost identical in Vanilla/Vue/React. The only difference is the presence or absence of type annotations (Vanilla is .js, Vue / React is .ts).
Thymeleaf — Spring Boot 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()
);
}
}
Builder notation in Java RestClient (Spring 6.1+). Compared to fetch, the type is stricter and IDE completion is more effective.
Commonalities and differences
| Perspective | Vanilla/Vue/React | Thymeleaf |
|---|---|---|
| Language | JavaScript / TypeScript | Java |
| HTTP Client | fetch (Standard) | RestClient (Spring Standard) |
| Error handling | try/catch + status code | try/catch + HttpClientErrorException |
| Type Safety | Type Annotations in TypeScript | Type Safety (Default) in Java |
| Network occurrence location | Browser → API | Server → API |
- For SPA systems (Vanilla/Vue/React), directly access the API from the browser → CORS settings are required / API authentication information is passed to the client
- Thymeleaf allows the server to access the API → CORS is not required / authentication information is stored within the server
7. Differences between state management and data binding
The individuality of each stack is determined by how it retains “input values on the screen, acquired responses, and tokens.”
Vanilla HTML — DOM is the source of state
// Input value: retrieve from DOM at any time
const accountId = document.getElementById("balance-account-id").value;
// Display response: write string directly in innerHTML
document.getElementById("balance-response").innerHTML = formatResponse(result);
// Token: save to localStorage
localStorage.setItem("banklink_token", token);
“State” has no concept. Always read the DOM or localStorage value. It’s simple, but as the app grows, it becomes difficult to synchronize.
Vue — Reactive declaration with ref
const balanceId = ref("ACC-0001"); // Reactive variable with string value
const balanceRes = ref(""); // Write {{ balanceRes }} in the template to update automatically// If you write <input v-model="balanceId" /> on the template side
// User input is automatically reflected in balanceId.value (two-way binding)
“Declaring reactive variable → template automatically follows” model. The writer does not have to be conscious of state synchronization.
React — Declare state hooks with useState
const [balanceId, setBalanceId] = useState("ACC-0001");
const [balanceRes, setBalanceRes] = useState<ApiResult | null>(null);
// Template side: Pass values and handlers separately
<input value={balanceId} onChange={e => setBalanceId(e.target.value)} />
“Declaring a variable-setter pair → Update via setter → Re-render” model. There is no syntax sugar like v-model in Vue, so you have to write onChange for each input field, which increases the amount of code.
Thymeleaf — Aggregate state on the server side with Form Object
@Data
public class AccountsForm {
private String authorization;
private String balanceAccountId;
private ExternalApiResponse apiResponse;
// ... other fields
}
<input th:field="*{balanceAccountId}" />
````**The Form Object on the server side is the "original copy of the state"**. Every time there is a POST, the input value is packed into the Form Object, and the Controller processes it → the result is packed into the Form Object and redrawn. A classic web model with no state on the client side.
### Unsuitable for each scene
| Nature of the app | Direct stack |
|---|---|
| Small scale, 1 screen, almost no condition | **Vanilla HTML** |
| Heavy use of reactive UI, many forms | **Vue** |
| High component reuse, ecosystem focus | **React** |
| I want to keep the state on the server side (business system) | **Thymeleaf** |
---
## 8. Differences in form processing
How to implement the deposit form (5 fields: accountId / amount / currency / reference / Idempotency-Key).
### Vanilla HTML
```javascript
// Give each input an id
<input id="deposit-account-id" value="ACC-0001" />
// Read the values all at once on click
const body = {
accountId: document.getElementById("deposit-account-id").value,
amount: Number(document.getElementById("deposit-amount").value),
// ...
};
You can reduce this repetition by writing a helper function value(), but the concept of “form” only exists on the HTML/JS side.
Vue — v-model takes only 5 lines```vue
Automatic conversion to numeric type with `v-model.number`. **The least amount of writing**.
### React — useState + onChange for each field
```tsx
const [depositAccountId, setDepositAccountId] = useState("ACC-0001");
const [depositAmount, setDepositAmount] = useState("10000");
const [depositCurrency, setDepositCurrency] = useState("JPY");
// ... 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 times + onChange 5 times. This can be omitted with an external library like react-hook-form, but in this article we will use “plain React” for comparison.### Thymeleaf — Automatic combination with Form Object with th:field
<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>
th:field Automatically sets the “name attribute, id attribute, and value attribute” with just one, and connects the Form Object and field on the server side. The writing experience is similar to Vue’s v-model.
9. Differences in error loading display
Display pattern when API fails.
Vanilla HTML```javascript
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> ;
}
**Requires manual escaping**. `escapeHtml` Prepare your own function or use `textContent`.
### Vue / React — auto-escaping + conditional rendering
```vue
<!-- Vue -->
<div v-if="status !== null" class="response-panel">
<span :class="status < 400 ? 'badge-ok' : 'badge-err'">HTTP {{ status }}</span>
<pre>{{ response }}</pre>
</div>
```````tsx
// 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>
)}
If you embed a value with {{ }} or {}, it will be auto-escaped. There is no need to be aware of XSS countermeasures.
Thymeleaf — auto escape with th:text
<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 is also automatically escaped. th:utext causes unescape (XSS risk).
10. Differences in startup/deployment configurations
Vanilla HTML — static delivery with 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; # API HelibaPro
}
}
```````dockerfile
FROM nginx:alpine
COPY banklink-web-vanilla-html /app/
COPY nginx-external.conf /etc/nginx/conf.d/default.conf
Minimal configuration. Deliver HTML/JS/CSS as is.
Vue / React — Vite build → nginx distribution
# build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY.
RUN npm run build # generate 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
Deploy build artifacts to nginx. The only difference from Vanilla is the addition of a build stage.
Thymeleaf — Spring Boot executable 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"]
JVM required. The container size is several tens of MB + JVM (around 200 MB).
Image size comparison| | Container image size | Startup time |
|---|---|---| | Vanilla HTML (nginx) | ~25 MB | < 1 second | | Vue (nginx delivery) | ~30 MB | < 1 second | | React (nginx delivery) | ~30 MB | < 1 second | | Thymeleaf (Spring Boot + JVM) | ~200 MB | 5-15 seconds |
If you prioritize weight reduction, the nginx distribution system (the former three) is advantageous. Although Spring Boot is heavy, it has the strength of being able to handle server-side logic, API proxy, authentication integration, etc. in the same process.
11. Horizontal comparison table| Perspective | Vanilla HTML | Vue 3 | React | Thymeleaf |
|---|---|---|---|---|
| Language | JS | TS | TS (TSX) | Java |
| Build | Not required | Vite | Vite | Maven |
| Learning cost | Low | Medium | Medium to high | Medium (low if you have already learned Java) |
| Number of code lines (account page) | Approx. 130 lines | Approx. 130 lines | Approx. 350 lines | Approx. 250 lines (distributed) |
| State Management Model | DOM | Reactive ref | useState | Form Object (server) |
| Form join | Manual | v-model | onChange Individual | th:field |
| XSS automatic escape | Manual | Automatic | Automatic | Automatic |
| API call location | Browser | Browser | Browser | Server |
| Where to find your credentials | localStorage | localStorage | localStorage | Server Session |
| CORS Required | Yes | Yes | Yes | No |
| Number of dependent packages | 0 | ~10 | ~10 | ~20 (Maven) |
| Container Image | ~25 MB | ~30 MB | ~30 MB | ~200 MB |
| Startup Time | Immediate | Immediate | Immediate | 5-15 seconds (JVM) |
| Dynamic UI | Weak | Strong | Strong | Weak (requires reloading) |
| Ecosystem | None | Medium | Huge | Spring Ecosystem |
12. How to choose — Strengths and weaknesses of 4 stacks and decision base
Vanilla HTMLStrengths:
- Zero dependencies, zero builds, zero learning costs (HTML/JS basics only)
- Minimum delivery cost/minimum container image
- Best for “creating a working demo in 30 minutes”
Weaknesses:
- State management breaks down as the app grows (limitation of direct DOM manipulation)
- No TypeScript type safety
- I don’t like reactive UI
Choose when:
- API operation confirmation tool, internal verification form, simple dashboard
- “I don’t need a framework, I just want a screen”
- Prototype to be replaced with SPA later
Vue
Strengths:
- Syntax sugar such as
v-modelLess code than React - Template syntax is similar to HTML and easy to read even for beginners
- Single file component (.vue) allows logic/template/style to fit into one file
Weaknesses:
- Smaller ecosystem compared to React
- Inferior to React in number of recruitment projects and human resources
Choose when:
- Business SPA has many forms/input UIs
- “If you’re having trouble choosing a framework, try Vue”
- Projects where the web engineer human resources market is mainly in Japan
React
Strengths:
- Huge ecosystem (UI library, state management, testing, mobile)
- Good compatibility with TypeScript (rich type definitions)
- Most job openings in the job market
Weaknesses:
- It tends to take more lines than Vue to write the same function.
- The cost of learning how to use
useState,useEffect,useMemo,useCallback - Requires understanding of function component re-rendering
Choose when:
- Looking to connect with large-scale SPA, Next.js / React Native
- Organizations that emphasize hiring engineers
- I want to reuse UI libraries (MUI / Mantine / shadcn, etc.)### Thymeleaf
Strengths:
- Client-side JS can be minimized (business system)
- Authentication information/API tokens are closed within the server (advantageous in situations with strict security requirements)
- Take full advantage of the Spring Security / Spring Boot ecosystem
- Java engineers can create web UIs using their existing skills
Weaknesses:
- Requires server round trip for each page transition (compared to SPA)
- Not suitable for reactive UI
- JVM container image is heavy
Choose when:
- Business system/management screen (for internal use)
- I don’t want to expose the API key to the client due to security requirements
- An organization with many Java engineers has already adopted Spring Boot
- Rather than a rich UI, “submit a form and display the results” is sufficient
Decision flow
Q1. Is UI reactivity necessary?
├─ No (Sending the form and displaying the results is sufficient)
│ ├─ I want to aggregate on the server side → Thymeleaf
│ └─ I want to make it lightweight → Vanilla HTML
└─ Yes (requires reactive UI)
├─ Focus on ecosystem and recruitment → React
└─ Want to reduce the amount of code/Low learning cost → Vue
13. Summary/Learning- 4 stacks are not “correct vs incorrect”, but advantages and disadvantages change depending on the project requirements. Differences become apparent when the same specifications are placed side by side.
- If you look only at the number of lines of code, Vanilla/Vue has less and React has more. However, React more than makes up for it in its recruitment market and ecosystem.
- Thymeleaf is not obsolete — It still often meets the security and operational requirements of business systems.
- “Select a framework by working backwards from the requirements” is the conclusion. If you choose something based on trends, you will have a hard time a few years later.
- After running 4 implementations in parallel, I realized that the difference in build tools (Vite vs. Maven vs. none) makes a big difference in the experience on the first day of project launch. At the prototype stage, the Vanilla/Vite system is overwhelmingly fast.
We hope that this comparison will be helpful when making a selection.