Bringing React-like Hot Reloading to Server-Side Rendering

Goal

The goal of this blog post is to set up a Spring Boot project with Gradle that uses Thymeleaf & HTMX and provides a React like development experience. This specifically means that changes made to the UI code (the Thymeleaf templates) are automatically hot-reloaded.

You can find the code for this post on GitHub. At the end of each section you’ll also find a link to a branch with the code for that section. This allows you to follow along step-by-step. If you encounter any errors or have a question, feel free to open an issue.

Disclaimer: This post is largely based on the incredible work of Wim Deblauwe. If you can use Maven instead of Gradle, I recommend skipping this post and head over to wimdeblauwe/ttcli. There you’ll find a CLI to bootstrap a Spring Boot/HTMX project without the need of any manual intervention.
I need to use Gradle. This post is in essence a write up of how to do manually what wimdeblauwe/ttcli does automatically with the difference that Gradle is being used.

To make everything work the following technologies are used:

  • Spring Boot
    The backbone of the application providing both the backend and the frontend.
  • Thymeleaf
    Templating engine to implement the frontend.
  • HTMX
    To create a single page application (SPA) like UI, using server-side rendering.
  • Vite
    Facilitates the hot-reloading functionality and opens the door to the JavaScript world if server-side rendering is not enough.
  • TailwindCSS
    To make things look pretty.

Setup a Spring Boot Project

Head over to https://start.spring.io/ to create the initial Spring Boot project.

Choose “Gradle - Groovy” as the Project and “Java” for the Language.

For the Spring Boot version I am using 4.0.7, since the htmx dependency is not yet compatible with 4.1.0.

Add the following dependencies:

  • Spring Web
  • Thymeleaf
  • htmx

For Group, Artifact and Package name you can choose what you want. I’ll go with:

  • Group: io.betweendata
  • Artifact: spring-boot-htmx-starter
  • Package name io.betweendata.spring-boot-htmx-starter

Here is the link to the preconfigured project: Preconfigured Project

Generate the project, extract it somewhere on your hard drive and open it in your IDE of choice. Execute the bootRun Gradle task to start it.

I am using Linux where the command is ./gradlew bootRun executed in the root of the project. On Windows it would be ./gradlew.bat bootRun. If you are on Windows keep this in mind for the rest of this post.

Open http://localhost:8080/ to verify it is working. You should see a “Whitelabel Error Page”.

That is it for the first step. You set up the base of the application. The next step is to add our first Thymeleaf template.

Code for this section: 01-setup-spring-boot-project.

Adding the first Thymeleaf template

The next step is to get rid of the error currently being shown, replacing it with a simple first template.

In src/main/resources/templates add the file index.html with this content:

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<body>
<h1>Hello World!</h1>
</body>
</html>

It is the bare minimum for a template that will just render “Hello World!".

The template on its own will not do anything. To have it sent to the browser when the user opens our application, we must add a controller that returns it.

Create the Java class io.betweendata.spring_boot_htmx_starter.HomeController with the endpoint serving the template:

package io.betweendata.spring_boot_htmx_starter;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/")
public class HomeController {

    @GetMapping
    public String index() {
        return "index";
    }
}

This controller will serve all requests for the root path (http://localhost:8080/) of the application. The name of the method (here index) doesn’t matter. Important is the @GetMapping annotation telling Spring the type of method (here GET) this endpoint reacts to. The method looks like it returns a string, but what it actually returns is the name of the template. If you use a modern IDE like IntelliJ, you should have autocomplete making it easier to return the correct name for the template.

Restart the server (./gradlew bootRun) and reload the page (http://localhost:8080/). You’ll now see the rendered template instead of the error message.

The problem is, that every time you make a change to the template, you will have to restart the server and reload the page in order to see the change. This is where web development tools like React shine. Any change you make will automatically reload the page without any manual intervention. That is the kind of developer experience we want. And in the next sections we will get it!

Code for this section: 02-add-first-template

Adding hot-reloading

There are different approaches to make hot-reloading work. The default way for Spring Boot is to use the dev tools. It works, but it is slow. Especially compared to something like React. They way the dev tools work is by looking for changes on the classpath (which includes the templates). For the files on the classpath to change the project must be build. IDEs like eclipse or IntelliJ provide ways to automatically trigger the build when something changes, but I found this to be too slow (and resource-intensive) to be an enjoyable developer experience.

This is where we will leave the safe world of the Java ecosystem and wander into the dark world of Node. More specifically Vite and two helpful libraries developed by Wim Deblauwe.

Working as a full stack developer (using Spring Boot and React) for the past years, I found myself increasingly frustrated with “modern” web development. On some days I spent more time trying updating NPM libraries than working on providing business value. Hence my interest in HTMX trying to simplify the tech-stack.

When running the server the Thymeleaf templates are placed under build/resources/main/templates. This is from where they are served to the browser. But even when editing them directly in that folder and reloading the browser, you would see no change. That is due to Spring caching the templates. The hot-reloading mechanism (Vite) will rely on replacing the templates in the build output directly. Therefore the first thing we need to do is to tell Spring not to cache them.

We only need this behavior during development. So we can use a dedicated Spring profile that we only activate during development.

In src/main/resources create a file named application-dev.yaml and add the config to disable caching:

spring:
  thymeleaf:
    cache: false

Start the server using the dev profile: ./gradlew bootRun --args='--spring.profiles.active=dev'

Now you can edit build/resources/main/templates/index.html directly, reload the page and see the changes you made. Of course we don’t want to work in the build output directly. It was just to demonstrate that if the templates in the output are changed, the updated version is served. This is in essence what we will tell Vite to do. Copy the updated templates into the output and reload the page.

The next step is to setup Vite and configure it. At this point you need to have NodeJS installed.

In the root of the project create a package.json with this content:

 {
  "name": "htmx-starter",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "devDependencies": {
    "vite": "^8.0.12"
  }
}

Run npm install to install Vite and make sure to add the node_modules folder to your .gitignore.

The first piece of the puzzle to get hot-reloading to work is to add the vite-plugin-spring-boot developed by Wim. It watches the templates in src/main/resources/templates, copies them to the build output and triggers a page reload when a template changes.

Install the plugin npm i -D @wim.deblauwe/vite-plugin-spring-boot and create vite.config.js in the root of the project:

import {defineConfig} from 'vite';
import path from 'path';
import springBoot from '@wim.deblauwe/vite-plugin-spring-boot';

export default defineConfig({
    plugins: [
        // Enable the plugin which will watch the Thymeleaf templates, copy them to the build output and trigger a
        // reload of the page
        springBoot({
            buildSystem: 'gradle'
        })
    ],
    // Tell Vite where the root of the project is located. For a Spring Boot application we point it to the folder where
    // the templates and other assets (i.e. CSS files) are located.
    root: path.join(__dirname, './src/main/resources'),
    server: {
        proxy: {
            // Proxy any request that does not handle assets to the Spring Boot endpoint
            '^/(?!static|assets|@|.*\\.(js|css|png|svg|jpg|jpeg|gif|ico|woff|woff2)$)': {
                target: 'http://localhost:8080',  // Spring Boot backend
                changeOrigin: true,
                secure: false
            }
        },
    }
});

The client side is done. At least for now. We’ll come back to it later to add Tailwind. But first we finish the server side.

Wim not only helps us with the client side but also with the server side, providing vite-spring-boot.

Add the dependency to the build.gradle:

implementation 'io.github.wimdeblauwe:vite-spring-boot-thymeleaf:2.0.1'

Also add <vite:client></vite:client> to the index.html:

<!DOCTYPE html>
<html lang="en"
>
<head>
    <meta charset="UTF-8">
    <title>spring-boot-htmx-starter</title>
    <vite:client></vite:client>
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>

This tag will be replaced by vite-spring-boot with the required JavaScript to allow Vite to trigger hot-reloading. To control if the script is actually rendered, add vite.mode=dev to the application-dev.yaml. This way we only add it during development.

When the vite.mode is not dev it will just replace the tag with an empty string.

Start both Vite npm run dev and the application ./gradlew bootRun --args='--spring.profiles.active=dev'. You can now open http://localhost:8080/ and start editing your templates. Hot reloading is working, and any change you make to a template will be reflected almost immediately in the browser.

As a side note: You can open the app using both URLs http://localhost:8080/ and http://localhost:5173/. The difference is that when you use :8080 Spring will forward the requests for assets (like CSS stylesheets) to Vite. When you use :5173 Vite will serve the assets directly but forward any other request (to serve the templates) to Spring. I am not yet sure what the implications are using one over the other. But in general both should work.

Code for this section: 03-configure-hot-reloading

Adding Tailwind

Another upside of using Vite for hot-reloading is that it opened the door to the whole web development ecosystem. This allows us to add TailwindCSS easily.

Under src/main/resources/static/css create a file called application.css with this content:

@import "tailwindcss";
@source "../../templates";

The first line enables Tailwind and the second line tells it where to look for code which uses Tailwind classes. Tailwind will scan our HTML templates and generate the CSS only for the Tailwind classes we are actually using.

Now install the Tailwind plugin npm install -D tailwindcss @tailwindcss/vite and configuring it in the vite.config.js:

import {defineConfig} from 'vite';
import path from 'path';
import springBoot from '@wim.deblauwe/vite-plugin-spring-boot';

import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
    plugins: [
        // Enable the plugin which will watch the Thymeleaf templates, copy them to the build output and trigger a
        // reload of the page
        springBoot({
            buildSystem: 'gradle'
        }),
        tailwindcss()
    ],
    build: {
        manifest: true,
        rollupOptions: {
            input: [
                '/static/css/application.css'
            ]
        },
        outDir: path.join(__dirname, `./build/resources/main/static`),
        copyPublicDir: false,
        emptyOutDir: true
    }
...

The Tailwind plugin is imported, activated in the plugins section and the build section is added so the CSS files generated by Tailwind will be placed in the same folder as the Gradle output.

The last step is to import the css file in the index.html template by importing it in the head element so that it looks like this:

<head>
    <meta charset="UTF-8">
    <title>spring-boot-htmx-starter</title>
    <vite:client></vite:client>
    <vite:vite>
        <vite:entry value="/css/application.css"></vite:entry>
    </vite:vite>
</head>

Restart both Vite and the application. Make a change to the index.html adding a Tailwind class:

<h1 class="text-blue-400">Hello World!</h1>

As soon as you save the change the website will reload and the CSS classes are applied.

Code for this section: 04-adding-tailwindcss

Adding HTMX

The last piece of the puzzle is HTMX, it allows us to write applications that feel like SPAs but are server-side rendered. It will still have its limitations when it comes to applications that require sophisticated client side interactions. But it will get us most of the way there. For anything beyond that we could integrate React, thanks to the existing Vite setup.

HTMX is just a small JavaScript library which you could import directly into the index.html template. With the goal of keeping as much in the Java ecosystem as possible, we will use a WebJar. It is pretty much just the JavaScript library packed and distributed as a .jar file.

Add the dependency to the build.gradle and refresh your dependencies:

implementation 'org.webjars.npm:htmx.org:4.0.0-beta4'

I am using the beta of version 4. You can of course choose any other version that works best for your use-case.

Add the import of the JavaScript file to the index.html directly after the html element:

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org"
>
<script type="text/javascript" th:src="@{/webjars/htmx.org/4.0.0-beta4/dist/htmx.min.js}"></script>
...

To show that HTMX is working, we’ll setup a simple counter. We will show a number and two buttons that can be used to count up or down. This not only shows that HTMX works but also shows some basic Thymeleaf concepts like fragments, URL parameters and server-side state.

Let’s add counter.html under src/main/resources/templates/components, which will contain the counter fragment:

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org"
>
<span th:fragment="counter" id="counter" th:text="${counter}"></span>
</html>

Update the index.html to use the fragment and add two buttons that will control the counter.

...
<body>
<h1 class="text-blue-300 text-6xl">Count: <span th:replace="~{components/counter :: counter}"></span></h1>
<div class="m-4 flex gap-4">
    <button th:hx-post="@{/count(direction='down')}" hx-target="#counter" class="p-1 outline-1 hover:cursor-pointer">
        Count Down
    </button>
    <button th:hx-post="@{/count(direction='up')}" hx-target="#counter" class="p-1 outline-1 hover:cursor-pointer">
        Count Up
    </button>
</div>
</body>
...

The counter fragment is referenced (th:replace="~{components/counter :: counter}) to be rendered. Each of the two buttons make a POST request to the /count endpoint but with a different URL parameter. The URL parameter will tell the backend to count up or down.

You can already click the buttons, but it will result in an error. To fix this we must implement the /count endpoint. We can do this in the HomeController:

@Controller
@RequestMapping("/")
public class HomeController {

    private int counter = 0;

    @GetMapping
    public String index(Model model) {
        model.addAttribute("counter", counter);
        return "index";
    }

    @PostMapping("/count")
    public String count(@RequestParam String direction, Model model) {

        if (direction.equals("up")) {
            model.addAttribute("counter", ++counter);
        } else {
            model.addAttribute("counter", --counter);
        }

        return "components/counter";
    }
}

This adds the server side state counter and the /count endpoint. The endpoint receives the URL parameter to control if we count up or down. The Model allows us to add the data required by the templates.

Important to note is that though the method technically returns a String, this String is the reference to the template this method returns. You can see in the network console of your browser that when clicking one of the buttons, the response looks like this:

Once you restart the server you can play around with the buttons.

<!DOCTYPE html>
<html lang="en"
>
<span id="counter">2</span>
</html>

HTMX will take care of replacing the corresponding HTML code. Much like React takes care of updating and rendering only changed elements in the DOM.

Code for this section: 05-adding-htmx

Building it

If you build the project now (./gradlew bootJar) you will get a .jar containing a working application that you can run (java -jar build/libs/spring-boot-htmx-starter-0.0.1-SNAPSHOT.jar). The problem is that it doesn’t look good. The CSS styles are missing. To fix this we need to integrate the Vite build into the Gradle build.

I will be using the node plugin for Gradle, but you could also do this without this dependency by integrating a simple command line call to run the Vite build.

Start by adding the plugin to the build.gradle:

id 'com.github.node-gradle.node' version '7.1.0'

In addition add the following two Gradle tasks:

tasks.register('npmBuild', NpmTask) {
	dependsOn npmInstall
	args = ['run', 'build']
}

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

The first one triggers the Vite build, it is pretty much the same as running npm run build on the console. The second one ensures that the Vite build is triggered when the build compiles the resources for the application.

Now you can build and run the application again and it will look pretty again.

Code for this section: 06-building-it

Bonus: Building it natively

As a bonus I wanted to make sure that this setup supports native builds. For this to work some extra steps are needed.

Most annoyingly we have to ensure that the GraalVM knows about the code that is being executed. For many existing libraries this information is provided by the GraalVM build tools. But especially when using the newest versions (i.e. of Spring Boot) this information might not be available yet. In this case we have to collect it ourselves.

For this we execute our tests with an agent which allows the GraalVM build tools to trace which code is being executed and store this information in the reachability-metadata.json which the build will use later to determine what code to include. This theoretically means that you need 100% test coverage to ensure all code being executed is part of the generated JSON file. In practice you need just enough code coverage that each different method is at least called once. I am not sure what the best way is to achieve this and if there even is a way to guarantee that no code is missed. In this regard native compilation still is unpleasant to use with Spring.

Start by adding the GraalVM build tools plugin to the build.gradle:

id 'org.graalvm.buildtools.native' version '0.11.5'

Then configure it:

graalvmNative {
    agent {
        // Keep test-framework reflection (JUnit, Mockito, Spring Test) out of the
        // production metadata - those classes aren't on the native-image classpath.
        accessFilterFiles.from('src/test/resources/access-filter.json')
    }
    metadataCopy {
        inputTaskNames.add('test')
        // Use a coordinate distinct from the application's own group/artifact
        // (io.betweendata/htmx-starter) so this agent-traced metadata does not collide
        // with the reachability-metadata.json that Spring Boot AOT generates at that
        // same path. GraalVM merges every META-INF/native-image/<group>/<artifact>
        // subdirectory, so the hints are still picked up for native compilation.
        outputDirectories.add('src/main/resources/META-INF/native-image/io.betweendata/htmx-starter-agent')
        // Overwrite with the freshly traced metadata rather than accumulating stale entries.
        mergeWithExisting = false
    }
}

The configuration defines which Gradle task to observe (test) and where to place the output.

As mentioned before, the tests will be executed with an agent that records all code that is being executed. Since the agent observes the test execution, it will not only record our production code but the test code as well. To prevent this code from being part of the native executable, we have to tell the agent to ignore it. Create src/test/resources/access-filter.json in which we define the package whose classes will be excluded:

{
  "rules": [
    {
      "includeClasses": "**"
    },
    {
      "excludeClasses": "org.junit.**"
    },
    {
      "excludeClasses": "org.mockito.**"
    },
    {
      "excludeClasses": "net.bytebuddy.**"
    },
    {
      "excludeClasses": "org.objenesis.**"
    },
    {
      "excludeClasses": "org.springframework.test.**"
    },
    {
      "excludeClasses": "org.springframework.boot.test.**"
    },
    {
      "excludeClasses": "org.springframework.boot.webmvc.test.**"
    },
    {
      "excludeClasses": "org.springframework.boot.webflux.test.**"
    }
  ]
}

The downside of this is that this file is maintained manually and you have to remember to add any new test dependency to it. Otherwise it will end up in your native code. That will not break your application but it will make the executable larger than it needs to be.

The last thing to do is to add a test that runs our application code to ensure we have enough coverage to include all relevant code in the native build. Update the existing SpringBootHtmxStarterApplicationTests:

package io.betweendata.spring_boot_htmx_starter;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class SpringBootHtmxStarterApplicationTests {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void contextLoads() {}

    @Test
    void indexReturnsOk() throws Exception {
        mockMvc.perform(get("/")).andExpect(status().isOk());
    }
}

Now we can create a native build by executing first the tests and than the actual build:

  • ./gradlew -Pagent test
  • ./gradlew -Pagent metadataCopy nativeCompile

Now starting the application (./build/native/nativeCompile/spring-boot-htmx-starter), you can observe that it behaves exactly the same. Even for this small app I observed a difference of about 100MB RAM between the regular build (~200MB) and the native build (~100MB).

Code for this section: 07-building-it-natively

Resources