Thim is a compile-time-safe server-side HTML renderer for Java and Kotlin applications. It validates templates against page models, messages and Spring routes, then generates direct Java renderers. Static HTML is stored as UTF-8 bytes and dynamic values are encoded for their output context.
Thim requires JDK 26 or newer. Its optional MVC adapter targets Spring Framework 7 and Spring Boot 4.
Name the page model after its template. home.html resolves to HomePage:
<!doctype html>
<html>
<body>
<h1 th:text="#{home.title}">Home</h1>
<p th:text="${greeting}">Greeting</p>
</body>
</html>Return the model directly from a controller. Kotlin data classes and Java records are both supported:
package no.example.page
data class HomePage(val greeting: String)
@GetMapping("/")
fun home() = HomePage("Hello")package no.example.page;
record HomePage(String greeting) {}
@GetMapping("/")
HomePage home() {
return new HomePage("Hello");
}Apply the plugin after the Kotlin JVM plugin in Kotlin modules:
plugins {
kotlin("jvm")
id("no.beint.thim") version "0.7.2"
}Java modules need only the Java and Thim plugins:
plugins {
java
id("no.beint.thim") version "0.7.2"
}The plugin supplies the runtime, compiler and Spring adapter and tracks templates and messages as compilation inputs. Compiled template jars publish their registries through Java's service loader, so templates can live in any application module.
thim {
generatedPackage.set("your.group.your_module.thim.generated")
registryName.set("ThimTemplates")
modelPackages.set(listOf("no.example.page"))
failOnUnusedMessages.set(true)
generateRoutes.set(true)
}Thim deliberately owns its default source layout: templates go in src/main/resources/templates, and message catalogs go in src/main/resources/i18n. Supported locales are inferred from the locale directories. Set defaultLocale only when it is not en. Override a source directory only for a migration or generated-source workflow.
The default model package is <project group>.page. Nested template names are part of the class name: error/404.html resolves to Error404Page. Fixed th:replace fragments and layouts are linked and inlined during compilation; fragment libraries need no page model.
Every page template must have a matching model by default. Set strictTemplates to false only while Thim and a runtime template engine intentionally share a template directory. Unused fragments and fragment parameters are reported, and failOnUnusedFragments promotes unused-fragment warnings to errors. Enable failOnUnusedMessages only when the configured bundles are owned entirely by compiled templates.
Page models are strict by default: they must be immutable, render-only data. Thim rejects mutable or unused properties, Any/Object, maps, raw or lazy collections, and persistence entities.
In strict mode, templates and message catalogs are compiler inputs and the Gradle plugin omits them from runtime resources. With strictTemplates=false, templates remain available to the runtime engine during migration; message catalogs are still compiled into renderers and omitted.
For gradual migration, keep the conventional template directory shared and configure only the exception:
thim {
strictTemplates.set(false)
}Migrate one controller and page model at a time. Copy the messages used by that page into the YAML catalog; keep legacy catalog entries temporarily when the old engine or an existing catalog linter still needs them. Once every page is compiled, remove the runtime engine and the strictTemplates override.
Complete documents are checked for duplicate ids and broken label, ARIA and local-anchor references. Repeated static ids warn. Templates without an <html> root are treated as partials, so document-wide references are not checked.
Use thimCheck for fast template validation during development:
./gradlew thimCheck
./gradlew thimCheck --continuousthimCheck runs the same validation as normal compilation. Java modules also write a report to build/reports/thim/check.json.
Thim compiles localized YAML catalogs into the generated renderer. SnakeYAML Engine is a compiler dependency only; parsing, key lookup and pattern interpretation never happen at request time.
The directory name is a canonical BCP 47 language tag. The relative YAML filename and nested mappings form the message namespace:
src/main/resources/i18n/
├── en/
│ └── home.yaml
└── nb/
└── home.yaml
Thus home.yaml owns the home.* namespace, while account/profile.yaml owns account.profile.*. Namespace collisions, inconsistent locale trees and invalid locale names fail compilation.
# en/home.yaml
title: Thim {version}
introduction: |-
Compile templates and translations together.
Ship no runtime template engine.
inbox:
_plural: unreadCount
one: One unread message
other: "{unreadCount} unread messages"
salutation:
_select: audience
MEMBER: Welcome back, {name}
other: Welcome, {name}Use named model properties in the template:
<title th:text="#{home.title(version=${version})}">Thim</title>
<p th:text="#{home.inbox(unreadCount=${unreadCount})}">Unread messages</p>_plural accepts the locale's reachable subset of zero, one, two, few, many and the required other category. Its argument must be a non-null integral property. _select requires a non-null string or enum and also requires other; enum variants must name real enum constants. Selections can be nested. All interpolated values are HTML-escaped; catalogs cannot produce raw HTML. Write {{ or }} for a literal brace.
Every discovered locale must contain the same relative .yaml files, message keys and argument contracts. The default locale defines the contract. Missing translations, extra keys, misspelled placeholders, incompatible argument types and unused messages (when enabled) fail compilation. At runtime Thim chooses an exact available language tag, then an available language-only tag, then the default locale.
Catalogs use a deliberately small YAML 1.2 profile: mappings and string scalars only. The failsafe schema means plain no, true, 12 and 2026-08-08 remain text. Duplicate keys, tags, anchors, aliases, sequences, multiple documents, empty catalogs and non-YAML files are rejected. Block scalars are supported for multiline copy. Only the lowercase .yaml extension is accepted.
Integer cardinal rules derived from Unicode CLDR 49 are embedded for af, bg, bs, ca, cs, cy, da, de, el, en, eo, es, et, eu, fi, fo, fr, ga, gd, gl, hr, hu, is, it, lt, lv, nb, nl, nn, no, pl, pt, ro, sk, sl, sq, sr, sv and sw. A catalog that uses _plural with another language fails compilation rather than guessing.
Kotlin applications can opt into controller-side route builders with generateRoutes.set(true). The generated object is placed beside the template registry and replaces a Templates suffix with Routes: WebAppTemplates produces WebAppRoutes. Set routesName to override it.
val settings = WebAppRoutes.connections(
additionalQueryParameters = mapOf("workspaceId" to 11, "connectWarning" to "partial"),
)
val campaign = WebAppRoutes.adsGoogleCampaign(campaignId = "42", customerId = "123")Thim emits one function per distinct path pattern. Names come from literal path segments; path variables are omitted from the usual name and receive a ById-style suffix when needed to resolve a collision. A path variable is required and retains the controller parameter's scalar or enum type. Query parameters are nullable with a null default and are omitted when null. Values are percent-encoded with the same encoder used by @{...}.
Spring mapping metadata does not contain arbitrary query parameters. Thim includes parameters declared with @RequestParam as named arguments. URL-only parameters that are consumed indirectly, such as flash-message selectors or application-wide request context, can be supplied through additionalQueryParameters:
WebAppRoutes.connections(
additionalQueryParameters = mapOf("workspaceId" to 11, "connectWarning" to "partial"),
)Route generation is opt-in and currently available for Kotlin applications.
A controller that always renders should return its page model directly. A controller that can render or redirect can use ThimResult for an explicit return type while page models remain dependency-free:
fun select(): ThimResult =
if (failed) {
ThimResult.Redirect(
WebAppRoutes.connections(
additionalQueryParameters = mapOf("connectWarning" to "connectionFailed"),
),
)
} else {
ThimResult.Page(ConnectApiPage(/* ... */))
}ThimResult.Page.model has type Any, so page models remain dependency-free. The Spring adapter renders Page and sends the path in Redirect as an HTTP redirect.
Existing handlers declared as Kotlin Any or Java Object remain supported, but ThimResult documents mixed page/redirect outcomes more clearly.
Artifacts and the Gradle plugin marker are published to Maven Central. Add mavenCentral() to pluginManagement and dependency resolution.
Thim accepts:
${property}and null-safe property pathsth:textth:eachth:ifandth:unless- fixed, build-time
th:fragmentandth:replacecomposition - property, message, static URL and quoted-literal values on ordinary
th:*attributes no.beint.thim.TrustedUrlproperties on URL attributes such asth:href,th:srcandth:action- conditional HTML boolean attributes
- literal
#{message(argument=${property})}expressions with typed, named arguments ${#locale.language}for a language attribute
Every dynamic value is encoded for its output context. Use static @{...} expressions or TrustedUrl for URLs, and use SafeHtml only with th:utext. Dynamic JavaScript, CSS and event-handler content is rejected.
Missing models, properties, messages and routes; unsafe nullable access; malformed HTML; and unsupported output contexts fail compilation. Prepare computed display values in the page model.
runtime: dependency-free Java output APIcompiler: build-time Java and Kotlin type analysisspring: Java Spring MVC adaptergradle-plugin: Java and Kotlin build integrationexample: Spring Boot application
See DESIGN.md for the architecture.