Link URLs
|
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 the examples were compiled and run on that stack (Maven wrapper, MockMvc, JDK 26 with --release 21; the
rendered output shown is the real output). Link expressions are part of the Standard Dialect, so everything about the @{...} syntax
applies to Thymeleaf standalone, with Spring MVC and with Spring WebFlux alike; the parts that depend on a servlet
container (the context path, HttpServletResponse.encodeURL, ResourceUrlEncodingFilter) are marked as such, and #mvc.url is a
Spring MVC feature only.
A web page is mostly links: navigation, stylesheets, scripts, images, form targets. Hard-coding them as /books breaks the day the
application is deployed under a context path such as /bookshelf, and building query strings by concatenation breaks on the first
title containing an ampersand. Thymeleaf therefore gives URLs a syntax of their own, the link expression @{...}, which prepares
the URL (context path, parameters, encoding, rewriting) at render time while the template keeps a working, navigable
href for prototyping. This page continues the Bookshelf scenario with its navigation.
What a link expression is
@{...} is one of the Standard Expression types (variables ${...}, selections *{...}, messages #{...}, links, fragments
~{...}). It produces a string, and it can be used anywhere an expression is allowed, not only in th:href. The most common home is
a modifier attribute that replaces the value of the real attribute:
<a href="details.html" th:href="@{/books/details(id=${book.id})}">view</a>
Source: Using Thymeleaf — Link URLs
th:href computes the URL and writes it into the href of the tag. The static href="details.html" is what a browser shows when the
file is opened straight from the disk, so the link stays clickable in the designer’s prototype and the template remains a
natural template. The same pattern applies to th:src (images, scripts), th:action (forms) and, for a <link> element, th:href.
The actual construction is not done by the attribute processor but by an implementation of org.thymeleaf.linkbuilder.ILinkBuilder
registered in the engine. By default a single one exists, StandardLinkBuilder, which is enough for non-web and for Servlet-API
scenarios; the section on custom link builders shows how to add another.
restricted mode applies"] parse --> params["Parameters evaluated
names and values encoded"] base --> builder["ILinkBuilder chain
ordered, null passes to the next"] params --> builder builder --> std["StandardLinkBuilder
context path, path variables, query, fragment"] std --> enc["response.encodeURL
jsessionid, proxies, CDN, resource versions"] enc --> out["href written to the HTML"]
The kinds of URL
The Standard URL syntax article distinguishes five shapes of URL base, told apart only by how the base starts:
| Kind | Starts with | Example expression | Rendered (application at /bookshelf) |
|---|---|---|---|
Absolute |
a protocol, |
|
|
Context-relative |
|
|
|
Server-relative |
|
|
|
Protocol-relative |
|
|
|
Page-relative |
anything else |
|
|
All five are "unchanged except for URL rewriting" apart from the context-relative one, which receives the context path, and the
server-relative one, which loses the ~. The following template contains one of each, and is rendered below by a request to
/bookshelf/links (context path /bookshelf) and by one to /links (no context path):
<link rel="stylesheet" href="../../static/css/site.css" th:href="@{/css/site.css}">
<script src="https://code.jquery.com/jquery-4.0.0.min.js"
th:src="@{//code.jquery.com/jquery-4.0.0.min.js}"></script>
...
<a th:href="@{https://www.thymeleaf.org/documentation.html}">absolute</a>
<a th:href="@{/books}">context-relative</a>
<a th:href="@{~/billing/showDetails}">server-relative</a>
<a th:href="@{details.html}">page-relative</a>
<link rel="stylesheet" href="/bookshelf/css/site.css">
<script src="//code.jquery.com/jquery-4.0.0.min.js"></script>
...
<a href="https://www.thymeleaf.org/documentation.html">absolute</a>
<a href="/bookshelf/books">context-relative</a>
<a href="/billing/showDetails">server-relative</a>
<a href="details.html">page-relative</a>
<a href="/books">context-relative</a>
<a href="/billing/showDetails">server-relative</a>
Source: Standard URL Syntax — Absolute URLs, Context-relative URLs, Server-relative URLs, Protocol-relative URLs, Using Thymeleaf — Server root relative URLs
(In the first listing the stylesheet is shown without the content hash that appears once the resource chain is enabled; see below.)
Which kind to use:
-
Context-relative for every link to your own application. It is the form you will write most, and the only one that survives a change of deployment path.
-
Server-relative for a link to another application on the same host, such as
/billingnext to/bookshelf. -
Absolute for other sites; protocol-relative for third-party scripts and styles that must follow the page’s own protocol (increasingly unnecessary when everything is HTTPS).
-
Page-relative only for links between static pages that must work both ways; it is resolved by the browser against the current URL, not by Thymeleaf, so it breaks when the same template is served from different paths.
The context path comes from the servlet container (server.servlet.context-path in Spring Boot, or the name of the deployed
WAR); Thymeleaf reads it from the request. This is why a context-relative link needs a web context. Outside a web application the
engine cannot know it and refuses to guess:
var engine = new TemplateEngine();
engine.setTemplateResolver(new StringTemplateResolver());
var ctx = new Context(); // not an IWebContext
ctx.setVariable("id", 7);
engine.process("<a th:href=\"@{https://shop.example.com/order/{id}/details(id=${id},view='full')}\">x</a>"
+ "<a th:href=\"@{order/{id}/details(id=${id})}\">x</a>"
+ "<a th:href=\"@{//cdn.example.com/a.js}\">y</a>", ctx);
<a href="https://shop.example.com/order/7/details?view=full">x</a><a href="order/7/details">x</a><a href="//cdn.example.com/a.js">y</a>
Source: Using Thymeleaf — Link URLs
Replacing the first expression by @{/order/{id}/details(id=${id})} in that non-web context fails at render time with a
TemplateProcessingException stating that the link base "cannot be context relative" unless the context used for executing the engine implements the IWebContext interface. Emails and other standalone output should therefore use
absolute URLs with the application’s public base URL (see Standalone Engine & Configuration
for the engine without a web context).
Parameters, path variables and fragments
Parameters are written in parentheses after the base, separated by commas. Every parameter value is itself an expression, and the required URL encoding is applied automatically, for names and values:
<a th:href="@{/books/details(id=${book.id})}">one parameter</a>
<a th:href="@{/books/details(id=${book.id},view='full')}">two parameters</a>
<a th:href="@{/authors/search(name=${book.author},genre=${genres})}">multi-valued</a>
<a th:href="@{/search(q=${query})}">encoding</a>
<a th:href="@{/search(flag)}">no value</a>
With book.id 42, book.author "Frank Herbert", genres a list of sci-fi and classic, and query the string
Dune & Co: a/b?, the output is:
<a href="/bookshelf/books/details?id=42">one parameter</a>
<a href="/bookshelf/books/details?id=42&view=full">two parameters</a>
<a href="/bookshelf/authors/search?name=Frank%20Herbert&genre=sci-fi&genre=classic">multi-valued</a>
<a href="/bookshelf/search?q=Dune%20%26%20Co:%20a/b?">encoding</a>
<a href="/bookshelf/search?flag">no value</a>
Three details are visible in the output. The ampersand between parameters is rendered as &, which is what HTML requires in an
attribute. A collection value is expanded into one name=value pair per element (genre=sci-fi&genre=classic), the form Spring
binds to a List<String> request parameter. And a parameter written without a value is rendered as a bare name. Note also that the
encoding escapes what would break the query string (space, &) but leaves characters that are legal inside a query component
(:, /, ?) as they are.
A path variable is a {name} placeholder inside the base, filled from the parameter of the same name; a parameter that has no
placeholder stays in the query string:
<a th:href="@{/books/{id}/details(id=${book.id},view='full')}">path variable</a>
<a href="/bookshelf/books/42/details?view=full">path variable</a>
A fragment identifier (#reviews) is written at the end of the base. Thymeleaf always puts it after the query string, whatever
the position in the expression, because that is the order a URL requires:
<a th:href="@{/books/{id}/details#reviews(id=${book.id},view='full')}">fragment</a>
<a href="/bookshelf/books/42/details?view=full#reviews">fragment</a>
The fragment is literal text. Writing a placeholder in it, as in @{/books/{id}/details#{s}(id=${book.id},s=${section})}, does not
fill it: the run on Thymeleaf 3.1.5 rendered #{s} unchanged and moved s=reviews into the query string. To choose the anchor at
run time, append it to a text expression that is applied outside the link, or keep it fixed.
Parameters are also the safe way to put user data in a URL: never concatenate it into the base, where it is not encoded, and never use it to choose the base (see restricted mode below).
Expressions in URLs
Because each parameter value is an expression, anything valid in the Standard Expression syntax works there: variables, message keys, literals, conditionals and operators:
<a th:href="@{/books/{id}/details(id=${book.id},view=(${admin} ? 'full' : 'short'))}">conditional</a>
admin false<a href="/bookshelf/books/42/details?view=short">conditional</a>
The conditional has to be wrapped in parentheses so that its ? and : are not read as part of the URL syntax. The same holds for
a message or a literal on the right-hand side.
The URL base is an expression too. A literal base is in fact a shortcut: @{/order/details(id=3)} is the same as
@{'/order/details'(id=3)}. Any other expression can take its place: a variable, an externalised message, a concatenation or a
conditional:
<a th:href="@{${detailsUrl}(id=${book.id})}">variable base</a>
<a th:href="@{#{books.details.url}(id=${book.id})}">message base</a>
<a th:href="@{'/books/'+${book.id}+'/details'}">concatenated base</a>
<a th:href="@{(${admin} ? '/admin/home' : ${user.homeUrl})(id=${book.id})}">conditional base</a>
With detailsUrl set to /books/details the first two rendered as /bookshelf/books/details?id=42 and the third as
/bookshelf/books/42/details. For a complex base, name it with th:with and keep the link readable:
<div th:with="baseUrl=(${admin} ? '/admin/home' : ${user.homeUrl})">
<a th:href="@{${baseUrl}(id=${book.id})}">home</a>
</div>
There is no @{...} inside a ${...} expression: th:text="${'Go to ' + @{/books}}" is rejected by the Spring EL parser
("@ or & can only be followed by an identifier or a quoted name"). Compose in the other direction, with the link as an operand of a
Standard Expression:
<p th:text="'Go to ' + @{/books}">Go to /books</p>
<p>Go to /bookshelf/books</p>
Not only th:href
Link expressions are expressions, so any attribute or text that takes one can use them. The usual homes are:
-
th:hrefon<a>and<link>,th:srcon<img>and<script>, andth:actionon<form>. -
As an operand in a text or attribute value, as in the previous example, or as an argument of a message such as
#{orders.explanation('3', @{/order/details(id=3)})}.
th:action does one more thing in a Spring application. For a POST form the Spring dialect asks Spring’s
RequestDataValueProcessor about the action URL and any hidden fields; with Spring Security present, this is how the CSRF token
is added to every POST form without any template code:
<form th:action="@{/books}" method="post"><button>Save</button></form>
<form action="/bookshelf/books" method="post"><input type="hidden" name="_csrf" value="..."/><button>Save</button></form>
Hence the rule for forms: use th:action (never a plain action) for every form that changes state, and keep state changes on POST.
RequestDataValueProcessor itself belongs to the section on Spring MVC integration.
How links are built: ILinkBuilder
@{...} is evaluated into a base string and a map of parameters, which are handed to the registered ILinkBuilder`s in order of
their `getOrder(). StandardLinkBuilder is registered by default and does all the work
described above (context path, ~, path variables, query encoding, fragment, then encodeURL). A second builder can claim the
links it understands and return null for the rest, so that the next builder is asked. The following one maps a made-up asset: scheme to a CDN host:
public class CdnLinkBuilder extends AbstractLinkBuilder {
private final String cdnBase;
public CdnLinkBuilder(String cdnBase) {
this.cdnBase = cdnBase;
setName("cdn");
setOrder(10);
}
@Override
public String buildLink(IExpressionContext context, String base, Map<String, Object> parameters) {
if (base == null || !base.startsWith("asset:")) {
return null; // not ours: the next ILinkBuilder is asked
}
return cdnBase + "/" + base.substring("asset:".length());
}
}
TemplateEngine.addLinkBuilder(...) registers it. Spring Boot does not pick up ILinkBuilder beans by itself (its
auto-configuration collects template resolvers and dialects only), so the builder is added to the auto-configured engine bean
before it initialises, here with a BeanPostProcessor:
@Configuration(proxyBeanMethods = false)
class LinkConfig {
@Bean
static BeanPostProcessor cdnLinkBuilderRegistrar() {
return new BeanPostProcessor() {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) {
if (bean instanceof SpringTemplateEngine engine) {
engine.addLinkBuilder(new CdnLinkBuilder("https://cdn.example.com/assets"));
}
return bean;
}
};
}
}
<a th:href="@{/books}">books</a><a th:href="@{asset:logo.svg}">logo</a>
<a href="/books">books</a><a href="https://cdn.example.com/assets/logo.svg">logo</a>
For most applications a custom builder is unnecessary: a CDN or a proxy is better served by the encodeURL hook described next.
URL rewriting
For every URL it builds, StandardLinkBuilder calls response.encodeURL(...) on the Servlet API response (Thymeleaf 3.1 supports
both the javax.servlet and the jakarta.servlet namespaces; Boot 4 uses jakarta). That is the standard Java hook for
URL rewriting, and anything done there applies to every Thymeleaf link, with no change in the templates:
-
the container adds
;jsessionid=...when it does not know whether the browser accepts cookies (on the first request) or when cookies are off; -
a rewriting filter such as UrlRewriteFilter, or your own response wrapper, can add proxy prefixes or send static assets to a CDN;
-
Spring MVC’s
ResourceUrlEncodingFilterinserts a version into the URLs of static resources (next section).
A small response wrapper shows the mechanism. It appends a marker to every URL that contains no query string, and it is registered only for one path:
@Bean
FilterRegistrationBean<jakarta.servlet.Filter> trackingFilter() {
var registration = new FilterRegistrationBean<jakarta.servlet.Filter>((req, res, chain) ->
chain.doFilter(req, new HttpServletResponseWrapper((HttpServletResponse) res) {
@Override
public String encodeURL(String url) {
return super.encodeURL(url) + (url.startsWith("/") && !url.contains("?") ? ";v=1" : "");
}
}));
registration.setOrder(Integer.MAX_VALUE);
registration.addUrlPatterns("/links/cdn");
return registration;
}
<a href="/books;v=1">books</a>
The base passed to encodeURL already carries the context path and the query string, so a wrapper sees the final URL. The tutorial’s own examples are
marked "plus rewriting" for absolute URLs too, so the hook applies to every kind of URL. encodeURL belongs to the Servlet API, so
this mechanism is a servlet one.
Versioned static resources and ResourceUrlEncodingFilter
Browsers cache static files aggressively; the clean way to let them cache forever and still see a change at once is a content hash in the URL, which Spring MVC’s resource chain provides. Spring Boot switches it on with properties:
spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/css/**
The templates do not change: th:href="@{/css/site.css}" keeps being written. The link is rewritten at run time by the Spring
Framework’s ResourceUrlEncodingFilter, which wraps the response so that encodeURL returns the versioned URL, using the resource
chain’s ResourceUrlProvider. Spring Boot’s Thymeleaf auto-configuration registers this filter when the resource chain is enabled
(ThymeleafAutoConfiguration, servlet web applications only), so no code is needed; if the filter is declared as a bean, Boot backs off.
<link rel="stylesheet" href="/bookshelf/css/site-c94009be5a506228c558093afff86b35.css">
The hash is computed from the file content, so it changes exactly when the file does, and the request for the hashed URL is
served by the same resource chain. The fixed strategy (spring.web.resources.chain.strategy.fixed.*) puts a constant version in the
path instead, which is useful for modules loaded by a JavaScript module loader. WebJars get versioned URLs of their own
(/webjars/jquery/1.2.0/jquery.min.js) and are linked with the same context-relative syntax. Without the chain enabled, the same
template renders the plain /css/site.css; the filter does nothing when there is no resource chain to ask.
Things to know:
-
The filter works through
encodeURL, so it only affects links that Thymeleaf builds through@{...}. A hard-codedhrefis not rewritten. -
Only paths matching the strategy’s
pathsare versioned. -
It is servlet-only: Boot registers it for servlet web applications, and it works through
encodeURL.
Restricted mode for URL bases
The tutorial’s Appendix D defines a restricted expression evaluation mode, a defence-in-depth measure rather than a first line of
defence. One of the contexts where it applies is "the base (non-parameter) part of URL expressions: @{...} URL bases,
th:href, and th:src"; the parameters within a link expression are not restricted. In restricted mode the expression
cannot read request parameters (${param.*}), instantiate objects (new ...) or use static classes, and some context utility
objects such as #execInfo are not available.
A base chosen from the request is therefore rejected, which also blocks the classic open redirect built on it:
<a th:href="@{${param.next}}">next</a>
A request to this page with ?next=https://evil.example does not render a link: the engine throws a TemplateProcessingException
with the message "Instantiation of new objects and access to static classes or parameters is forbidden in this context". The same
data is accepted as a parameter (the first expression below), where it is encoded as a value, and a new in the base is refused
as well:
<a th:href="@{/books(q=${param.q})}">search</a>
?q=x y<a href="/books?q=x%20y">search</a>
Source: Using Thymeleaf — Appendix D: Expression Restrictions, Restricted mode: Contexts in which it applies
Restricted mode is a net, not a guarantee. A base that arrives from the model (a variable the controller put there) is
allowed, and Thymeleaf does not look at its scheme. Treat any URL that originated with a user (a "return to" address, a profile
website) as untrusted: accept only relative paths or an allow-list of hosts in the controller, and show other values as
text with th:text. The pitfalls table collects this and the other traps.
Links to controller methods with #mvc.url
A link to an @RequestMapping method can be built from its mapping, without repeating the path, so that changing the mapping updates
every link. In Spring MVC, Thymeleaf offers the expression object #mvc for this (the equivalent of the JSP function
spring:mvcUrl). #mvc.url('NAME') returns a builder; .arg(index, value) supplies the n-th method argument, whether it is a
@PathVariable or a @RequestParam, and build() returns the URL string.
By default each mapping is named from the capital letters of the controller class followed by # and the method name, so
LinkController.page is LC#page. An explicit name on the mapping annotation replaces the default name; the controller is:
@Controller
public class LinkController {
@GetMapping("/links")
public String page(Model model) { ... }
@GetMapping(name = "book-details", path = "/books/{id}/details")
public String details(@PathVariable long id,
@RequestParam(defaultValue = "short") String view, Model model) { ... }
}
<a th:href="${#mvc.url('LC#page').build()}">mvc</a>
<a th:href="${#mvc.url('book-details').arg(0, 42L).arg(1, 'full').build()}">mvc args</a>
<a th:href="${#mvc.url('book-details').arg(0, book.id).build()}">named mapping</a>
<a href="/bookshelf/links">mvc</a>
<a href="/bookshelf/books/42/details?view=full">mvc args</a>
<a href="/bookshelf/books/42/details">named mapping</a>
The result already includes the context path and fills the path variables and parameters from .arg(...). Two observations from
the run: once a mapping is given an explicit name, the default LC#details no longer exists (looking it up throws
IllegalArgumentException: Mapping not found: LC#details), and an argument that is not supplied is left out (the third link has
no view parameter, although the method defaults it). Overloaded methods with the same name are the usual reason to set an
explicit name. #mvc is only available with thymeleaf-spring6, which is also the module used with Spring Framework 7 (there is no
thymeleaf-spring7).
Pitfalls
| Mistake | What happens | Do this |
|---|---|---|
Hard-coded |
Works at the root and breaks under a context path; never versioned or rewritten |
Always |
|
|
Use an absolute base (the public URL of the site) in emails and other standalone output |
Building the query by hand, |
The value is not encoded; |
|
Choosing the base from request data, |
Blocked in restricted mode; if passed through the model instead, nothing checks the scheme or host (open redirect, |
Validate in the controller (relative path or allow-listed host) and pass it only as a parameter value |
|
Spring EL parse error ( |
Use |
Placeholder in the fragment, |
Left unexpanded as text; the value moves to the query string |
Keep the fragment literal |
Conditional parameter value without parentheses |
The |
|
Explicit |
|
Use the explicit name; give one when two mappings would get the same default |
Expecting |
The filter only sees URLs that pass through |
Write the link as |
Putting |
No CSRF hidden field is added; Spring Security rejects the request |
|
Version notes
-
The
@{...}syntax andILinkBuilderare unchanged across 3.0 and 3.1. Since 3.1 the servlet types arejakarta.*, and the engine reaches the request throughIWebExchange(see Introduction & Architecture); the old#request,#responseand#sessionutility objects were removed, but link expressions do not depend on them. -
The Spring Boot resource-chain properties and the auto-configured
ResourceUrlEncodingFilterare as in the Boot 4.1 reference; the filter is registered byThymeleafAutoConfiguration, which now lives in thespring-boot-thymeleafmodule (see Spring Boot Auto-configuration & Properties). -
The Thymeleaf + Spring tutorial still links the Spring documentation for
#mvc.urlat an address that no longer exists; the current page is the Spring Framework "URI Links" chapter, "Links in Views". -
The consulted edition of Taming Thymeleaf (Wim Deblauwe, PDF v1.1.1, 2021) introduces
@{}with query parameters and path variables; the syntax is unchanged, but take Boot 4 coordinates and package names from this section.