Styling & Front-end Assets

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, with Tailwind CSS 4.3.3 (npm, 2026-07-16), Vite 8.3.3, frontend-maven-plugin 2.0.2 and the Gradle node plugin 7.1.0, all checked on 2026-10-08. Every example was run on that stack: the Spring parts through MockMvc (and WebTestClient for WebFlux) in a Spring Boot 4.1.1 scratch application, the Tailwind and Vite parts as real builds. The output shown is what was produced. Thymeleaf itself only writes the URLs of the assets (@{...}); the files are served by the web framework and built by your build tool. So the serving and cache-busting sections apply to Thymeleaf with Spring MVC (everything works) and with Spring WebFlux (static files and WebJars work, but links are not versioned, see the WebFlux section), and the Tailwind, Vite and Bootstrap parts also apply to standalone Thymeleaf, where you serve the files yourself.

The books about Thymeleaf describe a front-end pipeline that no longer matches current tooling. The sections below say what changed and show the current approach, including the part that surprises most people coming from Tailwind 3 or from those books: Tailwind v4 has no purge or content setting, and it scans your templates as plain text.

In a hurry: what to use for what

You want…​ Use Why

Your own CSS, JavaScript and images served

Files in src/main/resources/static, linked with th:href="@{/css/site.css}"

Spring Boot serves static, public, resources and META-INF/resources on the classpath by default.

A library such as htmx or Bootstrap without npm

A WebJar plus webjars-locator-lite

A pinned, self-hosted, version-less path in the template.

Files that browsers may cache for a year

spring.web.resources.chain.strategy.content.enabled=true

@{...} links get a content hash in the file name, so a changed file is a new URL.

Tailwind CSS v4

@import "tailwindcss" plus the standalone CLI, @tailwindcss/cli or @tailwindcss/vite

One CSS entry file; no config file; the engine scans your templates/**/*.html as text.

The CSS build inside mvn or gradle

frontend-maven-plugin (Maven) or com.github.node-gradle.node (Gradle)

Installs a local Node and runs your npm run build:css.

A new project with all of this wired

ttcli init

The Taming Thymeleaf generator; check the generated Tailwind version, see below.

Template and CSS edits visible while developing

spring.thymeleaf.cache=false, a Tailwind or Vite watcher

DevTools LiveReload is deprecated since Spring Boot 4.1.0 with no replacement.

From sources through the Tailwind build and the Spring resource chain to a hashed link in a Thymeleaf page, with a WebJar joining the chain
Figure 1. Front-end asset pipeline for a Thymeleaf page

Where the books are outdated

Two books cover this ground, and both predate the current tools. They are useful for the ideas and for the project layouts, but do not copy their set-up.

  • Taming Thymeleaf (the 2021 edition, version 1.1.1, was consulted; the Leanpub edition is now updated for Spring Boot 3) builds its application with Tailwind CSS 2: a purge list in the Tailwind configuration, PostCSS, a Gulp pipeline that copies files into target/classes with browser-sync as the live-reload proxy, and Node 13. In Tailwind v4 there is no purge option (nor content; the engine finds the sources itself), the CLI or Vite plugin replaces PostCSS and Gulp, and Node 13 is long out of support. The frontend-maven-plugin and a release profile for the production build are still a sound idea and are shown below with current versions.

  • Modern frontends with htmx generates its demos with ttcli and Tailwind 3: a tailwind.config.js with a content array and the three @tailwind directives. Tailwind v4 replaces them with a single @import "tailwindcss" line and CSS-first configuration (@theme, @source). The v4 upgrade guide documents that the @tailwind directives were removed and that a JavaScript config file is no longer detected automatically (it can be loaded with @config). The htmx and WebJar usage in that book is also older than htmx 2; see htmx Fundamentals for that.

Both books also use the local profile idea (spring.thymeleaf.cache=false only on a developer machine), which is still valid and is covered under live reload below.

For the same change on the Tailwind side, see the Tailwind upgrade guide page of the Tailwind section.

Static resources in Spring Boot

By default Spring Boot serves files from the classpath directories static, public, resources and META-INF/resources, mapped on /**, through Spring MVC’s resource handler. (In an executable web application the servlet container’s default servlet is not enabled; server.servlet.register-default-servlet turns it on as a fallback.) The three properties that change this are spring.web.resources.static-locations (replaces the list of locations), spring.mvc.static-path-pattern (the URL pattern, default /**) and spring.web.resources.add-mappings (default true; false turns the whole default mapping off). In Thymeleaf you do not write the path by hand: you use a link expression, which prepends the context path and, as the next sections show, can version the file.

Put the files under static:

src/main/resources/
  static/
    css/site.css
    js/shelf.js
  templates/
    assets/page.html

and link them with th:href and th:src. The plain href is the natural-template fallback: opened as a file the page still finds its stylesheet, and the server replaces it.

A template that links its CSS and JavaScript
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Assets</title>
  <link rel="stylesheet" th:href="@{/css/site.css}" href="../../static/css/site.css">
  <script th:src="@{/js/shelf.js}" defer></script>
</head>
<body>
  <h1>Bookshelf</h1>
</body>
</html>

Rendered by a controller method that returns assets/page (Boot 4.1.1, no extra properties):

<link rel="stylesheet" href="/css/site.css">
<script src="/js/shelf.js" defer></script>

With a context path (a request made to /shelf/assets in the context /shelf), the same template rendered href="/shelf/css/site.css". That is the reason to always write @{/css/site.css} rather than a literal /css/site.css: the literal form breaks when the application is deployed under a context path, and it is never versioned (below).

The link page of this section (Link URLs) covers the @{...} syntax itself.

Spring Security and static files

With Spring Security on the classpath every request needs a decision, including the CSS that the login page itself loads. A rule such as anyRequest().authenticated() without an exception for the assets answers 302 to /login for them, so the login page shows unstyled. Boot has a helper for the common locations, and your own output directory (here /dist, where the Tailwind build writes) gets an explicit matcher:

Permitting static resources in the security filter chain
@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(a -> a
            .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
            .requestMatchers("/login", "/dist/**").permitAll()
            .anyRequest().authenticated())
        .formLogin(Customizer.withDefaults());
    return http.build();
}

PathRequest here is org.springframework.boot.security.autoconfigure.web.servlet.PathRequest (the package changed in Boot 4). atCommonLocations() covers the locations named by the StaticResourceLocation enum: CSS, JAVA_SCRIPT, IMAGES, WEB_JARS, FAVICON and FONTS. It compiled and ran in the scratch application, which served /dist/app.css, /js/shelf.js and /webjars/... without a login. See Spring Security Integration for the rest of the security set-up.

WebJars and the version-less locator

A WebJar is a Maven artifact that packages a client-side library under META-INF/resources/webjars/<name>/<version>/. Spring Boot maps /webjars/** to those directories (the pattern can be changed with spring.mvc.webjars-path-pattern), so a library arrives as an ordinary dependency, is pinned by Maven and is served from your own origin. The WebJars site lists the coordinates, and the org.webjars.npm group mirrors npm packages.

htmx and Bootstrap as WebJars in pom.xml
<dependency>
  <groupId>org.webjars</groupId>
  <artifactId>webjars-locator-lite</artifactId>
</dependency>
<dependency>
  <groupId>org.webjars.npm</groupId>
  <artifactId>htmx.org</artifactId>
  <version>2.0.11</version>
</dependency>
<dependency>
  <groupId>org.webjars</groupId>
  <artifactId>bootstrap</artifactId>
  <version>5.3.8</version>
</dependency>

Source: WebJars

The Spring Boot 4.1.1 BOM manages webjars-locator-lite (1.1.4 there), so no version is given for it. It does not manage the libraries themselves: give their version, which is the version of the npm package. The htmx.org WebJar is a good example of why to pin it deliberately: the newest one on Maven Central is 4.0.0, the htmx 4 line, which is not the htmx 2 API that the htmx pages of this section teach.

webjars-locator-lite lets the template leave the version out. The path /webjars/htmx.org/dist/htmx.min.js is resolved to the one WebJar that provides htmx.org, so upgrading the library is a change to pom.xml only:

Version-less WebJar paths in a template
<link rel="stylesheet" th:href="@{/webjars/bootstrap/css/bootstrap.min.css}">
<script th:src="@{/webjars/bootstrap/js/bootstrap.bundle.min.js}" defer></script>
<script th:src="@{/webjars/htmx.org/dist/htmx.min.js}" defer></script>

Rendered on Boot 4.1.1 with the locator on the classpath, the links come out with the real version, and the version-less path is also answered when requested directly:

<link rel="stylesheet" href="/webjars/bootstrap/5.3.8/css/bootstrap.min.css">
<script src="/webjars/htmx.org/2.0.11/dist/htmx.min.js" defer></script>
GET /webjars/htmx.org/dist/htmx.min.js         -> 200
GET /webjars/htmx.org/2.0.11/dist/htmx.min.js  -> 200 text/javascript

Without webjars-locator-lite the same template renders the path as written, and the version-less request answers 404; the scratch application showed exactly that, so a missing locator is the usual reason for a WebJar link that "worked in the IDE". Spring Boot’s own documentation describes the version-agnostic URLs and the webjars-locator-lite dependency in the same Static Content section quoted above, and the WebJars site has a Spring Boot page.

For htmx and Alpine.js as WebJars, versus a CDN with Subresource Integrity, see htmx Fundamentals and Alpine.js & Web Components. Both load their scripts from /webjars/** and mention that the security chain must permit that path.

Versioned resources and ResourceUrlEncodingFilter

Browsers can cache a CSS file for a year, but only if a deployment that changes the file also changes its URL. Spring’s resource chain does that by inserting a version into the URL when the template is rendered, and understanding the URL again when the browser asks for it. There are two pieces:

  • A versioning strategy on the resource handler: content (a hash of the file in the name, such as site-49ab...c1.css) or fixed (a constant prefix such as /v12/).

  • The ResourceUrlEncodingFilter, which makes HttpServletResponse.encodeURL() return the versioned URL. Thymeleaf’s @{...} link builder calls encodeURL, so templates get versioned links without any change. Spring Boot registers the filter for Thymeleaf (and FreeMarker) automatically; you only declare it yourself for a template technology such as JSP.

Turn the content strategy on for everything:

Content-hash versioning in application.properties
spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/**
spring.web.resources.cache.cachecontrol.max-age=365d
spring.web.resources.cache.cachecontrol.cache-public=true

The template did not change. This is what it rendered, and what the browser’s follow-up requests got:

<link rel="stylesheet" href="/css/site-49ab8c649aaddc17697d493259623cc1.css">
<script src="/webjars/htmx.org/2.0.11/dist/htmx.min-3d6553be673509e52e526683624b4e2b.js" defer></script>
<script src="/js/shelf-d5223a5999e03fcc5a30bfd072112045.js" defer></script>

GET /css/site-49ab8c649aaddc17697d493259623cc1.css -> 200 text/css
    Cache-Control: max-age=31536000, public   ETag: W/"49ab8c649aaddc17697d493259623cc1"
GET /css/site.css                                    -> 200   (the plain URL still works)

The same run without the two cachecontrol properties answered with Cache-Control: no-cache, no-store, max-age=0, must-revalidate. That header comes from Spring Security’s default response headers when the resource handler sets none, so on an application with Spring Security the cache properties are what make the assets cacheable at all.

A JavaScript module loader that builds URLs in the browser cannot use renamed files. For those, the fixed strategy puts a version in the path instead of the file name, and the two can be combined, with the content strategy on the rest:

A fixed version for one directory, content hashes elsewhere
spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/css/**
spring.web.resources.chain.strategy.fixed.enabled=true
spring.web.resources.chain.strategy.fixed.paths=/js/**
spring.web.resources.chain.strategy.fixed.version=v12
<link rel="stylesheet" href="/css/site-49ab8c649aaddc17697d493259623cc1.css">
<script src="/v12/js/shelf.js" defer></script>

A pitfall appears here: the fixed strategy changes the path, so /v12/js/shelf.js is answered 302 to the login page until the security chain permits /v12/** (the content strategy keeps the directory, so /css/** keeps matching). The other pitfalls: a plain href="/css/site.css" without th:href is never rewritten, and the filter only helps links that go through @{...}, because only those reach encodeURL.

Spring WebFlux

With Thymeleaf and Spring WebFlux the static files and WebJars are served (properties: spring.webflux.static-path-pattern and spring.webflux.webjars-path-pattern, defaults /** and /webjars/**; locations still come from spring.web.resources.static-locations), and the locator resolves version-less WebJar paths when a request arrives. What does not happen is link versioning: in a WebFlux scratch application with the same two content-strategy properties the template

<link rel="stylesheet" th:href="@{/css/site.css}">
<script th:src="@{/webjars/htmx.org/dist/htmx.min.js}" defer></script>

rendered the links exactly as written (/css/site.css and /webjars/htmx.org/dist/htmx.min.js), with both requests answering 200. Spring Boot’s documentation of ResourceUrlEncodingFilter auto-configuration is in the Spring MVC section only. In a WebFlux application, let the build tool put a hash in the file name (Vite does by default) or serve the assets through a CDN or proxy that does, and give the files long cache headers only when the name changes.

The page about Thymeleaf with Spring WebFlux covers the rest of the reactive set-up.

Tailwind CSS v4 with Thymeleaf templates

Tailwind generates CSS for the utility classes it finds in your files, and nothing else. In v4 you do not tell it where the templates are with a content array; it scans the project, and @source adds or changes the places it looks. The Tailwind section covers the framework itself (see Tailwind CSS, Getting Started and Build and Production); this section covers how to wire it to a Spring Boot and Thymeleaf project. The surrounding HTML and CSS fundamentals are in the HTML & CSS section.

The project layout

Keep the Tailwind source outside static, so that the source and the generated file are never confused, and generate into static/dist:

package.json
src/main/frontend/app.css                      <- the Tailwind entry file (source)
src/main/resources/templates/**/*.html         <- scanned for classes
src/main/resources/static/dist/app.css         <- generated (add to .gitignore)
package.json with the Tailwind CLI
{
  "name": "bookshelf-frontend",
  "private": true,
  "scripts": {
    "build:css": "tailwindcss -i src/main/frontend/app.css -o src/main/resources/static/dist/app.css --minify",
    "watch:css": "tailwindcss -i src/main/frontend/app.css -o src/main/resources/static/dist/app.css --watch"
  },
  "devDependencies": {
    "@tailwindcss/cli": "4.3.3",
    "tailwindcss": "4.3.3"
  }
}
The whole of src/main/frontend/app.css
@import "tailwindcss";
@source "../resources/templates/**/*.html";

The CLI is the @tailwindcss/cli package, and its executable is called tailwindcss. Tailwind also ships it as a standalone executable that needs no Node.js (the installation page says so; it was not used here). The @source path is relative to the CSS file, and a glob is accepted. A template that uses utility classes, including in a Thymeleaf attribute value:

A template styled with Tailwind utilities
<link rel="stylesheet" th:href="@{/dist/app.css}" href="../../static/dist/app.css">
...
<body class="bg-slate-50 text-slate-900">
  <h1 class="text-2xl font-bold tracking-tight">Bookshelf</h1>
  <button type="button" th:classappend="${highlight} ? 'ring-2 ring-amber-500'"
          class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-500">Borrow</button>
</body>

Running the build (npm ci, then npm run build:css) on a project with only that template:

$ npm run build:css

> build:css
> tailwindcss -i src/main/frontend/app.css -o src/main/resources/static/dist/app.css --minify

≈ tailwindcss v4.3.3

Done in 18ms
$ wc -c src/main/resources/static/dist/app.css
    7554 src/main/resources/static/dist/app.css

Without --minify the same output is 8,766 bytes. It starts with the licence comment and the layer order, then the reset, then 14 plain utility rules and the hover variant, one for each class that was found, including the one that Thymeleaf adds in th:classappend (ring-amber-500):

/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
...
@layer utilities {
  .rounded-md {
    border-radius: var(--radius-md);
  }
  .bg-indigo-600 {
    background-color: var(--color-indigo-600);
  }
  ...
  .ring-amber-500 {
    --tw-ring-color: var(--color-amber-500);
  }
  @media (hover: hover) {
    .hover\:bg-indigo-500:hover {
      background-color: var(--color-indigo-500);
    }
  }
}

The ... lines mark omitted rules. The point of the output is that its size follows what the templates use: remove the button and the next build no longer contains the indigo rules, with no purge step.

What gets scanned, and how to control it

Tailwind reads files as plain text, looks for strings that look like class names, and generates CSS for the ones that exist. It does not parse HTML or run Thymeleaf. That has three consequences for a template engine.

  • Anything that spells the class out is found: class, th:class, th:classappend, th:attr="class=...", a Java enum that holds "bg-rose-600". Thymeleaf expressions around the string do not matter.

  • A class assembled at render time is never found. See the pitfall below.

  • By default Tailwind scans the project, skipping files in .gitignore, node_modules, binary files, CSS files and package-manager lock files. The scan starts at the current working directory.

The @source directives change that. Three forms were built and measured on the scratch application:

Automatic detection, explicit sources, and a Java directory
/* 1. automatic: scans the whole project from the working directory */
@import "tailwindcss";

/* 2. only what you list, nothing else */
@import "tailwindcss" source(none);
@source "../resources/templates/**/*.html";

/* 3. templates plus Java classes that hold class names, e.g. an enum of badge styles */
@import "tailwindcss" source(none);
@source "../resources/templates";
@source "../java";

Results in the scratch application, which has a Java enum with the constants "bg-green-600" and "bg-rose-600" and four templates:

Entry file Output size (minified) Observation

@import "tailwindcss" (automatic)

8,140 bytes

Contains bg-rose-600 and bg-green-600 from the Java source. Also contains the .static{position:static} utility, because the Java keyword static appears in the sources.

source(none) and @source of the templates only

7,924 bytes

No .static; neither bg-rose-600 nor bg-green-600, because the Java directory is not scanned.

source(none), @source of the templates and of ../java

8,140 bytes

The same output as automatic detection: both enum classes and .static are generated.

Automatic detection is convenient, and it is also why a stray word such as static in a Java file adds a rule. Use source(none) with explicit sources in a project whose build output has to be predictable, and keep generated and unrelated directories out of the scan.

Other directives from the same Tailwind page, all documented there: @source not "..." to ignore a path, the source("...") argument of the import to move the base path (for a monorepo), and @source inline("...") to force classes into the output. The last one, for a class that is stored in a database or built by code, was run:

@import "tailwindcss";
@source "../resources/templates/**/*.html";
@source inline("bg-{red,green,blue}-500");

The output then contained bg-red-500, bg-green-500 and bg-blue-500 and not bg-pink-500.

Pitfalls of text scanning in templates

The most common bug is a class name that Tailwind cannot see, because the template builds it:

<!-- never found: the full class name does not appear anywhere -->
<span th:class="'badge bg-' + ${color} + '-500'">dynamic</span>

<!-- found: both complete class names appear in the file -->
<span th:class="${color == 'red'} ? 'bg-red-600' : 'bg-green-600'">literal</span>

Built against that template, the CSS contained bg-red-600 and bg-green-600 and neither bg-red-500 nor bg-green-500. Write complete class names (a map or conditional that holds them), or safelist them with @source inline.

The other pitfalls: classes that only appear in a .css file are not scanned; files in .gitignore are skipped (so a template directory that you ignore, or a generated page, needs an explicit @source); --watch stops when its standard input closes unless you pass --watch=always (the CLI help lists that form, which a script or a node plugin needs); and generated static/dist/app.css belongs in .gitignore so that the build, not a commit, is the source of truth. The old tailwind.config.js with content is not read unless you load it with @config.

Running the build from Maven

A backend developer should be able to run mvn package without installing Node. The frontend-maven-plugin (com.github.eirslett, 2.0.2) downloads Node and npm into the build, runs npm ci and then your script, at generate-resources, which is before the resources are copied. Putting it in a profile keeps the day-to-day build fast and makes the production build explicit:

The frontend profile in pom.xml
<profiles>
  <profile>
    <id>frontend</id>
    <build>
      <plugins>
        <plugin>
          <groupId>com.github.eirslett</groupId>
          <artifactId>frontend-maven-plugin</artifactId>
          <version>2.0.2</version>
          <configuration>
            <nodeVersion>v24.12.0</nodeVersion>
            <installDirectory>target</installDirectory>
          </configuration>
          <executions>
            <execution>
              <id>install node and npm</id>
              <goals><goal>install-node-and-npm</goal></goals>
            </execution>
            <execution>
              <id>npm ci</id>
              <goals><goal>npm</goal></goals>
              <configuration><arguments>ci</arguments></configuration>
            </execution>
            <execution>
              <id>build css</id>
              <goals><goal>npm</goal></goals>
              <configuration><arguments>run build:css</arguments></configuration>
            </execution>
          </executions>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>

Running ./mvnw -Pfrontend generate-resources on a clean checkout produced (trimmed):

[INFO] --- frontend:2.0.2:install-node-and-npm (install node and npm) @ bookshelf ---
[INFO] Installing node version v24.12.0
[INFO] Installed node locally.
[INFO] --- frontend:2.0.2:npm (npm ci) @ bookshelf ---
[INFO] Running 'npm ci' in /.../bookshelf
[INFO] added 33 packages, and audited 34 packages in 444ms
[INFO] --- frontend:2.0.2:npm (build css) @ bookshelf ---
[INFO] Running 'npm run build:css' in /.../bookshelf
[INFO] > tailwindcss -i src/main/frontend/app.css -o src/main/resources/static/dist/app.css --minify
[ERROR] ≈ tailwindcss v4.3.3
[ERROR] Done in 21ms
[INFO] BUILD SUCCESS

The [ERROR] prefix is only the plugin printing what the CLI writes to its standard error stream; the build succeeded and static/dist/app.css existed afterwards. Node is downloaded from nodejs.org once into the local Maven repository and then unpacked into target. The documented skip properties turn the profile off per machine (-Dskip.installnodenpm -Dskip.npm), which printed Skipping execution. for each execution. The plugin README notes that it does not use an already-installed Node (use the exec-maven-plugin for that), and that node_modules belongs in .gitignore.

Running the build from Gradle

The Gradle equivalent is the node plugin, com.github.node-gradle.node (7.1.0 is the latest release listed on the Gradle plugin portal). It can download Node into .gradle, provides an npmInstall task, and an NpmTask runs a script. Wire the task into processResources:

build.gradle
plugins {
    id 'com.github.node-gradle.node' version '7.1.0'
}

node {
    download = true
    version = '24.12.0'
}

tasks.register('buildCss', NpmTask) {
    dependsOn tasks.named('npmInstall')
    args = ['run', 'build:css']
    inputs.dir('src/main/frontend')
    inputs.dir('src/main/resources/templates')
    outputs.dir('src/main/resources/static/dist')
}

tasks.named('processResources') {
    dependsOn tasks.named('buildCss')
}

./gradlew processResources with Gradle 9.7.1 ran npmInstall and buildCss (the same tailwindcss ... --minify line) and finished with BUILD SUCCESSFUL, leaving static/dist/app.css in place. Declaring the inputs and the output directory lets Gradle skip the task when neither the templates nor the CSS entry file changed.

A bundler: Vite

When the project has real JavaScript (TypeScript, Alpine components, Vue islands), Vite is the current bundler, and Tailwind has a first-party plugin for it. The build produces hashed or fixed file names, and the dev server serves the sources with hot updates. Install vite, tailwindcss and @tailwindcss/vite, and configure the output to go to static/dist:

vite.config.js
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

// Reload the browser when a Thymeleaf template changes (Vite only watches what it imports).
const reloadOnTemplateChange = () => ({
  name: 'reload-on-template-change',
  configureServer(server) {
    server.watcher.add('src/main/resources/templates')
    server.watcher.on('change', (file) => {
      if (file.endsWith('.html')) {
        server.ws.send({ type: 'full-reload' })
      }
    })
  }
})

export default defineConfig({
  plugins: [tailwindcss(), reloadOnTemplateChange()],
  build: {
    outDir: 'src/main/resources/static/dist',
    emptyOutDir: true,
    rollupOptions: {
      input: 'src/main/frontend/main.js',
      output: { entryFileNames: 'main.js', assetFileNames: '[name][extname]' }
    }
  },
  server: { port: 5173, strictPort: true }
})

with src/main/frontend/main.js containing import './app.css' and the same app.css as above. npm run build (vite build) printed:

vite v8.3.3 building client environment for production...
✓ 3 modules transformed.
src/main/resources/static/dist/main.css  7.58 kB │ gzip: 2.23 kB
src/main/resources/static/dist/main.js   0.00 kB │ gzip: 0.02 kB
✓ built in 109ms

The two entryFileNames/assetFileNames settings give stable names, so the template can link /dist/main.css and let the Spring resource chain add the content hash (on MVC). Vite’s own default is hashed names plus a manifest that a backend reads; its backend integration guide describes that route.

In development you do not build. The Spring template switches to the dev server when a property is set, and the pages then load their CSS and JavaScript from Vite with hot updates:

A property-driven switch between the Vite dev server and the built file
@ControllerAdvice
class ViteDevServerAdvice {

    private final String devServer;

    ViteDevServerAdvice(@Value("${app.vite.dev-server:}") String devServer) {
        this.devServer = devServer;
    }

    @ModelAttribute("viteDevServer")
    String viteDevServer() {
        return devServer.isBlank() ? null : devServer;
    }
}
<th:block th:if="${viteDevServer}">
  <script type="module" th:src="@{${viteDevServer} + '/@vite/client'}"></script>
  <script type="module" th:src="@{${viteDevServer} + '/src/main/frontend/main.js'}"></script>
</th:block>
<th:block th:unless="${viteDevServer}">
  <link rel="stylesheet" th:href="@{/dist/main.css}">
</th:block>

With app.vite.dev-server=http://localhost:5173 in a local profile (the value comes from configuration, never from the request) the page rendered the two module scripts, and without it the single built stylesheet:

<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/src/main/frontend/main.js"></script>
<link rel="stylesheet" href="/dist/main.css">

The Vite dev server was started and queried: it served /src/main/frontend/main.js, regenerated the CSS with the new class after a template edit (requested with the ?t= timestamp that its client adds), and pushed {"type":"full-reload"} messages over its WebSocket when page.html changed, which is what the small plugin above sends. Vite’s default server.cors allowed the origin http://localhost:8080 (it answered Access-Control-Allow-Origin: http://localhost:8080) and sent no such header for http://evil.example, so there is no reason to set cors: true.

Two details are easy to miss. Vite’s watcher takes a directory, not a glob: server.watcher.add('src/main/resources/templates/**/*.html') matched nothing in the test, and the directory form worked. And a string literal containing /@vite in the middle of a ${...} in th:src is refused by Thymeleaf 3.1.5 with the restricted-mode message "Instantiation of new objects and access to static classes or parameters is forbidden in this context": th:src="${viteDevServer + '/@vite/client'}" failed that way, while the form above, and th:src="${viteDevServer} + '/@vite/client'", rendered correctly. The same literal in th:title was fine; see Appendix D of the tutorial for the contexts where restricted mode applies.

For the build side, a Vite build is run from Maven or Gradle exactly like the CLI build above, with the script vite build in package.json.

Bootstrap

Bootstrap needs no build step to use. The quickest route is the WebJar from the section above, linked with version-less paths; the page of the Bootstrap section on getting started covers the other routes (npm, CDN) and customization covers Sass variables, which do need a build. See Bootstrap for the framework itself.

A Bootstrap page from the WebJar
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Bootstrap</title>
  <link rel="stylesheet" th:href="@{/webjars/bootstrap/css/bootstrap.min.css}">
  <script th:src="@{/webjars/bootstrap/js/bootstrap.bundle.min.js}" defer></script>
</head>
<body class="container py-4">
  <button type="button" class="btn btn-primary">Borrow</button>
</body>
</html>

With the content strategy and the locator active, it rendered:

<link rel="stylesheet" href="/webjars/bootstrap/5.3.8/css/bootstrap.min-1b1cb0e2be9a21f091a87691f20c6300.css">
<script src="/webjars/bootstrap/5.3.8/js/bootstrap.bundle.min-5cc1b73e70520fa84b1846afe0ec8fb6.js" defer></script>

and both URLs answered 200 with Cache-Control: max-age=31536000, public. Choose Bootstrap or Tailwind for a given page, not both: they style the same elements with different base rules (Tailwind’s preflight resets what Bootstrap’s Reboot sets up), so a page that loads both is hard to reason about.

ttcli: a project generator

ttcli is the "Taming Thymeleaf CLI" by the author of both books. It asks a few questions (group and artifact id, Boot version, Thymeleaf or JTE, the live reload set-up and the web dependencies) and creates a Spring Boot project in a sub-directory with the front-end already wired. According to its README it adds the npm scripts and dependencies for the chosen live reload set-up, starter index.html and layout/main.html templates, Tailwind CSS or Bootstrap when selected, the WebJars in the Maven dependencies, and an application-local.properties with template caching disabled. The live reload choices are DevTools (simplest, slowest, no npm unless Tailwind is chosen), npm-based watch scripts, and Vite. The README says to read the generated HELP.md for how to work with the choice. Version 1.13.0 was the latest release when this page was checked (2026-10-05).

# any of these, per the README (Homebrew on macOS and Linux, JBang as an alternative)
brew install wimdeblauwe/homebrew-ttcli/ttcli
ttcli init
jbang run ttcli@wimdeblauwe init

ttcli was not run for this page: it is a downloaded binary, and the commands above are quoted from its README. Use it as a starting point and check the result. In particular, the htmx book shows generated projects with Tailwind 3 configuration, so look at which Tailwind version the generated package.json pins, and move a Tailwind 3 project to v4 with the upgrade guide rather than assuming the generator did.

Live reload without DevTools LiveReload

The DevTools LiveReload server pushed a browser refresh when a resource changed. Spring Boot 4.1.0 deprecated it "with no replacement". The 4.1.1 metadata confirms it: spring.devtools.livereload.enabled (default false) and spring.devtools.livereload.port (default 35729) are both marked deprecated since 4.1.0. The rest of DevTools is not deprecated: automatic restart and the development-time property defaults, which include spring.thymeleaf.cache=false, remain. What you lose is the automatic browser refresh, and the options below replace it or make it unnecessary.

Option What it gives Checked how

spring.thymeleaf.cache=false

The engine re-reads a template file on every request, so an edited template shows on the next browser refresh. DevTools sets it for you; set it yourself in a local profile when you do not use DevTools.

Run: edited a template and a CSS file, see below.

Serve static and templates from the source tree

spring.web.resources.static-locations=file:src/main/resources/static/ and spring.thymeleaf.prefix=file:src/main/resources/templates/ make edits visible without copying them into target/classes.

Run: see below.

The Boot plugins' addResources

With the Maven plugin’s addResources option, src/main/resources is added to the run classpath, so HTML, CSS and JavaScript edits show without a rebuild. The Boot documentation says to prefer DevTools, and warns that resource filtering does not work with it.

Documentation only.

A Tailwind watcher

npm run watch:css rebuilds static/dist/app.css when a template or the entry file changes.

Run: see below.

Vite dev server

Hot-updated CSS and JavaScript, and a full page reload when a template changes (the plugin in the Vite section).

Run: see the Vite section.

DevTools restart

Still restarts the application on class changes. Changes under /static, /public, /resources, /templates and META-INF/resources do not restart it by default, because they need no restart.

Metadata and documentation.

Templates and static files without a restart

The first two rows were run together. A scratch test started the application with spring.thymeleaf.cache=false and the two file: locations pointing at a temporary copy, rendered the page, edited both files on disk, and rendered again:

Edit-in-place with the cache off and file: locations
spring.thymeleaf.cache=false
spring.thymeleaf.prefix=file:src/main/resources/templates/
spring.web.resources.static-locations=file:src/main/resources/static/
before the edit:  <h1>First edit</h1>            h1 { color: red; }
after the edit:   <h1>Second edit</h1>           h1 { color: blue; }

The same test with spring.thymeleaf.cache=true kept rendering <h1>First edit</h1> after the template was edited, while the CSS file changed anyway: static files are read from the location on each request, so it is the template cache that has to be off. Run the application from the project root, because a relative file: path is resolved against the working directory. Do not set the file: prefix in production: the packaged application has no src directory.

A Tailwind watcher

--watch keeps the CLI running and rebuilds when a scanned file changes. In the scratch application the watcher was started (as tailwindcss -i ... -o ... --watch=always), a template was edited to replace bg-indigo-600 with bg-teal-700, and a few seconds later the output file contained bg-teal-700. Its output was:

≈ tailwindcss v4.3.3

Done in 68ms
Done in 1ms

Run npm run watch:css in one terminal and the application in another. The browser still needs a manual refresh unless you use the Vite set-up, which pushes the reload.

A local profile (application-local.properties, activated with --spring.profiles.active=local) is the place for spring.thymeleaf.cache=false, the file: locations and app.vite.dev-server, so none of them reaches production. The page on Spring Boot auto-configuration and properties lists the spring.thymeleaf.* properties, and Performance & Production covers what to turn back on for a deployment.

Pitfalls

Symptom Cause and fix

The WebJar link returns 404 or the version-less path does not resolve

webjars-locator-lite is not on the classpath (verified: 404 without it). Add it, or write the version in the path.

The login page is unstyled, or an asset answers 302 to /login

The security chain does not permit the asset path. Permit the static locations, your output directory (/dist/**) and a fixed-version prefix such as /v12/**.

A link in the page is not versioned

It does not go through @{...} (a literal href), the content strategy is off, or the application is WebFlux, where the links stay as written.

Static assets are never cached, or are cached for a year after a change

Spring Security’s default no-cache header applies when spring.web.resources.cache.* is not set; a long cache without a changing URL (no content or fixed strategy, no hashed names) serves stale files.

A Tailwind class works in one template and not in another

It is built at render time ('bg-' + ${color}), or it lives in a file that is ignored or not scanned. Write complete class names, add @source, or use @source inline.

The CSS output contains utilities you never wrote

Automatic detection scanned other files (for example the Java keyword static produced .static). Use source(none) and list the sources.

The Tailwind build "succeeds" but nothing is styled

The generated file is not linked, or it is written to a directory that the application does not serve, or the browser cached the old file. Check the th:href target and the content strategy.

th:src fails with "Instantiation of new objects and access to static classes or parameters is forbidden"

A ${...} whose literal contains /@name (such as /@vite/client) in a URL attribute. Keep the literal outside the ${...}, or in a link expression.

The Tailwind watcher exits immediately in a script

--watch stops when standard input is closed; use --watch=always.

Changes to src/main/resources do not show in the browser

The file was not copied to target/classes (IDE build or process-resources did not run), the template cache is on, or the browser cached the CSS. See the live reload options.

Tailwind v3 snippets from the books do not work

@tailwind base and tailwind.config.js with content are the v3 form; v4 uses @import "tailwindcss" and @source. See the upgrade guide.