Fragment Rendering & AJAX
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on 2026-10-08, and every example
was compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, JDK 26 with --release 21, 21 tests with MockMvc, the application started and queried with curl, and the fetch() script exercised in headless Chromium with Playwright); the rendered output shown is the real output of those runs. Fragment selection by view
name, ThymeleafView and FragmentsRendering apply to Thymeleaf with Spring MVC (the servlet stack). Spring WebFlux has the same fragment expressions in view names and its own
FragmentsRendering type (see Reactive Rendering with WebFlux), and engine.process(template, selectors, context) is a plain Thymeleaf
API that works standalone as well (see Standalone Engine & Configuration). The htmx pages of this section (htmx 2.0.11 and htmx-spring-boot 5.2.0) build on this one. The running scenario is the Bookshelf introduced on the Getting Started page.
An ordinary request returns a whole page: the browser throws away the current document and builds a new one. An AJAX request (or an htmx request) asks for less: a new table body,
a counter, one more form row. The server can answer with JSON and let JavaScript build the markup, or it can answer with HTML that is already rendered and let the script
put it into the page. The second style is called HTML over the wire. It needs no second template language and no duplicated escaping rules, because the markup comes from the same
Thymeleaf templates, and Thymeleaf already knows how to render only a part of a template. This page shows every way to ask for that part, from a controller return value to a
direct call of the template engine, and the fetch() code that consumes it.
Full pages and fragments
Thymeleaf offers the possibility to render only part of a template as the result of its execution. A fragment here is whatever a fragment expression selects: an element
carrying th:fragment, or any element picked by a markup selector (an id, a tag name, a class, an attribute). The selection is made while the template is processed, after it was
parsed completely, so the template keeps being an ordinary page that opens in a browser, and the same file serves the full-page request and the fragment request.
The sequence shows what changes between the two requests: nothing in the pipeline except the view name. The handler, the model and the template are the same, and the selector travels with the view name, so the part of the view that Thymeleaf renders is decided in one place, the controller.
The Bookshelf catalog template
The examples of this page share one template. It is a full page, and the elements that other requests want are marked: th:fragment="count" and th:fragment="table", and an
id="content" wrapper that is selected without any th:fragment at all. Book titles and authors are user data and are written with th:text, so R&D <Notes> appears escaped.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Catalog</title>
<script th:src="@{/js/fragments.js}" defer></script>
</head>
<body>
<h1>Catalog</h1>
<div id="content">
<p th:fragment="count" id="count" th:text="|${#lists.size(books)} books|">3 books</p>
<table th:fragment="table" id="books">
<tr th:each="b : ${books}">
<td th:text="${b.title}">Title</td>
<td th:text="${b.author}">Author</td>
</tr>
</table>
</div>
<button type="button" th:data-fetch-url="@{/catalog}" data-fetch-target="#books" data-fetch-mode="replace">Reload</button>
<button type="button" th:data-fetch-url="@{/catalog/summary}" data-fetch-mode="by-id">Refresh both</button>
<a th:href="@{/catalog/new}">New book</a>
</body>
</html>
The full-page handler is the usual one. The model attribute books is filled in the handler, so every fragment rendered from this template sees it.
@Controller
@RequestMapping("/catalog")
class CatalogController {
private final BookRepository books;
CatalogController(BookRepository books) {
this.books = books;
}
@GetMapping
String page(Model model) {
model.addAttribute("books", books.findAll());
return "catalog/list";
}
// ... the other handlers of this page
}
Rendered, with the seed data of the scratch application (the curl call authenticates with HTTP Basic, enabled next to the form login as on the Getting Started page):
curl -s -u reader:change-me localhost:8089/catalog
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Catalog</title>
<script src="/js/fragments.js" defer></script>
</head>
<body>
<h1>Catalog</h1>
<div id="content">
<p id="count">3 books</p>
<table id="books">
<tr>
<td>Dune</td>
<td>Frank Herbert</td>
</tr>
<tr>
<td>Emma</td>
<td>Jane Austen</td>
</tr>
<tr>
<td>R&D <Notes></td>
<td>Ada Lovelace</td>
</tr>
</table>
</div>
<button type="button" data-fetch-url="/catalog" data-fetch-target="#books" data-fetch-mode="replace">Reload</button>
<button type="button" data-fetch-url="/catalog/summary" data-fetch-mode="by-id">Refresh both</button>
<a href="/catalog/new">New book</a>
</body>
</html>
Fragment view names
The simplest way to return a fragment is to return its location. Instead of a plain view name, the handler returns a fragment expression written without the ~{ } wrapper: the
template name, a :: separator, and a selector. It is the same syntax that th:insert and th:replace use (see Fragments), and the Thymeleaf
Spring tutorial calls it specifying fragments in controller return values. Spring’s ThymeleafViewResolver hands the whole string to the Thymeleaf view, which splits it into the
template to load and the selector to render.
// A th:fragment name.
@GetMapping(headers = "X-Requested-With=XMLHttpRequest")
String tableForScripts(Model model, HttpServletResponse response) {
model.addAttribute("books", books.findAll());
response.addHeader(HttpHeaders.VARY, "X-Requested-With");
return "catalog/list :: table";
}
// A markup selector: no th:fragment needed, just an id.
@GetMapping("/content")
String content(Model model) {
model.addAttribute("books", books.findAll());
return "catalog/list :: #content";
}
Called without and with the X-Requested-With header, the first handler answers with the whole page and with only the table (the whole page was shown above):
curl -s -u reader:change-me -H 'X-Requested-With: XMLHttpRequest' localhost:8089/catalog
<table id="books">
<tr>
<td>Dune</td>
<td>Frank Herbert</td>
</tr>
<tr>
<td>Emma</td>
<td>Jane Austen</td>
</tr>
<tr>
<td>R&D <Notes></td>
<td>Ada Lovelace</td>
</tr>
</table>
The second handler, called with curl -s -u reader:change-me localhost:8089/catalog/content, returns the div element with id="content" including the count paragraph and the
table, and nothing outside it: no <!DOCTYPE>, <html>, <head> or <h1>. The element is returned from its start tag to its end tag, with the indentation of the source
file left as it was.
Which selector selects what
The part after :: is a markup selector, the same one explained on the Fragments page and listed in the Markup Selector Syntax appendix of the
tutorial. Fragment view names take the whole syntax, so the common cases are:
| View name | Renders |
|---|---|
|
The element with |
|
The element whose |
|
The fragment |
|
The whole template (no selector, the ordinary case) |
Fragment parameters work in view names as they do in th:insert.
Calling a handler that returns catalog/rows :: row(index=5) renders the row with index set to 5; the response
was:
<div class="author-row">
<label for="author-5">Author</label>
<input type="text" name="authors" id="author-5">
</div>
The tutorial also shows a form with a positional value,
index :: #content ('myvalue'). Parameters of a view name must never come from the request:
the string is a fragment expression that the engine evaluates.
Fragments of a decorated page
A page that is decorated by the Layout Dialect (see Layouts & the Layout Dialect) can still be asked for a piece of itself. In the
scratch application the books/list template decorates layout.html; a handler that returns books/list :: main got just the content element, without the layout:
<main>
<h1>Libros</h1>
<ul></ul>
<a href="/books/new">Nuevo libro</a>
</main>
Source: Thymeleaf + Spring — Specifying fragments in controller return values and Layout Dialect — layout:fragment
The layout:fragment attribute of the element was consumed and the layout (head, navigation, footer) is not part of the response. That is what a script wants. It also means
that a fragment response is never decorated: put everything the fragment needs inside the selected element.
What an unknown fragment name returns
Asking for a selector that matches nothing is not an error.
A handler that returned catalog/list :: nosuch, and a handler that returned books/list :: content (the name of a
layout:fragment, not of a th:fragment), both produced a response with status 200 and an empty body. Check fragment names in a test, as the examples of this page do, because
neither the log nor the status code will say that the selector found nothing.
Same URL for the page and the fragment
A handler can serve both representations of one URL: the browser address bar gets the whole page, and a script that adds a request header gets only the fragment. In the controller
above, Spring MVC chose the mapping with headers = "X-Requested-With=XMLHttpRequest" for the script and the plain mapping for everything else. The header is a convention, not
something the browser sends on its own: fetch() does not add it, so the script of the next sections sets it. The htmx library sends its own header, HX-Request: true, on every
request it makes, and the htmx with Spring Boot page shows the library that maps on it.
A URL that returns different bodies depending on a request header must say so to caches. The handler above adds Vary: X-Requested-With to the fragment response (the response from
the earlier curl run showed the header), so a browser cache or a proxy does not hand the fragment to the next request for the full page, or the other way round. Add the same
header on the full-page response when a shared cache sits in front of the application.
curl -s -i -u reader:change-me -H 'X-Requested-With: XMLHttpRequest' localhost:8089/catalog | tr -d '\r' | grep -E '^(HTTP|Vary|Content-Type)'
HTTP/1.1 200
Vary: X-Requested-With
Content-Type: text/html;charset=UTF-8
Source: MDN — Vary
ThymeleafView beans with a markup selector
The fragment can also be fixed in a bean instead of in the string a handler returns. org.thymeleaf.spring6.view.ThymeleafView has a markupSelector property, and a prototype-scoped
bean of that class names a template and a selector. A handler that returns the bean name then renders only that part of the template, exactly as if it had returned
catalog/list :: #books. The advantage is that the controller no longer knows the template layout; the cost is a bean, and one extra ThymeleafViewResolver in Spring Boot
(explained on the Spring MVC Integration page, because Boot’s BeanNameViewResolver returns the bare bean otherwise and the request
fails with IllegalArgumentException: Property 'locale' is required).
@Configuration(proxyBeanMethods = false)
class CatalogViews {
@Bean(name = "catalogTable")
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
ThymeleafView catalogTable() {
ThymeleafView view = new ThymeleafView("catalog/list"); // templateName
view.setMarkupSelector("#books");
return view;
}
@Bean
ThymeleafViewResolver viewBeanResolver(SpringTemplateEngine templateEngine) {
ThymeleafViewResolver resolver = new ThymeleafViewResolver();
resolver.setTemplateEngine(templateEngine);
resolver.setCharacterEncoding("UTF-8");
resolver.setOrder(1);
resolver.setViewNames(new String[] {"catalogTable"});
return resolver;
}
}
// in CatalogController
@GetMapping("/table-bean")
String tableBean(Model model) {
model.addAttribute("books", books.findAll());
return "catalogTable";
}
curl -s -u reader:change-me localhost:8089/catalog/table-bean returned the same <table id="books"> element as the fragment view name did, and no count paragraph. The tutorial
shows both a fragment name ("content") and an id selector ("#content") as values of setMarkupSelector. Under the hood both mechanisms end in the same call: ThymeleafView has a renderFragment method that takes a set of selectors. A view name that contains :: is parsed there as a fragment expression into a template name, selectors and parameters, and a markupSelector is passed as a one-element set. Both end in process on the engine with that set and a Writer.
Several fragments in one response
HTML over the wire often needs more than one fragment: after a book is added, the table changes and so does the counter. The Spring Framework reference calls the technique HTML fragments. Instead of one view and one model, a handler returns several pairs of both and the server renders them one after another into the same response body.
FragmentsRendering
org.springframework.web.servlet.view.FragmentsRendering (since Spring Framework 6.2) is the dedicated return type. A builder collects the fragments and the handler returns the result.
Each fragment is a view name, and it can be a fragment expression like any other view name.
@GetMapping("/summary")
FragmentsRendering summary(Model model) {
model.addAttribute("books", books.findAll());
return FragmentsRendering.fragment("catalog/list :: count")
.fragment("catalog/list :: table")
.build();
}
The response is the two fragments concatenated, in the order of the calls (note that nothing separates them, and that the browser sees a body with two sibling elements and no wrapper):
<p id="count">3 books</p><table id="books">
<tr>
<td>Dune</td>
<td>Frank Herbert</td>
</tr>
<tr>
<td>Emma</td>
<td>Jane Austen</td>
</tr>
<tr>
<td>R&D <Notes></td>
<td>Ada Lovelace</td>
</tr>
</table>
The builder offers more than adding fragments. The methods below are those of FragmentsRendering.Builder and the static factories in Spring Framework 7.0.9:
| Method | Purpose |
|---|---|
|
Start a builder with one fragment; the second form gives that fragment its own model |
|
Add another fragment |
|
Start or continue with a collection of fragments built elsewhere |
|
Set the status and headers of the one response that carries all fragments |
|
Create the |
|
Deprecated for removal in favour of |
Collection<ModelAndView>
The same can be done by returning a Collection<ModelAndView> (the reference shows a List<ModelAndView>), which is convenient when the fragments are computed in a loop or
come from a service. Each element is a view name with a model, and each fragment can have an independent model that inherits the attributes of the shared model for the request.
In the next handler the counter fragment gets a model of its own with one book, and the table fragment uses the shared model with all of them:
@GetMapping("/summary-mav")
List<ModelAndView> summaryMav(Model model) {
List<Book> all = books.findAll();
model.addAttribute("books", all);
return List.of(
new ModelAndView("catalog/list :: count", Map.of("books", all.subList(0, 1))),
new ModelAndView("catalog/list :: table"));
}
<p id="count">1 books</p><table id="books">
<tr>
<td>Dune</td>
<td>Frank Herbert</td>
</tr>
<tr>
<td>Emma</td>
<td>Jane Austen</td>
</tr>
<tr>
<td>R&D <Notes></td>
<td>Ada Lovelace</td>
</tr>
</table>
The counter says one book and the table has three rows: the fragment’s own model overrode the shared books for the counter only. Put every attribute that more than one fragment reads
into the shared model (the Model argument of the handler), as above. In a run where the shared model did not contain books and only the first fragment had it in its own
model, the second fragment (which has no model) still saw the first fragment’s books, so it rendered "1 books" as well. Treat a fragment’s own model as an override of shared
attributes, and do not rely on an attribute that exists only in an earlier fragment’s model.
What the client does with several fragments
A response with two sibling elements is not something a browser can "swap" without help; the receiver has to know where each piece goes. Two approaches exist. The plain-JavaScript
one is in the next section: every fragment carries an id, and the script replaces the page element that has the same id. The htmx one is declarative: the elements that should
land elsewhere carry hx-swap-oob (out-of-band swap), and htmx replaces the matching element by id, whatever the request’s target is. That is how the htmx book’s chapters 3 and 6
return a single fragment by "view :: fragment" and several fragments in one response, and it is explained in htmx Fundamentals and
htmx with Spring Boot. The consulted edition of that book is v1.0.0 (2023), which predates htmx 2.
Streaming fragments
The same two return types exist in a streaming form. A handler can return an SseEmitter and send a ModelAndView per event so that every server-sent event carries one rendered
fragment, or return a Flux<ModelAndView>. The reference describes both next to the examples above. Those handlers are the subject of
Server-Sent Events & WebSockets. On the reactive stack the counterpart of FragmentsRendering is the type of the same name
in the package org.springframework.web.reactive.result.view.
The fetch() baseline: partial updates and dynamic form rows
Before reaching for a library, the plain browser API is enough for two classic cases: reloading a part of the page, and adding a row to a form (an author field, a contact line). The
script below is the whole client side. Every button that has data-fetch-url asks for a fragment with fetch(), sends the X-Requested-With header so that the same-URL handler
returns the fragment, and then, depending on data-fetch-mode, appends it, replaces a target element, or replaces by id every sibling that came back.
// Plain-JavaScript baseline: fetch an HTML fragment and put it into the page.
document.addEventListener('click', async (event) => {
const button = event.target.closest('[data-fetch-url]');
if (!button) return;
const url = new URL(button.dataset.fetchUrl, location.href);
if (button.dataset.fetchMode === 'append') {
// Reserve the index before awaiting, so two quick clicks never share one.
const next = Number(button.dataset.nextIndex ?? document.querySelector(button.dataset.fetchTarget).children.length);
button.dataset.nextIndex = next + 1;
url.searchParams.set('index', next);
}
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!response.ok) return;
const html = await response.text();
const mode = button.dataset.fetchMode;
if (mode === 'append') {
document.querySelector(button.dataset.fetchTarget).insertAdjacentHTML('beforeend', html);
} else if (mode === 'replace') {
document.querySelector(button.dataset.fetchTarget).outerHTML = html;
} else if (mode === 'by-id') {
// One response with several fragments: replace the element with the same id.
// Copy the children first: replaceWith() moves each node out of the live collection.
const parsed = new DOMParser().parseFromString(html, 'text/html');
for (const fresh of [...parsed.body.children]) {
if (fresh.id) document.getElementById(fresh.id)?.replaceWith(fresh);
}
}
});
Source: MDN — Using the Fetch API
The script is a static file and the template passes it only data attributes written by the template itself (th:data-fetch-url builds a context-aware URL, the other attributes
are literals), so no request data is ever interpolated into JavaScript, and the HTML it inserts is always the escaped output of a Thymeleaf template. Never build the markup
in JavaScript from user text ('<li>' + title + '</li>'); if the script must show a message of its own, set textContent.
Dynamic form rows
The form needs one input per author and a button that adds another. The server renders one row as a fragment, and the script fetches it. The fragment takes the row number as a
parameter so that every id and for pair is unique:
<div th:fragment="row" class="author-row">
<label th:for="|author-${index}|">Author</label>
<input type="text" name="authors" th:id="|author-${index}|">
</div>
@GetMapping("/author-row")
String authorRow(@RequestParam(defaultValue = "0") int index, Model model) {
model.addAttribute("index", index);
return "catalog/rows :: row";
}
@GetMapping("/new")
String newForm(Model model) {
model.addAttribute("newBook", new NewBook());
return "catalog/new";
}
@PostMapping
String create(@Valid NewBook newBook, BindingResult result) {
if (result.hasErrors()) {
return "catalog/new";
}
Book book = new Book();
book.setTitle(newBook.getTitle());
book.setAuthor(newBook.getAuthors().stream().filter(a -> !a.isBlank()).collect(Collectors.joining(", ")));
books.save(book);
return "redirect:/catalog";
}
<form th:action="@{/catalog}" th:object="${newBook}" method="post">
<label for="title">Title</label>
<input type="text" id="title" th:field="*{title}">
<span th:if="${#fields.hasErrors('title')}" th:errors="*{title}">Title error</span>
<div id="author-rows"></div>
<button type="button" id="add-author" th:data-fetch-url="@{/catalog/author-row}" data-fetch-target="#author-rows" data-fetch-mode="append">Add author</button>
<button type="submit">Save</button>
</form>
The row handler is a safe GET: index is bound to an int, so only a number can reach the template, and it changes nothing on the server. The state change is the form’s own
POST, a normal submission with th:action, which is why Spring Security’s CSRF token (the hidden _csrf field) is part of the page without any script. The fields all have the
same name authors, so Spring binds them to a List<String>. The button asks for a row with curl -s -u reader:change-me 'localhost:8089/catalog/author-row?index=1', which
answers:
<div class="author-row">
<label for="author-1">Author</label>
<input type="text" name="authors" id="author-1">
</div>
The scripts were run in headless Chromium against the running application: clicking "Add author" twice produced the rows author-0 and author-1, filling both and saving created the
book "Good Omens" with the authors "Terry Pratchett, Neil Gaiman", and the requests the browser made were:
GET /catalog/new (document navigation)
GET /catalog/author-row?index=0 X-Requested-With: XMLHttpRequest
GET /catalog/author-row?index=1 X-Requested-With: XMLHttpRequest
POST /catalog (form submission, CSRF token in the form)
GET /catalog (after the redirect)
The "Refresh both" button was checked the same way: the page’s count paragraph and table were overwritten with "stale" text first, a click on the button restored both from the
single /catalog/summary response, and the "Reload" button replaced the table only. The Taming Thymeleaf book (chapter 16, "Dynamically adding rows") uses this same idea, a row
fragment fetched with fetch(), with Spring Boot 2.3-era code; the idea is unchanged, but use jakarta.* imports, spring-boot-starter-webmvc and the CSRF-aware form above.
A fetch() that changes state
Reading fragments needs nothing special. A fetch() that sends POST, PUT or DELETE is subject to the CSRF protection like any other request, and Spring Security accepts the token
in a request header as well as in a form field. The default header name is X-CSRF-TOKEN; the page can expose the value to the script in a <meta> element rendered by Thymeleaf from
the _csrf request attribute, or the script can copy it from a hidden _csrf input. In the scratch application a POST to /catalog with a valid session and the token in the
X-CSRF-TOKEN header was answered with a redirect (the save succeeded), and the same POST without the token is rejected (the MockMvc test saw status 403). Keep CSRF protection
enabled for these requests instead of exempting the URLs.
Rendering fragments programmatically
Sometimes a fragment is needed as a String, not as a response: to push it over a WebSocket or an SSE connection, to put it in an e-mail, to cache it, or to render it from a
scheduled job. Then the handler is out of the picture and the template engine is called directly. ITemplateEngine has the overload that takes a set of selectors:
String process(String template, Set<String> templateSelectors, IContext context)
templateSelectors is a set of the same markup selectors that follow :: in a view name. The engine parses the template and processes only the selected parts. A small service
wraps the call for the Bookshelf (SpringTemplateEngine is the engine bean that Spring Boot creates, and Context is the plain, non-web implementation of IContext):
@Service
public class FragmentRenderer {
private final SpringTemplateEngine engine;
public FragmentRenderer(SpringTemplateEngine engine) {
this.engine = engine;
}
/** Renders one markup selector of a template to a String, outside any request. */
public String render(String template, String selector, Locale locale, Map<String, Object> variables) {
return engine.process(template, Set.of(selector), new Context(locale, variables));
}
}
// in CatalogController
@GetMapping(value = "/count", produces = MediaType.TEXT_HTML_VALUE)
ResponseEntity<String> count(Locale locale) {
String html = renderer.render("catalog/list", "count", locale, Map.of("books", books.findAll()));
return ResponseEntity.ok().header(HttpHeaders.VARY, "X-Requested-With").body(html);
}
curl -s -u reader:change-me localhost:8089/catalog/count returned exactly the fragment:
<p id="count">3 books</p>
Rendered with an empty books list, these were the results for different selectors against the same template (whitespace collapsed):
Selector in Set.of(...) |
Result |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
The whole |
The tutorial describes the general mechanism as fragment specs: objects that implement org.thymeleaf.fragment.IFragmentSpec, of which the most common is
StandardDOMSelectorFragmentSpec, which takes a DOM selector exactly like those of th:insert. ThymeleafView does the equivalent for you from the view name, so application code
rarely needs the type; the overload with Set<String> is the programmatic entry point. The same engine methods also exist with a Writer to stream into (the one ThymeleafView calls), and as processThrottled variants that hand out the output in chunks (the reactive view uses that kind of rendering; see Reactive Rendering with WebFlux).
A plain Context is not a web context
A Context knows variables and a locale, nothing else. A template that was written for a request often uses features that need a request. In the scratch run, selecting the buttons of
the catalog page (button), which contain a context-relative link @{/catalog}, failed with:
TemplateProcessingException: Link base "/catalog" cannot be context relative (/...) unless the context
used for executing the engine implements the org.thymeleaf.context.IWebContext interface
(template: "catalog/list" - line 19, col 23)
Keep fragments that are rendered outside a request free of context-relative URLs, session and param data, and Spring Security objects, or build an IWebContext
(org.thymeleaf.context.WebContext, which needs an IWebExchange; see Standalone Engine & Configuration). The locale
is also yours to pass: the Locale argument of the handler above is the request’s, but a scheduled job must choose one itself.
Source: Thymeleaf 3.1.5 API — Context
From fetch() to htmx
Everything above is what htmx automates. An element with an hx-get attribute makes the request, htmx adds the HX-Request: true header, and the response fragment goes into the
element named by hx-target with the strategy of hx-swap; hx-swap-oob places the extra fragments of a FragmentsRendering response. The server side does not change: the
same fragment view names, the same markup selectors, the same shared model. What the library adds is declarative targeting, triggers, history and events, and the htmx Fundamentals,
htmx with Spring Boot and htmx Patterns pages build on this one. For small client-only
state such as an open menu, Alpine.js & Web Components needs no request at all.
Source: htmx — Out of Band Swaps
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
The script inserts a whole page ( |
The handler returned a plain view name; no selector was given |
Return |
Status 200 and an empty body |
The selector matched nothing: a misspelled |
Select by |
A fragment response has none of the layout (head, navigation, footer) |
Selection happens on the content page, so the layout is never applied to the selected part |
Expected: put everything the fragment needs inside the selected element |
|
Boot’s |
Add a |
The second fragment of a |
A fragment’s own model attribute that is absent from the shared model was still visible to the next fragment |
Put shared attributes in the |
The browser or a proxy shows the fragment where the page was expected |
One URL, two representations, no |
Send |
|
|
Use a web context, pass absolute URLs as variables, or keep such links out of the fragment |
Two dynamic rows share the same |
The script counted the rows before the first response arrived, so two quick clicks used the same index |
Reserve the index synchronously, before the |
Only half of the fragments of a response replace page elements |
|
Copy first: |
|
The CSRF token was not sent |
Send it in the |
A visitor can choose which template or fragment is rendered |
The view name or the fragment parameters were built from request data, for example |
Return fixed view names; map a request value to a name through a whitelist (a |
Markup injected into the page contains user text unescaped |
The script builds HTML strings from user data instead of fetching a rendered fragment |
Render user data with |
|
|
Use |
Version notes
-
FragmentsRenderingand theCollection<ModelAndView>return type are available since Spring Framework 6.2, and the reference documents them for both the servlet and the reactive stack. In Spring Framework 7.0.9 the factory methods namedwithare deprecated for removal. -
The Thymeleaf + Spring tutorial for 3.1 documents
ThymeleafView.setMarkupSelectorand fragment view names; its examples are written for Spring 6 andthymeleaf-spring6, which is also the artifact that Spring Boot 4.1 uses with Spring Framework 7 (there is nothymeleaf-spring7). -
th:includeand the unwrapped fragment syntax are deprecated in Thymeleaf 3.1 (see Fragments); the tutorial writes fragment view names without the~{ }wrapper, and the examples here follow it. -
The consulted editions of the two books (Taming Thymeleaf PDF v1.1.1, 2021, and Modern frontends with htmx v1.0.0, 2023) show the same fragment view names and the
fetch()rows with Spring Boot 2.x and 3.x code. The ideas hold; the htmx book predates htmx 2 andhtmx-spring-boot5.x, which are covered on the htmx pages of this section.
References
-
Tutorial: Thymeleaf + Spring 3.1 — Rendering Template Fragments
-
Tutorial: Thymeleaf + Spring 3.1 — Specifying fragments in view beans
-
Tutorial: Thymeleaf + Spring 3.1 — Specifying fragments in controller return values
-
Tutorial: Using Thymeleaf 3.1 — Defining and referencing fragments
-
Tutorial: Using Thymeleaf 3.1 — Fragment specification syntax
-
Tutorial: Using Thymeleaf 3.1 — Appendix C: Markup Selector Syntax