Tech Blog

Building an End-User DVD Rental App — Vue 3 + Spring Boot Paired with the Admin App, with Article Map

by y104
Vue3 Spring Boot TypeScript PostgreSQL

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

LayerTechnology
BackendSpring Boot 3 + MyBatis + Spring Security
FrontendVue 3 + TypeScript + Vite
DBPostgreSQL (dvdrental)
PaymentPayPay Dynamic QR Code, PAY.JP credit card
DeploymentSingle 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:

AreaAccessContent
PublicNo login requiredFilm list, film detail
MemberLogin requiredMy 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)

Film list screen

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 screen

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 / Registration screen

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)

Member registration form

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 drawer

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

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.

Checkout screen

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_authentication table
  • 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

ThemeArticle
Why separate repos for admin and customer appWhy We Separated the Admin and Customer Apps into Different Repositories — What We Gained and Lost
Platform visionThe Vision of Evolving the DVD Rental System into a Platform Like DMM
Why not start from scratch DB designThe Choice of Not Designing Tables from Scratch — Building on dvdrental

■ Backend Structure

ThemeArticle
Why Vue 3 + REST API separationWhy We Chose the Spring Boot Backend + Vue 3 Frontend Separation Architecture
Why MyBatis over JPAWhy We Chose MyBatis Over JPA for the Spring Boot API Server
Growing DB schema incrementally with FlywayIncrementally Growing Schema Migrations with Flyway
LLM batch for auto-generating film tagsIntegrating an LLM (Ollama/OpenAI) Batch for Auto-Generating Film Tags into Spring Boot
PostgreSQL array type with Spring BootMapping PostgreSQL Array Type (text[]) with Spring Boot + JPA

■ Frontend Structure

ThemeArticle
How we solved CORS/Proxy issues in developmentRecord of Breaking Through CORS and Proxy Issues with Vue 3 + Vite + Spring Boot
Bundling frontend into jar in productionBundling Vue3/React Frontend into Spring Boot for Automatic Serving
Frontend-complete filter search using only computedImplementing Frontend Filter Search with Only Vue 3 computed
Persisting sidebar state with localStorage without flashPersisting Sidebar Open/Close State with localStorage While Preventing Flash
Not unifying desktop and mobile with responsive CSSThe Decision Not to Unify Desktop and Mobile with Responsive CSS and How to Verify It
Generating film posters and videos with Gemini UltraGenerating Film Posters and Preview Videos with Gemini Ultra for the DVD Rental App
Screen state management with App.vue currentPageDesign Decision: Managing Page State with a Simple currentPage in App.vue
Why I added scrollTo + blur on detail navigationWhy I Added scrollTo(0, 0) and blur() on Detail Navigation
Encoding accident from bulk text edit and recoveryEncoding Accident from Bulk Text Editing and Recovery via Full Regeneration

■ UX Design & Payment Flow

ThemeArticle
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

ThemeArticle
Running Maven/Node on Windows without PATH dependencyRunning Maven / Node / Java on Windows Without PATH Dependency — Self-Contained Project Setup

■ Articles from the Paired Admin App

ThemeArticle
Admin app overview (dvdrental + Spring Boot + Thymeleaf)Building a DVD Rental Admin App with Spring Boot + Thymeleaf on top of the dvdrental Sample DB
Deploying the admin app to AWS ECS/Fargate + RDSDeploying a Spring Boot + Thymeleaf + PostgreSQL Admin App to AWS ECS/Fargate + RDS
How we localized dvdrental to JapaneseHow We Localized the dvdrental PostgreSQL Sample DB to Japanese Using SQL and CSV
Persisting search conditions across screen transitions (@SessionAttributes)Persisting Search Conditions Across Screen Transitions with Spring MVC @SessionAttributes
Discovering ECS Fargate fixed costs (ALB + NAT Gateway)Discovering ECS Fargate Fixed Costs (ALB + NAT Gateway) and Rethinking the Architecture
Checking Spring Boot logs in ECS with CloudWatch LogsHow to Check Spring Boot App Logs Running on AWS ECS with CloudWatch Logs
Deploying to the wrong region with CDK and cleanupDeploying to the Wrong Region with AWS CDK + PowerShell + SSO and the Cleanup
Switching Spring Boot active profile in Docker/ECSSwitching Spring Boot Active Profile in docker-compose and ECS
PostgreSQL encoding and WindowsPostgreSQL Encoding and Windows

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.

Feel free to send a message

Please send a message if you have any technical questions, feedback, or inquiries.