A visitor fills in a workshop registration form and clicks Submit. What turns those fields into a Java object? Where should validation happen? And how does Java produce the HTML page that the visitor sees next? Spring MVC becomes much easier to understand when you follow one complete request through a small, working application.

Spring MVC: from a browser request to a rendered web page. Editorial illustration.

This guide builds a fictional workshop registration page with Spring Boot, Spring MVC and Thymeleaf. You will display a form, validate its input, preserve values when something is wrong, and redirect to a receipt after a successful submission. The downloadable project includes the complete source and automated tests.

1. Why learn Spring MVC now?

The interesting current development for a beginner is the evolution of the Spring platform around a familiar programming model. The official reference currently identifies Spring Boot 4.1.2 and Spring Framework 7.0.10. Framework 7.1 is still documented as a preview scheduled for November 2026. This tutorial uses Boot 4.1.2 and lets its dependency management select compatible libraries. Check the supported release line and current patches when starting your own project. Spring Boot reference, Framework 7.1 release notes.

Three developments make this a useful learning topic: the current Boot generation provides an up-to-date baseline; Spring's binding guidance emphasizes purpose-built web input objects; and the official MVC guides continue to demonstrate server-rendered HTML with Thymeleaf. These are documented platform directions, not evidence of a measured market-share surge. Spring model binding, Serving Web Content.

For Homann Software's learning series, the editorial opportunity is practical: complement the existing REST, authentication and transaction tutorials with a page that a reader can open and use. You do not need an AI integration, a separate frontend build or a database to learn the request lifecycle. Basic Java, an understanding of methods and classes, and a little HTML are enough to begin.

2. Model, View and Controller: three responsibilities

Spring MVC is Spring Framework's Servlet-based web framework. Spring Boot supplies application setup and auto-configuration around it; Thymeleaf supplies the template engine used in this example. These tools have different jobs. Spring Web MVC overview.

Imagine preparing a printed workshop receipt. The controller decides which request to handle and which information to obtain. The model carries the information needed for the page. The view arranges that information into HTML.

Part Job in our application What to keep out of it
Controller Map URLs, inspect validation results, choose a view or redirect Shared mutable form fields
Model Expose registrationForm or registration to the template The assumption that every model object is a database entity
View Render inputs, field errors and the receipt Application decisions about accepting requests
Service Create and retrieve demo receipts HTTP status codes and template names

Conceptual responsibility map showing the browser, DispatcherServlet, controller, service, model attributes and Thymeleaf view.

The central entry point inside MVC is the DispatcherServlet. It coordinates request handling through components that locate a handler, invoke it and resolve a view. Your annotated controller participates in this mechanism. With Boot, you normally do not create a servlet registration or write web.xml yourself. DispatcherServlet documentation.

In our application, a GET request for /registrations/new ultimately returns a template name and a form object. Thymeleaf combines them into HTML. The browser receives that HTML; it does not run the Java controller or receive the template's original expressions.

3. Is Spring MVC only for HTML pages?

No. A controller can select a view, while a response-body handler writes its return value into the HTTP response through message conversion. @RestController combines @Controller with @ResponseBody. For a normal @Controller method in this tutorial, returning "registration-form" selects a view. Under response-body semantics, a returned string is response content instead. That distinction explains the common beginner surprise of seeing a template name printed in the browser. Spring response-body documentation.

For this workshop page, we choose server-rendered HTML because the interaction is a short form followed by a receipt. A JSON API is useful when a separate client needs structured data. Neither choice makes the other obsolete. Our use case determines the representation; the framework name does not force a frontend architecture.

4. Our use case: request workshop seats

US-01: As a visitor, I want to submit my name, email and requested number of seats so that I can receive an acknowledgement that my request was recorded.

UC-01 starts when the visitor opens the form. The visitor enters a name of up to 80 characters, an email of up to 120 characters, and a whole number from one to four. Invalid input redisplays the form with errors. Valid input creates a receipt and navigates to its page.

The word “request” matters. This application does not allocate workshop capacity. It records an in-memory receipt containing a server-generated ID, name and requested seats. It validates the email format but deliberately does not retain the email or send a message. Email syntax validation does not establish ownership or deliverability.

Request Expected result
GET /registrations/new 200 HTML form
Invalid POST /registrations with valid CSRF token 200 form with field errors
Valid POST /registrations with valid CSRF token 302 redirect to the new receipt
GET /registrations/{id} for an existing receipt 200 HTML acknowledgement
Missing or invalid CSRF token on POST 403 before the controller handles it
Unknown UUID / malformed UUID 404 / 400 respectively

Using 200 for a redisplayed form is this application's design choice. An application can adopt other error-response conventions, provided its clients and tests agree.

5. Download and run the complete example

Download the tested Spring MVC lab. It includes all Java classes, templates, CSS, tests, a README, and editable diagram sources. You need JDK 21 or later and Maven 3.6.3 or later; verification used JDK 21 and Maven 3.9.11. The first build needs internet access for Maven dependencies.

Unzip the download, open a terminal in the folder containing pom.xml, and run:

mvn verify
java -jar target/spring-mvc-lab-1.0.0.jar

Open http://127.0.0.1:8080/registrations/new. Use fictional data such as Ada, [email protected], and two seats. You should reach a receipt showing Ada and two requested seats. Refresh that receipt: the browser repeats a GET request. Stop the server with Ctrl+C. Its receipts disappear when the JVM stops.

If port 8080 is already occupied, start the JAR with --server.port=8081 and use that port in the URL. The included configuration binds to 127.0.0.1 for local learning. Opening the root URL / is not the starting point; this project deliberately maps the form at /registrations/new.

6. Set up the project

The project uses the following Maven configuration. The Boot 4 MVC and MVC-test starters are explicit; older Boot tutorials may show different starter names and test annotation imports. Use the imports from this download together with its pinned parent version.

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
 <modelVersion>4.0.0</modelVersion>
 <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.2</version><relativePath/></parent>
 <groupId>com.homannsoftware</groupId><artifactId>spring-mvc-lab</artifactId><version>1.0.0</version>
 <properties><java.version>21</java.version></properties>
 <dependencies>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc-test</artifactId><scope>test</scope></dependency>
  <dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
 </dependencies>
 <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>

The package com.homannsoftware.mvclab contains the application entry point and the other Java types. Keeping them beneath the application class's package makes the demo's component discovery straightforward. The main method delegates startup to Boot:

package com.homannsoftware.mvclab;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/** Entry point for the local Spring MVC teaching application. */
@SpringBootApplication
public class MvcLabApplication {
    /** Starts the embedded servlet server. @param args command-line options */
    public static void main(String[] args) {
        SpringApplication.run(MvcLabApplication.class, args);
    }
}

Here is the small source layout you will work with:

src/main/java/com/homannsoftware/mvclab/
  MvcLabApplication.java
  RegistrationForm.java
  Registration.java
  RegistrationService.java
  RegistrationController.java
  SecurityConfig.java
src/main/resources/
  application.properties
  messages.properties
  static/style.css
  templates/registration-form.html
  templates/registration-receipt.html
src/test/java/com/homannsoftware/mvclab/
  RegistrationWebTest.java

7. Define exactly what the browser may submit

Our input object is a Java record: a compact data carrier with a constructor and accessors. Its components are the three fields that this page accepts. It has no writable receipt ID, administrative role or approval status.

package com.homannsoftware.mvclab;

import jakarta.validation.constraints.*;

/** Request-specific input, separate from stored registrations.
 * @param name attendee display name
 * @param email contact email, not proof of ownership
 * @param seats requested seats, from one to four
 */
public record RegistrationForm(
    @NotBlank(message = "Enter your name.")
    @Size(max = 80, message = "Use at most 80 characters.") String name,
    @NotBlank(message = "Enter your email.")
    @Email(message = "Enter a valid email address.")
    @Size(max = 120, message = "Use at most 120 characters.") String email,
    @NotNull(message = "Enter the number of seats.")
    @Min(value = 1, message = "Request at least one seat.")
    @Max(value = 4, message = "Request at most four seats.") Integer seats
) {}

Spring can construct a model attribute from matching request values. Here, a dedicated record plus constructor-only binding keeps the web input boundary small. The controller's @InitBinder enables declarative binding, without allowing later property binding. This follows Spring's guidance to design input objects deliberately. Model attribute binding.

Notice Integer seats rather than int seats. A missing value can be represented as null, so @NotNull can report it. A value such as abc cannot be converted to an integer; that produces a binding error. A value such as 5 converts successfully but fails the maximum constraint. Both paths must produce a useful response.

The download includes a friendly conversion message in messages.properties:

typeMismatch.registrationForm.seats=Enter a whole number from 1 to 4.

For this demo, one to four is an input rule, not a live availability check. If a real workshop had limited capacity, an application service and durable transaction would need to enforce the allocation rule independently of this form.

8. Read the controller one request at a time

Here is the complete controller from the tested project:

package com.homannsoftware.mvclab;

import java.util.UUID;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

/** Adapts HTTP form requests to the registration service and HTML views. */
@Controller
public class RegistrationController {
    private final RegistrationService service;

    /** Constructor injection. @param service shared receipt service */
    public RegistrationController(RegistrationService service) {
        this.service = service;
    }

    /** Enables constructor binding only. @param binder request data binder */
    @InitBinder("registrationForm")
    void bindForm(WebDataBinder binder) {
        binder.setDeclarativeBinding(true);
    }

    /** Displays an empty form. @param model view attributes @return template name */
    @GetMapping("/registrations/new")
    public String form(Model model) {
        model.addAttribute("registrationForm", new RegistrationForm("", "", 1));
        return "registration-form";
    }

    /** Validates input before writing a receipt.
     * @param form bound and validated input
     * @param errors binding and constraint errors
     * @return form view on error, otherwise a redirect to the receipt
     */
    @PostMapping("/registrations")
    public String submit(@Valid @ModelAttribute("registrationForm") RegistrationForm form,
                         BindingResult errors) {
        if (errors.hasErrors()) {
            return "registration-form";
        }
        var receipt = service.register(form);
        return "redirect:/registrations/" + receipt.id();
    }

    /** Displays a receipt. @param id receipt ID @param model view attributes
     * @return receipt template name
     */
    @GetMapping("/registrations/{id}")
    public String receipt(@PathVariable UUID id, Model model) {
        var receipt = service.find(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        model.addAttribute("registration", receipt);
        return "registration-receipt";
    }
}

form adds a blank RegistrationForm with one seat to the model and selects registration-form.html. No receipt is created by opening the page.

submit receives a request-specific form object. @Valid asks Spring to apply Bean Validation. BindingResult immediately follows the associated form parameter, allowing the method to inspect binding and validation errors. Returning the same form view keeps that input and its errors available for rendering. Spring MVC validation.

When there are no errors, the method calls the service and returns a redirect to the generated receipt URL. Spring's default redirect behavior in this example is 302. The browser then requests the receipt using GET. This is Post/Redirect/Get: it avoids another POST when the visitor simply refreshes the resulting receipt page. It does not make repeated POST requests idempotent. Redirect handling.

receipt loads an existing receipt and exposes it as registration. An unknown UUID produces 404. A string that cannot be converted to a UUID produces 400 in the tested configuration.

Our controller stores only its service reference in an instance field. A visitor's name or current form never belongs in a shared controller field: two requests must not overwrite each other's input. The method parameters and local variables make that separation visible.

9. Put application work in the service

The service is intentionally small enough to understand in one reading:

package com.homannsoftware.mvclab;

import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.springframework.stereotype.Service;

/** Stores demo receipts for the lifetime of this JVM. */
@Service
public class RegistrationService {
    private final ConcurrentMap<UUID, Registration> receipts = new ConcurrentHashMap<>();

    /** Records an already validated request; does not send mail or reserve capacity.
     * @param form validated browser input
     * @return new receipt; email is deliberately not retained
     */
    public Registration register(RegistrationForm form) {
        var receipt = new Registration(UUID.randomUUID(), form.name(), form.seats());
        receipts.put(receipt.id(), receipt);
        return receipt;
    }

    /** Looks up a receipt. @param id receipt identifier @return receipt if present */
    public Optional<Registration> find(UUID id) {
        return Optional.ofNullable(receipts.get(id));
    }
}
package com.homannsoftware.mvclab;

import java.util.UUID;

/** Immutable in-memory receipt; this does not reserve workshop capacity.
 * @param id server-created receipt identifier
 * @param name attendee name
 * @param seats requested seats
 */
public record Registration(UUID id, String name, int seats) {}

The server chooses the UUID. The form cannot choose it. The concurrent map supports independent requests accessing the demo store, and the stored receipts are immutable records. This is not durable storage, a capacity-management algorithm, or a guarantee against duplicate submissions.

Keeping creation and lookup behind the service gives us a clear place to evolve the application. For example, a later version could store receipts in a database, add an authenticated owner, and enforce allocation rules. The controller could still coordinate HTTP and views without becoming the owner of those rules.

10. Render the form and receipt with Thymeleaf

Place the form template in src/main/resources/templates/registration-form.html:

<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Workshop registration</title><link rel="stylesheet" th:href="@{/style.css}"></head>
<body><main>
<p>HOMANN SOFTWARE · SPRING MVC LAB</p>
<h1>Workshop registration</h1>
<p>Request 1–4 seats for our fictional Spring workshop.</p>
<form th:action="@{/registrations}" th:object="${registrationForm}" method="post">
    <label for="name">Name</label>
    <input id="name" th:field="*{name}" maxlength="80" required autocomplete="name" aria-describedby="name-error">
    <p id="name-error" class="error" th:errors="*{name}"></p>
    <label for="email">Email</label>
    <input id="email" type="email" th:field="*{email}" maxlength="120" required autocomplete="email" aria-describedby="email-error">
    <p id="email-error" class="error" th:errors="*{email}"></p>
    <label for="seats">Seats</label>
    <input id="seats" type="number" th:field="*{seats}" min="1" max="4" required aria-describedby="seats-error">
    <p id="seats-error" class="error" th:errors="*{seats}"></p>
    <button type="submit">Submit request</button>
</form>
<p class="note">Local teaching demo. Use fictional data. No email is sent, and no workshop capacity is reserved. Receipts disappear on restart.</p>
</main></body></html>

The template binds the form to registrationForm. Each th:field connects an HTML input to a component of that object. th:errors displays the errors for the corresponding field. Labels and error references make the page easier to navigate. Spring's official form-validation guide demonstrates this integration. Validating Form Input.

HTML attributes such as required, min, max and maxlength help visitors avoid mistakes. They do not replace server validation: a custom client can send its own request. Our automated tests deliberately exercise input that a browser would normally block.

The receipt uses a separate template:

<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Registration received</title><link rel="stylesheet" th:href="@{/style.css}"></head>
<body><main>
<h1>Request received</h1>
<p>Thank you, <span id="attendee" th:text="${registration.name}">Attendee</span>.</p>
<p>Requested seats: <span id="requested-seats" th:text="${registration.seats}">1</span></p>
<p class="note">This receipt records a request. It does not confirm a seat allocation.</p>
<a th:href="@{/registrations/new}">Create another request</a>
</main></body></html>

th:text inserts the attendee name as escaped text. That matters when a submitted name contains angle brackets. The project tests a script-shaped name and checks that the response contains escaped text rather than a script element. Avoid switching user content to unescaped output such as th:utext. Thymeleaf text rendering.

The CSS lives under static/. Both pages reference it through a Thymeleaf URL expression. The styling is deliberately modest so you can concentrate on how data reaches the page.

11. Keep CSRF protection in the first working version

Security should be understandable even in a beginner example. Our configuration permits public access to demo pages and retains Spring Security's default CSRF protection:

package com.homannsoftware.mvclab;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

/** Public, loopback-only demo with the default CSRF protection retained. */
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
    /** Configures public demo pages. @param http security builder
     * @return servlet filter chain @throws Exception for invalid configuration
     */
    @Bean
    SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
        return http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()).build();
    }
}

With the Spring Security integration, a Thymeleaf POST form using th:action receives a hidden CSRF field. The browser submits it with the form. A POST with a missing or invalid token is rejected. Allowing a request through authorization with permitAll() does not disable CSRF checks. Spring Security CSRF documentation.

CSRF protection does not authenticate a visitor or establish ownership of a receipt. In this local demo, anyone who knows a receipt URL can open it. A random UUID is not authorization. Before adapting this pattern for a public application, decide who may read each receipt, retain data appropriately, add persistent storage and protect real business operations. Those are application requirements beyond this teaching example.

12. See the same application through UML and BPMN

The UML sequence diagram follows the technical conversation. An invalid form returns HTML with errors. A successful form creates a receipt, returns a redirect, and causes a second request. Security failures occur before the controller's submission method.

UML sequence showing form submission, validation alternatives, receipt creation, a 302 redirect and the follow-up GET.

The BPMN diagram asks a different question: what happens to the visitor's registration request? Invalid input returns for correction; valid input is recorded and acknowledged. The loop describes the business interaction across page requests, rather than a Java loop executing inside the controller.

BPMN process showing entry or correction, validation, an exclusive valid-or-invalid decision, in-memory recording and receipt display.

The download includes editable PlantUML and SVG sources, plus BPMN 2.0 XML with diagram coordinates. The BPMN file is marked non-executable; the Java application does not run a workflow engine. Its internal references and diagram elements were checked, but it was not executed or certified against an engine. OMG BPMN specification.

13. What we tested, and what those results mean

The supplied Maven build passes 23 automated web test cases, with zero failures, errors or skipped cases. They load the real application configuration, security filters, validation and Thymeleaf renderer through MockMvc.

Test area Evidence in the project
Initial form HTML response, default seat count and generated CSRF field
Invalid input 13 cases: empty or oversized fields, invalid email, missing seats, range violations, fractions, text and integer overflow
CSRF Missing token and invalid token both return 403
Successful submission Redirect followed by receipt rendering and a repeated GET
Input boundary A submitted receipt ID does not become the server's ID
Output encoding Script-shaped name appears as escaped text
HTTP behavior Unknown receipt 404, malformed UUID 400, unsupported method 405, CSS served

For example, the CSRF test sends a real MVC request through the configured filters without adding a token:

    @Test void missingCsrfIsForbidden() throws Exception {
        mvc.perform(post("/registrations").param("name", "Ada")
            .param("email", "[email protected]").param("seats", "1"))
            .andExpect(status().isForbidden());
    }

MockMvc exercises the MVC request pipeline using mock request and response objects; it does not prove that a real TCP server or browser works. MockMvc reference. We therefore also started the packaged JAR and checked the form and receipt in Chromium, including a valid submission, server-side error rendering, refresh behavior, escaping and mobile layout. Detailed local verification records accompany the article package.

These checks establish the behavior of this small demo. They do not establish production capacity, database behavior, email delivery, comprehensive accessibility conformance, or performance under load.

14. Common beginner mistakes

Symptom Likely cause and useful next check
Browser prints registration-form Check whether the handler uses @RestController or @ResponseBody
Template cannot be found Check the template filename, location and Thymeleaf dependency
Every POST returns 403 Check that the rendered form contains _csrf; do not remove the protection to hide the symptom
Invalid data creates a receipt Check @Valid, the validation starter and the hasErrors() branch
Form values disappear after an error Return the form view with the bound input; an error redirect starts a new request
A refreshed receipt creates another record Check whether successful POST handling renders directly instead of redirecting
Boot configuration behaves unexpectedly Check whether copied @EnableWebMvc configuration has taken over MVC setup

For ordinary Boot customization, start with its existing MVC auto-configuration and a WebMvcConfigurer where needed. Adding @EnableWebMvc means taking control of MVC configuration and can change the auto-configuration behavior you expected. This example needs neither. Boot MVC auto-configuration.

15. Where to go next

Try changing the maximum seats from four to three, then adjust the HTML constraint, validation message and tests together. Add a workshop-title attribute to the model and render it on both pages. These small exercises reveal which layer owns each part of the behavior.

When you are comfortable following the complete browser interaction, continue with our Spring Boot REST API guide, Keycloak and OIDC, and Spring transactions and rollback. They address the next boundaries: structured API responses, user identity and reliable database changes.

Spring MVC becomes approachable when each step has a clear owner: the browser submits, Spring binds and validates, the controller coordinates, the service records the request, and the view renders the result. You now have a small application in which you can inspect every one of those steps.

Research checked on November 3, 2026. Sources are linked beside the claims they support. Feature image created with OpenAI Imagegen; explanatory diagrams and example code created for this article. The diagrams describe this teaching application and do not report measured adoption or production benchmarks.