Spring Boot
Static Resources
Spring Boot serves CSS, JavaScript, images, and fonts from the classpath with no configuration, and then gives you hooks for locations, caching, WebJars, and content-hash URLs. This entry covers the defaults, custom locations, WebJars, cache-busting with hashed URLs, a caching policy that does not strand users on stale files, security rules, and turning static serving off for pure APIs. It also covers the traps: a year-long cache on URLs that never change, hashed URLs that are never generated because a filter is missing, user uploads served as static files, and 404 handling that changed in Spring Framework 6.1.
Default Locations and URL Mapping
Out of the box Boot serves files from four classpath folders: META-INF/resources, resources, static, and public. In practice, use static. A file's URL is its path relative to that folder, so static/css/main.css is served at /css/main.css, and static/index.html becomes the welcome page at /. A favicon is not special: put favicon.ico in static and it is served like any other file.
These defaults belong to Boot's auto-configuration. If you add @EnableWebMvc to your application, Boot's MVC configuration (including this static handling) switches off, and your CSS suddenly returns 404. Leave @EnableWebMvc out and customise through a WebMvcConfigurer instead.
In templates, always build URLs with @{...}. It adds the context path for you, and (as shown later) it is also how hashed asset URLs get rewritten.
yaml
# Defaults, in order:
# classpath:/META-INF/resources/ classpath:/resources/ classpath:/static/ classpath:/public/
# File to URL
# src/main/resources/static/css/main.css -> GET /css/main.css
# src/main/resources/static/js/app.js -> GET /js/app.js
# src/main/resources/static/images/logo.png -> GET /images/logo.png
# src/main/resources/static/index.html -> GET / (welcome page)
# Thymeleaf: @{...} adds the context path and enables URL rewriting
# <link rel="stylesheet" th:href="@{/css/main.css}" />
# <script th:src="@{/js/app.js}"></script>
# <img th:src="@{/images/logo.png}" alt="Logo" />Configuring Locations and Paths
Three settings cover most needs. static-locations replaces the default list, so include every folder you still want (a common mistake is adding one custom folder and losing static/). static-path-pattern changes the URL prefix; if you set it to /static/**, then css/main.css is served at /static/css/main.css and every @{/css/main.css} in your templates must change too. add-mappings=false switches static serving off entirely.
Be careful with what you expose through static-locations. A file: location is read from disk on every request, so files appear without a restart, which is handy for development and risky for anything user-supplied (see the uploads note in the security section). Folder paths for file: locations must end with a slash.
For development, point at the source folder so edits show up without a rebuild, and turn the cache off. Do it in a dev profile only.
yaml
# application.yml
spring:
web:
resources:
static-locations: classpath:/static/,classpath:/public/ # replaces the defaults
cache:
cachecontrol:
max-age: 1h # a safe default for files whose URLs do not change
cache-public: true
mvc:
static-path-pattern: /** # default; changing it changes every asset URL
---
# Dev profile: serve straight from the source tree, no caching, no rebuild needed
spring:
config:
activate:
on-profile: dev
web:
resources:
static-locations: file:src/main/resources/static/
cache:
period: 0
// Extra handler for a custom URL prefix
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/docs/**")
.addResourceLocations("classpath:/docs/")
.setCacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePublic());
}
}WebJars: Client Libraries as Dependencies
A WebJar packages a front-end library (Bootstrap, htmx, Alpine) as a Maven or Gradle dependency. Boot serves its contents under /webjars/**, so you get versioning and dependency management for your front-end libraries through your normal build.
To write URLs without the version number, add a locator. Use webjars-locator-lite, not the older webjars-locator-core: Spring Framework 6.2 and Boot 3.4 moved to the lite locator and deprecated the core one, which scanned the whole classpath at startup and was slow. If your Boot BOM does not manage its version, pin it.
One caution with version-less URLs: they never change when you upgrade the library, so browsers that cached the old file by a long max-age keep using it. Either keep caching short for /webjars/** or put them behind content-hash versioning (next section).
XML
<!-- pom.xml (example versions: use current releases) -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>bootstrap</artifactId>
<version>5.3.3</version>
</dependency>
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>htmx.org</artifactId>
<version>2.0.4</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-lite</artifactId> <!-- enables version-free URLs -->
</dependency>
<!-- With the locator: upgrading the jar does not change the template -->
<link rel="stylesheet" th:href="@{/webjars/bootstrap/css/bootstrap.min.css}" />
<script th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script>
<!-- Without it: the version is part of the URL -->
<link rel="stylesheet" href="/webjars/bootstrap/5.3.3/css/bootstrap.min.css" />Content Hashing for Cache Busting
The problem: if you tell browsers to cache app.js for a year, nobody sees your next release. The fix is to put a hash of the file's content in its URL (app-3f2a9c.js). Change the file and the URL changes, so you can cache each URL forever.
Spring does this with a resource chain and VersionResourceResolver. To keep the setup simple, put the assets you want hashed under one folder, such as static/assets, give that folder its own handler, and leave everything else (notably index.html) on a short cache. A long cache on a welcome page that points at hashed files would defeat the whole idea.
Hashed URLs only appear if something rewrites them. That something is ResourceUrlEncodingFilter, which hooks into the encodeURL call that Thymeleaf's @{...} makes. Boot registers it only when spring.web.resources.chain.enabled is true. If you configure the chain purely in Java, the filter is missing, your URLs stay unhashed, and nothing errors, so declare the bean (or set the property) and check the rendered HTML.
Two limits: the chain rewrites url(...) references inside CSS files, but it does not rewrite paths inside JavaScript, so import maps, dynamic import() calls, and strings in JS keep pointing at the unhashed names.
Java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/assets/**")
.addResourceLocations("classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic().immutable())
.resourceChain(true) // caches resolution in memory
.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
}
// Boot only registers this when spring.web.resources.chain.enabled=true.
// With Java-only config, declare it yourself or hashed URLs are never produced.
@Bean
ResourceUrlEncodingFilter resourceUrlEncodingFilter() {
return new ResourceUrlEncodingFilter();
}
}
<!-- Template (static/assets/css/main.css) -->
<link rel="stylesheet" th:href="@{/assets/css/main.css}" />
<!-- Renders as: /assets/css/main-<content-hash>.css -->
# Property alternative (applies to the default handler for /**, so mind index.html):
# spring:
# web:
# resources:
# chain:
# strategy:
# content: { enabled: true, paths: "/assets/**" }
# compressed: true # serve pre-built .gz / .br files when presentA Caching Policy That Works
Pick the cache rule from how the URL behaves, not from the file type. Hashed asset URLs never change content, so cache them for a year with immutable. HTML pages and anything at a stable URL (index.html, a plain /css/site.css) should be revalidated: no-cache does not mean "do not cache", it means "check with the server before reusing", and a 304 response makes that cheap. Use no-store only for sensitive content that must never be kept.
Spring already sends Last-Modified for static files and answers conditional requests with 304, so you do not need to add ETags for them. In particular, ShallowEtagHeaderFilter is a poor fit here: it buffers each whole response in memory to hash it, and the file is read in full every time, so it saves bandwidth but costs memory and CPU. It is better suited to small JSON responses than to static assets.
Also turn on compression for text assets, or better, precompress them at build time and let the resource chain serve the .gz or .br file.
yaml
// Cache rules by URL behaviour
// /assets/css/main-3f2a9c.css hashed max-age=31536000, public, immutable
// /index.html, /about.html stable URL no-cache (revalidate; 304 if unchanged)
// /css/site.css stable URL max-age=3600 or no-cache
// /account/** private data no-store
CacheControl.maxAge(Duration.ofDays(365)).cachePublic().immutable(); // hashed assets
CacheControl.noCache(); // revalidate every time
CacheControl.noStore(); // never keep
# application.yml: compress text responses
server:
compression:
enabled: true
mime-types: text/html,text/css,application/javascript,application/json,image/svg+xml
min-response-size: 1024
# At scale, let a CDN or reverse proxy (nginx) serve /assets/** and keep the app for dynamic requests.Spring Security and User Uploads
If Spring Security is on the classpath, static files are protected like everything else, so your login page loads without CSS until you permit them. PathRequest.toStaticResources().atCommonLocations() permits the usual folders (css, js, images, webjars, favicon). It does not know about custom folders such as /assets or /fonts, so add those yourself.
Do not serve user-uploaded files by adding the upload folder to static-locations. Files in a static location are served exactly as stored, so an uploaded HTML or SVG file runs as part of your site and can carry a script (stored XSS), and any file a user can name is publicly readable. Serve uploads through a controller that checks permission, sets the content type from your own allowlist, and sends Content-Disposition: attachment for anything that should download. If you really need a direct handler, give it its own path, never the root, ideally on a separate domain.
Java
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.requestMatchers("/assets/**", "/fonts/**").permitAll() // custom folders are not covered
.requestMatchers("/login", "/error").permitAll()
.anyRequest().authenticated())
.build();
}
// Uploads: through a controller, not through static-locations
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable Long id, @AuthenticationPrincipal AppUser user) {
StoredFile file = fileService.findFor(id, user); // checks ownership
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.safeContentType()))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(file.name()).build().toString())
.header("X-Content-Type-Options", "nosniff")
.body(file.resource());
}Turning Static Serving Off for REST APIs
A pure API has no use for the static handler. Setting add-mappings to false removes it, which also removes the welcome page and /webjars/**, so do not use it if you serve any of those.
Be aware that 404 handling changed. Since Spring Framework 6.1 (Boot 3.2), a request for a missing static file raises NoResourceFoundException, while a request that matches no handler at all raises NoHandlerFoundException. A handler that catches only the second misses the first. The simplest robust fix is to extend ResponseEntityExceptionHandler in your @RestControllerAdvice: it already handles both and returns a standard ProblemDetail 404, so you do not need the throw-exception-if-no-handler-found property or a hand-built error body.
Java
# application.yml
spring:
web:
resources:
add-mappings: false # also disables the welcome page and /webjars/**
@RestControllerAdvice
class ApiErrors extends ResponseEntityExceptionHandler {
// Handles NoHandlerFoundException and NoResourceFoundException (Spring 6.1+)
// and returns application/problem+json with status 404. Add @ExceptionHandler methods
// here for your own exceptions.
}
// Result for GET /nope
// HTTP/1.1 404 Content-Type: application/problem+json
// { "type": "about:blank", "title": "Not Found", "status": 404,
// "detail": "No static resource nope.", "instance": "/nope" }