
Intro Link to heading
In my previous posts, I talked about static web pages and templating engines for generating (or prerendering) them, breaking down how to use layouts specifically of one of my favs - HUGO.
The next level of complexity in web development is dynamicity. Instead of loading the whole new page, we may want just to update some parts of it. In case we are using circulating data, there’s a need to establish mechanisms for synchronizing data between the client and server efficiently. This is typically achieved through techniques like AJAX
(Asynchronous JavaScript and XML), WebSockets
for real-time communication, or frameworks that support dynamic data binding such as React
, Vue
, or Angular
. These approaches allow for partial updates to the webpage, reducing bandwidth usage and improving the user experience by making interactions faster and more seamless.
HTMX: an underdog of web data binding Link to heading
But there’s an even simpler approach. Instead of using heavy frameworks, you may choose HTMX, a tiny library that helps you bind your web pages to a data source and update them dynamically when needed.
I want to share with you my experience with HTMX based on a free tool I have created for a blog of my B2C calorie counting app FoodIntake. This is a Recipe Analyzer which uses data from one of my no-sql food databases and has some AI API in between serving for preprocessing user input. One of the tools is a pretrained model for parsing ingredients and quantities ingredient-parser, and another one is an LLaMa model provided by LLM hoster GROQ. The free usage of the LLM is limited by RPS, but it’s enough for now to attract more users to my blog and eventually to the mobile app.

Brief functionality description and stack used Link to heading
The Recipe Analyzer is very simple. It gets a list of ingredient names with respective recipe quantities and generates a USDA Nutrition Facts label.
Notice that this two-page application has a lot of dynamicity in it. On the first page, there are Clear, Analyze, and Try Sample buttons to manipulate the content entered into the input box:
The second page has even more dynamicity. There are three controls to change the serving size and number of portions of a recipe. Each ingredient has a dropdown menu to pick from a list of recognized and matched USDA ingredients from my food database.
The label itself is dynamic and recalculated and redrawn any time the user changes any of the values.
The HTML of the pages is super simple and takes only 410 lines. The CSS is a Tailwind library.
The server side is written in Go
using the GIN
server and Redis
as a cache for keeping session data bound to the page for dynamic updates.
Go-Gin connection Link to heading
I used this template to kick off the backend service for the mini-project. I extended the project template by using GIN as I already worked with the framework, so I decided to use it again.
This is basically backbone code to read CSS, define routes, and add a template renderer.
router := gin.Default()
router.Static(BasePath+"/css", "./css")
router.SetFuncMap(funcMap)
router.LoadHTMLGlob("templates/*.html")
basePathRoutes := router.Group(BasePath)
{
basePathRoutes.GET("/", index)
basePathRoutes.GET("/index.html", index)
basePathRoutes.GET("/sample-recipe", sampleRecipe)
basePathRoutes.GET("/reset-view", resetView)
basePathRoutes.GET("/get-nutrition-label", getNutritionLabel)
basePathRoutes.POST("/update-serving-size", updateServingSize)
basePathRoutes.POST("/update-portions", updatePortions)
basePathRoutes.POST("/parse-ingredients", parseIngredients)
basePathRoutes.POST("/update-option", updateOption)
}
I used the BasePath environment variable to simplify local and NGINX proxy routing management.
Binding of data with HTMX Link to heading
HTMX is a pretty straightforward framework, and the only problem I have experienced with it is making a decision about the update scope for specific content.
But basically all UI frameworks with “reactive”, one-way or two-way data binding are based on the principle. In declarative programming, you manage UI implicitly by mutating the state of variables bound to a specific UI scope.
Change of the state means you recompute memory storing the blocks of UI by calling some function, aka we are doing some functional programming
here.
This page helped me a lot, but to solve the problems quicker I recommend to join discord community and read pages here on the official webpage
c.Header("HX-Trigger", "ingredientUpdated")
c.HTML(http.StatusOK, "ingredient-row.html", gin.H{
"Base": updatedIngredient.Base,
"Name": updatedIngredient.Name,
"Size": updatedIngredient.Size,
"Unit": updatedIngredient.Unit,
"Calories": updatedIngredient.Calories,
"Options": updatedIngredient.Options,
"SelectedOption": updatedIngredient.SelectedOption,
})
And this is how the payload is parsed inside a template:
<div class="flex md:flex-row flex-col md:space-x-10 space-y-10 w-full" hx-swap="innerHTML" hx-trigger="ingredientUpdated from:body, servingSizeUpdated from:body" hx-get="{{basePath}}/get-nutrition-label" hx-target="#nutrition-label-container">
<div class="flex-grow" id="ingredients-container"></div>
<div id="nutrition-label-container"></div>
</div>
hx-target
- defines a scope of replacement
hx-trigger
- defines an event or trigger of the content replacement
hx-swap
- defines a type of the content replacement
hx-get
- defines a data source for the content (brother Ajax)
I have experience with many reactive frameworks, read much documentation, and watched a lot of educational video materials on the topic. I have written many tools and apps based on them. Here are 3 rules of wisdom I stick to when I work with such frameworks:
1. Always start from a data source.
2. There has to be only one data source of truth.
3. Split your scope of state updates for complex views: the more different state triggers and events you have, the deeper should be your view hierarchy. You have to have granular control over scopes of UI to reduce the count of updates at a specific event trigger. You should put your state as deep as possible into a view hierarchy to avoid excessive updates of UI which shouldn’t be updated at the event to have a performant fast loading application.
Other UI updates which don’t require dynamic data changes Link to heading
For some simple UI updates which did not require server data, I used JQuery
. Yes! It is still alive and works perfectly.
<script>
$(document).ready(function () {
// Toggle dropdown visibility
$(document).on('click', '.dropdown-button', function () {
var dropdownMenu = $(this).next('.dropdown-menu');
$('.dropdown-menu').not(dropdownMenu).addClass('hidden');
dropdownMenu.toggleClass('hidden');
});
....
</script>
Foreword about HTMX Link to heading
I think HTMX is a pretty lightweight and straightforward tool. It has some learning curve as every tool has.
– Would I recommend it? – Well, if you don’t fear learning something new, if you need to make something blazingly fast, if you are not biased and don’t fear losing years of experience with any other framework by investing in experience with HTMX instead. Then Yes I DO!

The community of HTMX is quite rich and getting better over time. You may find tons of examples and stories, debates, and essays on comparisons with other frameworks on again the official webpage
I’ve had discussions, I tried React before, I wanted something simple and fast loading. It worked for me, maybe it will work for you, check it out yourself.
Database Link to heading
As this is a free tool and I wanted to make it quickly, there’s no authorization or persistent database involved. So for storing session data, I used the in-memory database Redis.
The only downside of Redis, in my opinion, is that every open session takes your server’s memory, so I reduced expiration time to 30 seconds (ttl) to decrease potential usage.
LLM AI Link to heading
I didn’t mention written in Python intermideary micro service in the stack to get ingredients options.
This tool does some basic preprocessing of entered data to extract sizes and convert measurement units into grams and milliliters.
To increase the precision of LLM output, preprocessing is done using a small ML model. I used this python library for the task.
When the preprocessing is done, I request LLM to convert units to grams and milliliters.
model = "llama-3.3-70b-versatile"
...
try:
response = client.chat.completions.create(
messages=messages,
model=model,
response_model=ResponseModel,
max_tokens=4096
)
if response and response.tool_calls:
data_map = {item.name: item for item in data}
for tool_call in response.tool_calls:
parsed_parameters = json.loads(tool_call.tool_parameters)
for ingredient_name, details in parsed_parameters["ingredients"].items():
if ingredient_name in data_map:
data_map[ingredient_name].unit = details["unit"]
data_map[ingredient_name].size = details["size"]
return list(data_map.values())
except Exception as e:
logging.error(e)
return data
After this, I extract ingredient options from my compound database of Food Ingredients composed from several open, managed by governments food databases. The data is stored in ElasticSearch for efficient searching.
Security Link to heading
So as there’s no authorization involved on the client side, I needed a secure mechanism for communicating between the app and my services which have access to the Food-Database.
I used a signed JWT token for the purpose, and all requests to one of the endpoints for searching ingredient options by an ingredient name in the database are accessible via the JWT.
func GenerateJWT() (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": "https://foodintake.space/recipe-calculator", // Issuer
"aud": "<my_endpoint_to_fetch_data>", // Audience
"exp": time.Now().Add(time.Hour).Unix(), // Expiration time (1 hour from now)
"iat": time.Now().Unix(), // Issued at
"sub": "recipe-calc", // Subject
})
return token.SignedString(privateKey)
}
The database is behind an auth micro-service which checks the JWT claims, expiration, and signing.
headerValue := r.Header.Get("X-Original-URI")
if _, exists := JWTAllowedEndpoints[headerValue]; exists {
if claims, err := validateAndExtractClaims(idToken); err == nil {
json.NewEncoder(w).Encode(map[string]interface{}{"success": "Authorized", "jwt": claims})
return
}
}
The server routing is configured like this, so before reaching the data service, every call has to pass authorization.
location /data/ {
proxy_set_header X-Original-URI $request_uri;
auth_request /auth;
auth_request_set $auth_status $upstream_status;
proxy_pass <data_service_name>;
rewrite /data(/|$)(.*) /$2 break;
}