Using Thymeleaf without Spring MVC

Last update: 2026-05-10 10:03:32 +0800 +0800
Available translations: 日本語 / 中文(简体) / 中文(繁體)

Thymeleaf can render HTML in a batch job, command-line program, mail service, or report pipeline without Spring MVC. A standalone application configures a template resolver and TemplateEngine, supplies a context, and writes the rendered output to its destination.

Choose the Integration Level #

A non-Spring program needs the core Thymeleaf artifact:

xml
<dependency>
  <groupId>org.thymeleaf</groupId>
  <artifactId>thymeleaf</artifactId>
  <version>3.1.3.RELEASE</version>
</dependency>

A Spring application can use thymeleaf-spring6 and SpringTemplateEngine for Spring EL, MessageSource, conversion, and Spring-aware link behavior. Spring Boot dependency management should choose the matching Thymeleaf version. spring-boot-starter-thymeleaf is convenient when those integrations are needed, but it is not required to run the core engine and does not require MVC.

Configure the Resolver and Engine #

Select a resolver that matches template ownership. A ClassLoaderTemplateResolver works for immutable templates packaged in a JAR; a FileTemplateResolver works for operator-managed files:

java
var resolver = new ClassLoaderTemplateResolver();
resolver.setPrefix("reports/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
resolver.setCacheable(true);

var engine = new TemplateEngine();
engine.setTemplateResolver(resolver);

Configure an engine once and then share it. Thymeleaf documents a fully configured TemplateEngine as thread-safe, while changing configuration after processing begins is unsupported. Enable caching for stable production templates. Disable it, or configure bounded cache validity, only when files must be re-read during development or operation.

Character encoding on the resolver controls how template bytes are decoded. The encoding used by the output Writer independently controls generated file bytes; set both explicitly.

Render Heterogeneous Report Data #

Expose domain-shaped values instead of constructing HTML in Java:

java
record WarningRow(String orderId, String message) {}
record DailyReport(List<OrderSummary> orders, List<WarningRow> warnings) {}

var context = new Context(Locale.ENGLISH);
context.setVariable("report", report);
context.setVariable("generatedAt", generatedAt);
engine.process("daily-report", context, writer);

A template can render independent sections with different row types:

html
<section>
  <h2>Warnings</h2>
  <table>
    <tr th:each="warning : ${report.warnings}">
      <td th:text="${warning.orderId}">order</td>
      <td th:text="${warning.message}">message</td>
    </tr>
  </table>
</section>

th:text escapes text for the output mode. th:utext emits unescaped markup and must not receive untrusted content. HTML template mode parses and writes HTML-aware markup; choose XML, text, JavaScript, or CSS modes only when the target format requires their different rules.

Fragments and Testability #

Declare reusable markup with th:fragment and compose it through th:insert or th:replace. Fragment parameters keep shared tables and headers independent of global variable names. Multiple resolvers can be ordered and restricted by resolvable patterns, preventing a broad filesystem resolver from shadowing packaged templates unintentionally.

Keep rendering as a pure service that accepts a report model and a Writer. Tests can use StringWriter, parse the result with an HTML parser, and assert structure and escaping. Resolver tests should also verify missing templates and non-ASCII content.

Manage Output Resources #

Write with an explicit charset and close the writer:

java
Path temporary = Files.createTempFile(outputDirectory, "daily-", ".html");
try (Writer writer = Files.newBufferedWriter(
        temporary, StandardCharsets.UTF_8,
        StandardOpenOption.TRUNCATE_EXISTING)) {
    engine.process("daily-report", context, writer);
}
Files.move(temporary, destination,
    StandardCopyOption.ATOMIC_MOVE,
    StandardCopyOption.REPLACE_EXISTING);

Create temporary files in the destination filesystem when an atomic move is required. Clean up the temporary path after rendering or upload failure, and do not publish the destination until processing succeeds. For object storage, render to a bounded buffer or stream through an upload API whose retry and partial-failure behavior is explicit.

Primary References #

Comments

Read or comment on GitHub (sign-in required to comment)