Building an End-User DVD Rental App — Vue 3 + Spring Boot Paired with the Admin App, with Article Map
Background
I previously built an admin app based on the PostgreSQL sample database dvdrental.
→ Building a DVD Rental Admin App with Spring Boot + Thymeleaf on top of the dvdrental Sample DB
That admin app let staff manage customer data, but there was no place for customers themselves to use. This end-user app was started to answer that question.
Tech Stack
| Layer | Technology |
|---|---|
| Backend | Spring Boot 3 + MyBatis + Spring Security |
| Frontend | Vue 3 + TypeScript + Vite |
| DB | PostgreSQL (dvdrental) |
| Payment | PayPay Dynamic QR Code, PAY.JP credit card |
| Deployment | Single JAR (frontend bundled into static/) |
The frontend is bundled into the Spring Boot JAR at build time, so the app is deployed as a single unit.
→ Bundling Vue3/React Frontend into Spring Boot for Automatic Serving
Public and Member Areas
The app is split into two areas:
| Area | Access | Content |
|---|---|---|
| Public | No login required | Film list, film detail |
| Member | Login required | My page, rental history, checkout |
Screen Structure — App.vue currentPage Management
This app does not use Vue Router. Screen transitions are managed by a currentPage ref in App.vue.
// App.vue (excerpt)
const currentPage = ref<
'films' | 'detail' | 'auth' | 'mypage' | 'checkout' | 'checkout-complete' | ...
>('films')
→ Design Decision: Managing Page State with a Simple currentPage in App.vue
Screen Overview
① Film List (FilmListView.vue)

The film list supports filtering by category, rental tier, and runtime, plus free-text search — all implemented with Vue 3 computed properties on the frontend.
→ Implementing Frontend Filter Search with Only Vue 3 computed
The left sidebar filter panel state is persisted in localStorage so it stays closed between sessions.
→ Persisting Sidebar Open/Close State with localStorage While Preventing Flash
② Film Detail (FilmDetailView.vue)

Film detail pages display cover images and preview videos generated by Gemini Ultra.
→ Generating Film Posters and Preview Videos with Gemini Ultra for the DVD Rental App
The detail page has two buttons: “Rent Now” and “Add to Cart”. The decision to separate these is a UX design choice.
<!-- FilmDetailView.vue (CTA buttons excerpt) -->
<template v-if="isRentalSection">
<button class="btn-primary" @click="emit('direct-checkout', film)">
Rent Now (Checkout)
</button>
<button v-if="isAuthenticated" class="btn-secondary"
@click="emit('add-to-cart', film)">
Add to Cart
</button>
</template>
→ Why EC Sites Should Separate “Add to Cart” and “Buy Now” — Design and Implementation
When navigating to the detail page, resetDetailViewport forces scroll to top and clears focus so the previous page’s scroll position doesn’t persist.
// FilmDetailView.vue (scroll/focus reset excerpt)
const resetDetailViewport = () => {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' })
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement) {
activeElement.blur()
}
}
watch(() => film.filmId, () => {
resetDetailViewport()
void resolveInitialMedia()
})
onMounted(() => {
resetDetailViewport()
void resolveInitialMedia()
})
→ Why I Added scrollTo(0, 0) and blur() on Detail Navigation
④ Login / Registration (AuthView.vue)

Login is a standard frontend → backend REST call. Spring Security’s filter chain is not used; password verification is done manually with BCryptPasswordEncoder.
// AuthController.java (login excerpt)
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
AuthMapper.AuthRecord record = authMapper.findByEmail(request.email());
if (record == null) {
// Return the same message whether email exists or not (enumeration prevention)
return ResponseEntity.status(401)
.body(new LoginResponse(false, "Invalid email or password.", ""));
}
if (!passwordEncoder.matches(request.password(), record.passwordHash())) {
return ResponseEntity.status(401)
.body(new LoginResponse(false, "Invalid email or password.", ""));
}
authMapper.updateLastLoginAt(record.customerId());
return ResponseEntity.ok(new LoginResponse(true, "Login successful.", "/films"));
}
⑤ Member Registration (MemberRegistrationView.vue)

A 3-step flow: Input → Confirm → Complete. The confirm screen displays entered values read-only and allows going back to edit.
Step Management
Screen transitions are managed by currentPage ref in App.vue rather than Vue Router. Form values are temporarily saved in registerDraft and passed to the confirm screen as props.
// App.vue (registration flow excerpt)
const registerDraft = ref<RegisterRequest | null>(null)
const openMemberRegisterConfirm = (payload: RegisterRequest) => {
registerDraft.value = payload // temporarily save input
currentPage.value = 'member-register-confirm'
}
<!-- App.vue (template excerpt) -->
<MemberRegistrationView
v-else-if="currentPage === 'member-register'"
@confirm="openMemberRegisterConfirm"
@back-auth="currentPage = 'auth'"
/>
<MemberRegistrationConfirmView
v-else-if="currentPage === 'member-register-confirm' && registerDraft"
:payload="registerDraft"
@back-edit="currentPage = 'member-register'"
@registered="currentPage = 'auth'"
/>
MemberRegistrationView only emits the input values to the parent via emit('confirm', { ...form }); it doesn’t know where to navigate next. Navigation control is centralized in the parent component.
Input Validation
Password validation is done inside @submit.prevent="goConfirm". Rather than relying on HTML required, TypeScript explicitly checks password match, length, and alphanumeric mixing.
// MemberRegistrationView.vue (validation excerpt)
const goConfirm = () => {
passwordError.value = ''
if (form.password !== form.passwordConfirmation) {
passwordError.value = 'Passwords do not match.'
return
}
if (form.password.length < 8) {
passwordError.value = 'Password must be at least 8 characters.'
return
}
if (!/[a-zA-Z]/.test(form.password) || !/[0-9]/.test(form.password)) {
passwordError.value = 'Password must contain both letters and numbers.'
return
}
emit('confirm', { ...form })
}
API Call from Confirm Screen
The confirm screen (MemberRegistrationConfirmView.vue) holds the “Register” button and calls the API from there. A loading flag prevents double submission; errors are displayed inline.
// MemberRegistrationConfirmView.vue (registration excerpt)
const submit = async () => {
loading.value = true
try {
const result = await registerMember(props.payload)
message.value = result.message
emit('registered') // parent switches to 'auth' page
} catch (error) {
isError.value = true
message.value = error instanceof Error ? error.message : 'Registration failed.'
} finally {
loading.value = false
}
}
⑥ Cart Drawer (CartDrawer.vue)
A drawer UI that slides in from the right. It opens when the cart icon is clicked on film list or detail pages.

Cart state is persisted in localStorage, so contents are retained even after closing the page.
// useCart.ts (add to cart excerpt)
function addToCart(film: PublicFilmSummary): boolean {
ensureInitialized()
const exists = itemsRef.value.some((item) => item.filmId === film.filmId)
if (exists) return false // prevent duplicate adds
const next: CartItem = { ...film, addedAt: new Date().toISOString() }
itemsRef.value = [next, ...itemsRef.value]
saveToStorage(itemsRef.value)
return true
}
⑦ My Page (MyPageView.vue)

My page data is fetched by JOINing existing dvdrental tables. Rental status is calculated dynamically using SQL CASE expressions.
// MyPageMapper.java (rental history excerpt)
@Select("""
SELECT
r.rental_id,
f.title AS film_title,
CASE
WHEN r.return_date IS NOT NULL THEN 'RETURNED'
WHEN r.rental_date < CURRENT_TIMESTAMP - INTERVAL '7 days' THEN 'OVERDUE'
ELSE 'OPEN'
END AS rental_status,
COALESCE(SUM(p.amount), 0) AS billed_amount
FROM rental r
JOIN inventory i ON i.inventory_id = r.inventory_id
JOIN film f ON f.film_id = i.film_id
LEFT JOIN payment p ON p.rental_id = r.rental_id
WHERE r.customer_id = #{customerId}
GROUP BY r.rental_id, r.rental_date, r.return_date, f.title
ORDER BY r.rental_date DESC
LIMIT #{size} OFFSET #{offset}
""")
List<RentalHistoryItemResponse> selectRentalHistory(
@Param("customerId") int customerId,
@Param("size") int size,
@Param("offset") int offset);
⑧ Checkout (CheckoutView.vue)
The checkout screen uses an “Isolated Checkout” layout where the normal header and nav are hidden.

Two payment methods are implemented: PAY.JP and PayPay.
PayPay generates a Dynamic QR Code and redirects to the PayPay app. Payment completion is detected by polling.
// PayPayService.java (QR code generation excerpt)
public QRCodeDetails createQRCode(int amount, String merchantPaymentId, String redirectUrl)
throws ApiException {
QRCode qrCode = new QRCode();
qrCode.setMerchantPaymentId(merchantPaymentId);
qrCode.setAmount(new MoneyAmount().amount(amount).currency(MoneyAmount.CurrencyEnum.JPY));
qrCode.setCodeType("ORDER_QR");
qrCode.setOrderDescription("DVD Rental Payment");
qrCode.setRequestedAt(Instant.now().getEpochSecond());
return paymentApi.createQRCode(qrCode);
}
PAY.JP mounts Payjp.js on the frontend so card information never passes through the server (PCI DSS compliant).
DB Customizations for dvdrental
Since dvdrental is a sample DB, additional tables are needed to run the end-user app. Schema changes are managed with Flyway.
→ Incrementally Growing Schema Migrations with Flyway
Added Table ① customer_authentication
The dvdrental customer table only holds business data (name, address, etc.) and has no password hash for authentication. A separate authentication table was created.
CREATE TABLE public.customer_authentication (
customer_id INTEGER PRIMARY KEY
REFERENCES public.customer(customer_id),
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
auth_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
The MyBatis Mapper references this table directly.
// AuthMapper.java (excerpt)
@Select("""
SELECT customer_id, email, password_hash, auth_status
FROM public.customer_authentication
WHERE LOWER(email) = LOWER(#{email})
LIMIT 1
""")
AuthRecord findByEmail(@Param("email") String email);
Added Table ② rental_tier
The rental_rate in dvdrental’s film table is a flat number. A tier master was added to enable filtering by categories like “New Release”, “Semi-New”, and “Standard”.
CREATE TABLE public.rental_tier (
rental_tier_id SERIAL PRIMARY KEY,
code VARCHAR(20) NOT NULL UNIQUE, -- 'NEW', 'SEMI_NEW', 'STANDARD'
label VARCHAR(50) NOT NULL,
default_rate NUMERIC(5,2) NOT NULL
);
-- Add FK column to film table
ALTER TABLE public.film
ADD COLUMN rental_tier_id INTEGER REFERENCES public.rental_tier(rental_tier_id);
The film list API JOINs rental_tier and returns tier_code for frontend filtering.
// PublicFilmMapper.java (excerpt)
@Select("""
SELECT
f.film_id, f.title, f.release_year,
c.name AS category_name,
COALESCE(f.rental_rate, rt.default_rate) AS rental_rate,
rt.code AS tier_code
FROM film f
JOIN film_category fc ON fc.film_id = f.film_id
JOIN category c ON c.category_id = fc.category_id
JOIN rental_tier rt ON rt.rental_tier_id = f.rental_tier_id
ORDER BY f.film_id
LIMIT #{size} OFFSET #{offset}
""")
List<PublicFilmSummaryResponse> selectPublicFilms(@Param("size") int size, @Param("offset") int offset);
Added Column ③ film.taste_tags
Mood tags are auto-generated by LLM (Ollama / OpenAI) and stored in the taste_tags column of the film table.
ALTER TABLE film ADD COLUMN IF NOT EXISTS taste_tags TEXT[];
A Spring Boot batch passes movie titles and descriptions to the LLM and stores the returned tag arrays as TEXT[].
→ Integrating an LLM (Ollama/OpenAI) Batch for Auto-Generating Film Tags into Spring Boot
→ Mapping PostgreSQL Array Type (text[]) with Spring Boot + JPA
Backend API Structure
REST APIs are split between public endpoints and authenticated endpoints.
Public API (no auth required)
GET /api/public/health health check
GET /api/public/films film list (paginated)
Authenticated API (login required)
POST /api/auth/login login
GET /api/me/profile own member info
GET /api/me/rentals own rental history
GET /api/me/payments own payment history
POST /api/payments/paypay PayPay QR code generation
GET /api/payments/paypay/{id} payment status check (polling)
POST /api/payments/payjp PAY.JP card registration & charge
In development, the setup is localhost:5173 (Vue) → localhost:8082 (Spring Boot) as separate origins. In production, mvn package bundles the frontend build artifacts into static/ and Spring Boot serves them.
→ Record of Breaking Through CORS and Proxy Issues with Vue 3 + Vite + Spring Boot
→ Bundling Vue3/React Frontend into Spring Boot for Automatic Serving
Implemented Features
Public Area (no login required)
- Film list (category, rental tier, runtime filters, free-text search)
- Film detail (cover image, preview video, inventory status, cast)
- Recommendations for non-logged-in users
- Filtering by LLM auto-generated mood tags (in progress)
Member Area (after login)
- Member registration (3-step: input → confirm → complete)
- Login / logout
- My page (usage summary, rental history, payment history)
- Member info editing, email address change
- Account deletion flow (with confirmation)
- Personalized recommendations and “Watch Later” list
Payment & Rental
- “Add to Cart” flow (cart drawer)
- “Rent Now” flow for immediate checkout
- PAY.JP credit card payment (card registration and reuse)
- PayPay Dynamic QR Code payment (completion detected by polling)
Troubles During Development
Encoding Accident from Bulk Text Edit
During development, a bulk text replace operation caused encoding-related corruption in multiple Vue files.
- “Regenerating the correct version in full” was faster than chasing partial fixes
- After recovery, list, feature, ranking, and detail were verified in order before merging to develop
In text-replacement-heavy work, it’s important to decide on recovery procedures ahead of time, assuming encoding accidents will happen.
→ Encoding Accident from Bulk Text Editing and Recovery via Full Regeneration
Features In Progress / Not Started
This section is updated as development progresses.
- Full integration of registration flow with
customer_authenticationtable - Production-ready inventory locking logic (currently references sample data only)
- Rental period management (extension and return flow)
- Email confirmation for orders
- AWS deployment (same ECS/Fargate setup as the admin app)
Article Map (articles written/being written for this series)
Each article focuses on a specific topic and can be referenced standalone for Spring Boot / Java / Vue 3 / TypeScript topics.
■ Overall Project Design Decisions
| Theme | Article |
|---|---|
| Why separate repos for admin and customer app | Why We Separated the Admin and Customer Apps into Different Repositories — What We Gained and Lost |
| Platform vision | The Vision of Evolving the DVD Rental System into a Platform Like DMM |
| Why not start from scratch DB design | The Choice of Not Designing Tables from Scratch — Building on dvdrental |
■ Backend Structure
| Theme | Article |
|---|---|
| Why Vue 3 + REST API separation | Why We Chose the Spring Boot Backend + Vue 3 Frontend Separation Architecture |
| Why MyBatis over JPA | Why We Chose MyBatis Over JPA for the Spring Boot API Server |
| Growing DB schema incrementally with Flyway | Incrementally Growing Schema Migrations with Flyway |
| LLM batch for auto-generating film tags | Integrating an LLM (Ollama/OpenAI) Batch for Auto-Generating Film Tags into Spring Boot |
| PostgreSQL array type with Spring Boot | Mapping PostgreSQL Array Type (text[]) with Spring Boot + JPA |
■ Frontend Structure
■ UX Design & Payment Flow
| Theme | Article |
|---|---|
| Why to separate “Add to Cart” and “Buy Now” | Why EC Sites Should Separate “Add to Cart” and “Buy Now” — Design and Implementation |
| Why checkout should be “Isolated Checkout” | Why Checkout Should Be “Isolated Checkout” and How to Implement It |
■ Development Environment
| Theme | Article |
|---|---|
| Running Maven/Node on Windows without PATH dependency | Running Maven / Node / Java on Windows Without PATH Dependency — Self-Contained Project Setup |
■ Articles from the Paired Admin App
Closing
When building the admin app, the nagging feeling that “there’s a place for staff to manage customer data, but no place for customers themselves to use” never went away.
This end-user app was started to answer that question.
However, it’s not complete yet.
Parts needed for a “truly working service” — like the payment flow and inventory locking logic — are still missing. I’m recording design decisions and implementations in individual articles while slowly bringing it to completion.
This entire article series is written to function as a reference and textbook of Spring Boot / Java / Vue 3 / TypeScript implementation patterns through real app development. When researching a specific technology or topic, refer directly to the relevant category in the article map.
Links to each article will be filled in progressively as they are published.