Spring BootStatic Resources
Spring Boot

Static Resources

Static resources are files served directly to the browser without server-side processing — CSS, JavaScript, images, fonts, and other assets. Spring Boot auto-configures ResourceHttpRequestHandler to serve static files from well-known classpath locations and supports content versioning, caching headers, WebJars, and custom resource chains for production optimisation.

Default Static Resource Locations

Spring Boot auto-configures four classpath locations as static resource roots. Any file placed in these directories is served at the URL path matching the file's path relative to the root.
Shell
# ── Four default static resource locations (evaluated in order): ───────
# classpath:/META-INF/resources/
# classpath:/resources/
# classpath:/static/              ← most commonly used
# classpath:/public/

# ── File layout → URL mapping: ────────────────────────────────────────
# 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/public/favicon.ico        → GET /favicon.ico
# src/main/resources/static/index.html         → GET / (welcome file)

# ── Typical static directory layout: ──────────────────────────────────
# src/main/resources/
# └── static/
#     ├── css/
#     │   ├── main.css
#     │   └── vendor/bootstrap.min.css
#     ├── js/
#     │   ├── app.js
#     │   └── vendor/htmx.min.js
#     ├── images/
#     │   ├── logo.png
#     │   └── icons/
#     └── fonts/
#         └── inter.woff2

# ── Referencing in Thymeleaf (always use @{} for context-path safety): ─
# <link rel="stylesheet" th:href="@{/css/main.css}" />
# <script th:src="@{/js/app.js}"></script>
# <img th:src="@{/images/logo.png}" />

Configuring Static Resources

Spring Boot exposes static resource configuration through application.yml properties and through WebMvcConfigurer.addResourceHandlers() for advanced scenarios — custom locations, additional URL patterns, and cache control.
yaml
# ── application.yml — common static resource settings: ───────────────
spring:
  mvc:
    static-path-pattern: /static/**   # serve static files under /static/** only
                                       # default: /**
  web:
    resources:
      static-locations:
        - classpath:/static/           # default locations
        - classpath:/public/
        - file:/opt/myapp/uploads/     # serve files from the filesystem
      add-mappings: true               # set false to disable all static serving
                                       # (pure REST API — no static files)
      cache:
        period: 3600                   # Cache-Control: max-age=3600 (seconds)
        cachecontrol:
          max-age: 365d                # override with a Duration
          cache-public: true           # Cache-Control: public
          no-cache: false

# ── WebMvcConfigurer — custom resource handlers: ──────────────────────
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {

        // Custom URL pattern → custom classpath location:
        registry.addResourceHandler("/assets/**")
            .addResourceLocations("classpath:/assets/")
            .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());

        // Serve uploaded files from the filesystem:
        registry.addResourceHandler("/uploads/**")
            .addResourceLocations("file:/opt/myapp/uploads/")
            .setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));

        // Disable caching for a specific path (dev tools etc.):
        registry.addResourceHandler("/dev/**")
            .addResourceLocations("classpath:/dev/")
            .setCacheControl(CacheControl.noStore());
    }
}

WebJars — Dependency-Managed Client Libraries

WebJars packages client-side libraries (Bootstrap, jQuery, HTMX, Alpine.js) as Maven/Gradle dependencies. Spring Boot auto-configures a resource handler for /webjars/** that serves the JAR contents. The webjars-locator-core dependency removes version numbers from the URL path.
XML
<!-- pom.xml — add Bootstrap and HTMX as WebJars: -->
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>bootstrap</artifactId>
    <version>5.3.2</version>
</dependency>

<dependency>
    <groupId>org.webjars.npm</groupId>
    <artifactId>htmx.org</artifactId>
    <version>1.9.10</version>
</dependency>

<!-- Remove version from URL path (version-agnostic references): -->
<dependency>
    <groupId>org.webjars</groupId>
    <artifactId>webjars-locator-core</artifactId>
</dependency>

<!-- ── Without webjars-locator — version in URL: ─────────────────────── -->
<link rel="stylesheet"
      href="/webjars/bootstrap/5.3.2/css/bootstrap.min.css" />
<script src="/webjars/htmx.org/1.9.10/dist/htmx.min.js"></script>

<!-- ── With webjars-locator — version-free URL: ─────────────────────── -->
<link rel="stylesheet"
      th:href="@{/webjars/bootstrap/css/bootstrap.min.css}" />
<script th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script>

<!-- Update the JAR version in pom.xml → URL stays the same.
     webjars-locator resolves the actual path at runtime. -->

Content Versioning for Cache Busting

Spring's ResourceChainRegistration and VersionResourceResolver append a content hash to resource URLs automatically. This enables permanent caching headers (max-age: 1 year) — the URL changes whenever the file content changes, forcing browsers to fetch the new version.
Java
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/static/**")
            .addResourceLocations("classpath:/static/")
            .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS)
                .cachePublic().immutable())
            .resourceChain(true)                          // enable resource chain
            .addResolver(new VersionResourceResolver()
                .addContentVersionStrategy("/**"));       // hash appended to URL
    }
}

<!-- ── Thymeleaf — use @{} to resolve versioned URLs: ────────────────── -->
<!-- Without versioning:   /css/main.css  -->
<!-- With versioning:      /css/main-d41d8cd98f00b204e9800998ecf8427e.css -->

<link rel="stylesheet" th:href="@{/static/css/main.css}" />
<script th:src="@{/static/js/app.js}"></script>
<!-- Thymeleaf resolves @{} through the ResourceUrlProvider,
     which applies the VersionResourceResolver and returns the hashed URL. -->

# ── application.yml — enable versioning via properties (simpler): ─────
spring:
  web:
    resources:
      chain:
        enabled: true
        strategy:
          content:
            enabled: true
            paths: /**     # apply to all resources
      # Or use a fixed version string instead of content hash:
      # chain:
      #   strategy:
      #     fixed:
      #       enabled: true
      #       version: v2.1.0
      #       paths: /js/**,/css/**

Caching Headers and ETags

Correct HTTP caching headers are essential for production performance. Spring Boot's ResourceHttpRequestHandler sets Last-Modified automatically. Add CacheControl and ETag support for fine-grained cache control.
Java
# ── Cache-Control strategies: ────────────────────────────────────────

# Immutable assets (versioned URLs — change URL when content changes):
CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable()
# Cache-Control: max-age=31536000, public, immutable
# Browser caches forever — safe because the URL contains a content hash.

# Short-lived assets (change frequently, CDN cacheable):
CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic()
# Cache-Control: max-age=3600, public

# Private assets (user-specific — no shared cache):
CacheControl.maxAge(10, TimeUnit.MINUTES).cachePrivate()
# Cache-Control: max-age=600, private

# No caching (admin pages, sensitive data):
CacheControl.noStore()
# Cache-Control: no-store

# ── ETag support — enable in application.yml: ────────────────────────
server:
  servlet:
    session:
      tracking-modes: cookie
spring:
  web:
    resources:
      cache:
        use-last-modified: true   # Last-Modified header (default: true)

# ── ShallowEtagHeaderFilter — add ETag to all responses: ─────────────
@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<ShallowEtagHeaderFilter> etagFilter() {
        FilterRegistrationBean<ShallowEtagHeaderFilter> bean =
            new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
        bean.addUrlPatterns("/static/*");
        return bean;
    }
}
# ShallowEtagHeaderFilter computes an MD5 hash of the response body
# and sets ETag. Subsequent requests with If-None-Match return 304
# without re-sending the body — saves bandwidth, not server computation.

Disabling Static Resources for REST APIs

A pure REST API does not serve static files. Disabling the auto-configured static resource handler removes an unnecessary component and prevents accidental file serving.
yaml
# ── Disable static resource handling entirely: ────────────────────────
spring:
  web:
    resources:
      add-mappings: false   # disables ResourceHttpRequestHandler

# ── Or configure via WebMvcConfigurer: ────────────────────────────────
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void configureDefaultServletHandling(
            DefaultServletHandlerConfigurer configurer) {
        // Do NOT call configurer.enable() — default servlet not needed for REST APIs
    }
}

# ── Effect on 404 handling: ───────────────────────────────────────────
# With add-mappings: false, requests to unmapped URLs are not intercepted
# by the static resource handler. Spring MVC returns 404 through the
# normal exception resolver chain — controlled by @RestControllerAdvice.

# Ensure NoHandlerFoundException is thrown (not swallowed):
spring:
  mvc:
    throw-exception-if-no-handler-found: true
  web:
    resources:
      add-mappings: false

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(NoHandlerFoundException.class)
    public ResponseEntity<ErrorResponse> handleNoHandler(
            NoHandlerFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse.of(404, "Not Found",
                ex.getHttpMethod() + " " + ex.getRequestURL() + " not found"));
    }
}