HTMX Crash Course - It's Not a React Alternative
In this post you will learn what is HTMX and how it differs from frontend frameworks. And in this HTMX tutorial we will create a todo application so you can see how we can build an application without frontend framework.
Like we have a click event on the button and we can attach some attributes on our HTML.
If you have a use case where you need a small amount of Javascript just to animate your comments for example and to avoid page reloading or just to implement infinite scrolling these are exactly use cases for HTMX.
This is the list of todos that we can add, edit or delete. We can build this stuff without frontend framework just with HTMX.Here is a small express project which I prepared for us. Additionally there are 2 CSS files which you can take from the source code under this post.Additionally we have an interface for our This is exactly the entity that we will use in our application. And Typescript is not mandatory to use HTMX, it is just easier for me do write code this way.
Now I want to create a Here I created basic markup and as you can see the whole Pug markup is build around indentation. We don't write open/close tags like we do in plain html. We indent every single tag which is exactly how we put one tag inside another.Also here we injected 2 scripts: for Here we added an Here we have an important line. We set our
As you can see in browser our template is rendered.
This is just an array that we can modify later.Now we provided these list of todos as a variable to our template.Here we added our form, main and footer with links.
As you can see we successfully rendered our basic markup.
Here we added an attribute But in order to generate an ID we must install one more package. Here we added Here we a markup for our In order to prepare markup we must call
As you can see after we submit our form it is being replaced with the markup that we got from our POST request.
With
As you can see our new todo was added to the list.But now here we need some Hyperscript magic. We want to clear an input after our todo was created.Here we have an As we already passed
Now even after page reload our todos are rendered on initialize.
It's just a template with a variable Now inside our We must also render our template in the footer.
As you can see in browser our counter is rendered and we see Here we created
As you can see in browser our counter is rerendered correctly with every new todo.
Here we created
As you can see our todos are filtered and we don't show any if we don't have completed todos when we activated completed tab.But we have a problem with styling because we didn't highlight active tab. As we passed a Here we add class if Here we added a button which will call Here we update our list of
As you can see in browser our cross is there and when we click it our element is being removed from the list.
Here we added a toggle checkbox which sends a PATCH request to Here we try to find a todo and set it to completed. We also prepare 2 template for
As you can see in browser we have a completed toggle that can change the state of our todo.And actually if you want to improve your Javascript knowledge and prepare for the interview I highly recommend you to check my course Javascript Interview Questions.
📚 Source code of what we've done
The idea
What is the idea of HTMX? We don't write Javascript at all, we don't have some frontend framework but we have some bindings to our default HTML.<button hx-post="/clicked" hx-swap="outerHTML">
Click me
</button>
Also it is important to remember that HTMX is extremely small.HTMX gives you access to AJAX, Websockets and CSS Transitions directly in HTML through attributes. Which means we add some attributes to elements without writing Javascript.For example our button that you just saw will send a POST API call to the url
/clicked
. Then it will take a markup that our API must return and put it inside this element.
HTMX gets markup from backend at not JSON data.It doesn't work like React where we build all markup from scratch. It uses existing backend rendered markup.
Why HTMX?
The main question here is why do we need HTMX at all when we have frameworks like React or Angular and a lot of people are used to them. HTMX is completely different.Previously we already had tools which worked in a similar way. Hotwire / Turbo did exactly that by just replacing existing markup with new markup and these tools typically exist inside backend frameworks like for example Ruby on Rails.The main point to remember is that HTMX is not an alternative to frontend framework.

Project planning
The goal of this post is to build TODO MVC project.
import express from "express";
import bodyParser from "body-parser";
import path from "path";
const app = express();
app.set("views", path.join(__dirname, "views"));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, "assets")));
app.listen(3012, () => {
console.log("Project started");
});
Todo
.export type Todo = {
id: string;
name: string;
isCompleted: boolean;
};
Configuring views
As I already said we respond to the API calls with markup. This is why we need to install something that can generate markup for our response. I want to use here a package which is called Pug. It is not mandatory, you can use any package which can generate markup.npm i pug
views
folder where we will store all template files.// src/views/index.pug
doctype html
html(lang='en')
head
meta(charset='utf-8')
meta(name='viewport' content='width=device-width, initial-scale=1')
link(rel='stylesheet' href='/css/todo-mvc-base.css')
link(rel='stylesheet' href='/css/todo-mvc-app.css')
body
script(src='https://unpkg.com/htmx.org@1.9.5')
script(src='https://unpkg.com/hyperscript.org@0.9.11')
htmx
and hyperscript
which is a dependency for htmx
for some attributes.body
h1 Hello HTMX
h1
tag to the body.Now let's render this template.app.set("view engine", "pug");
...
app.get("/", (req, res) => {
res.render("index");
view engine
as pug
so express uses correct library to create a markup. After that we render our template on /
route.
Adding markup
Now we need to store a list oftodos
inside our node application.const app = express();
let todos: Todo[] = [];
...
app.get("/", (req, res) => {
res.render("index", {todos});
doctype html
html(lang='en')
head
meta(charset='utf-8')
meta(name='viewport' content='width=device-width, initial-scale=1')
link(rel='stylesheet' href='/css/todo-mvc-base.css')
link(rel='stylesheet' href='/css/todo-mvc-app.css')
body
.todoapp
header.header
h1 todos
form
input.new-todo(
placeholder="What needs to be done?"
name="name"
autoFocus=""
section.main
ul.todo-list
footer.footer
ul.filters
li
a(
href="/?filter=all"
) All
li
a(
href="/?filter=active"
) Active
li
a(
href="/?filter=completed"
) Completed
script(src='https://unpkg.com/htmx.org@1.9.5')
script(src='https://unpkg.com/hyperscript.org@0.9.11')

Adding todos
Now let's talk about creating of new todos. We already have a form with the input.form(
hx-post="/todos"
)
hx-post
which means that when we submit our form it sends a request to /todos
route.Now we need to implement /todos
route. npm i uuid
uuid
allows us to do exactly that.import { v4 as uuid } from "uuid";
app.post("/todos", (req, res) => {
const newTodo: Todo = {
id: uuid(),
name: req.body.name,
isCompleted: false,
};
todos.push(newTodo);
console.log(newTodo);
res.send(200)
});
/todos
route where we create a new todo and add it to the todos
list.Now we must return a markup in our POST
request. But in order to do that we must create todo-item
template.// src/views/includes/todo-item.pug
li
.view
label #{todo.name}
todo-item
and in #{}
we can render any properties that we provide inside. So we must provide todo
to render this template.import pug from "pug";
...
app.post("/todos", (req, res) => {
const todoItemTemplate = pug.compileFile(
path.join(__dirname, "views/includes/todo-item.pug")
);
const todoItemMarkup = todoItemTemplate({ todo: newTodo });
res.send(todoItemMarkup);
});
pug.compileFile
which store a template. After this we must call it and provide needed properties inside. At the end in todoItemMarkup
we are getting plain HTML markup.
This is the default behavior of HTMX. It replaces the element that sends the request with returned markup.But it is not what we want. We want to add this markup to another place.
form(
hx-post="/todos"
hx-target="#todo-list"
hx-swap="afterbegin"
)
...
ul.todo-list#todo-list
hx-target
we tell what element we want to modify instead. And in hx-swap
we tell how we should modify it. afterbegin
means that we want to prepend the returned markup. So it will be added to the beginning of our list.Also we must add an ID todo-list
to our ul
in order to tell HTMX which element it must use.
form(
hx-post="/todos"
hx-target="#todo-list"
hx-swap="afterbegin"
_="on htmx:afterOnLoad set #new-todo.value to ''"
)
input.new-todo#new-todo(
placeholder="What needs to be done?"
name="name"
autoFocus=""
)
_
attribute which listens to HTMX event htmx:afterOnLoad
and sets an input value to an empty string.
htmx:afterOnLoad happens after our request was successfully made.The last thing that we want to do is to render all our todos in our markup so when we reload the page they are there.
ul.todo-list#todo-list
each todo in todos
include includes/todo-item
todos
property to the template we can just render them in the loop using todo-item
template.
Todos counter
The next thing that we want to implement is a counter of our todos. But it is quite tricky to do in HTMX.Our first step here is to create a new view for our counter.// src/views/includes/item-count.pug
span#todo-count.todo-count(hx-swap-oob="true")
strong #{itemsLeft} item left
itemsLeft
but we added a strange attribute hx-swap-oob
. What it does?
We use this attribute to swap our template when we get a response back.Why do we need that at all? So we create our todos and we want to render counter at the bottom. By default this counter will be never updated. But we want to rerender it every single time when we add or remove a todo.In order to implement that we can return markup from
item-count
file and an attribute hx-swap-oob
will swap the markup by this id todo-count
.app.post("/todos", (req, res) => {
...
const todoItemTemplate = pug.compileFile(
path.join(__dirname, "views/includes/todo-item.pug")
);
const todoItemMarkup = todoItemTemplate({ todo: newTodo });
const itemCountTemplate = pug.compileFile(
path.join(__dirname, "views/includes/item-count.pug")
);
const itemCountMarkup = itemCountTemplate({ itemsLeft: 20 });
res.send(todoItemMarkup + itemCountMarkup);
});
/todos
we prepare the template not only for todoItem
but also for itemCount
. And inside we provide 20 just for test. Most importantly we concatenaty 2 markups together and return them.
HTMX will see an ID in markup and put that part in the correct place to rerender our counter because of hx-swap-oob
tag.
...
footer.footer
include includes/item-count.pug
ul.filters
...

20
. Of course it is not correct and we must calculate correctly itemsLeft
property.const getItemsLeft = () => todos.filter((todo) => !todo.isCompleted).length;
...
app.get("/", (req, res) => {
res.render("index", {
todos,
itemsLeft: getItemsLeft()
});
});
...
app.post("/todos", (req, res) => {
...
const itemCountMarkup = itemCountTemplate({ itemsLeft: getItemsLeft() });
res.send(todoItemMarkup + itemCountMarkup);
});
getItemsLeft
function which returns the amount of not completed todos. Then we provided this information in our index template and when we calculate counter markup to update.
Adding filters
Now we must do exactly the same with filters. We must filter our data and provide not all todos in the template but filter them depending on what tab is activated.const getFilteredTodos = (filter: unknown) => {
if (filter === "active") {
return todos.filter((todo) => !todo.isCompleted);
} else if (filter === "completed") {
return todos.filter((todo) => todo.isCompleted);
} else {
return todos;
}
};
...
app.get("/", (req, res) => {
res.render("index", {
todos: getFilteredTodos(req.query.filter),
itemsLeft: getItemsLeft(),
filter: req.query.filter,
});
});
getFilteredTodos
function which returns only todos needed for activated tab. Then we provide these todos in our template based on filter
query parameter that we got from URL.
filter
property inside we can easily apply these styles.ul.filters
li
a(
href="/?filter=all"
class={selected: filter === 'all'}
) All
li
a(
href="/?filter=active"
class={selected: filter === 'active'}
) Active
li
a(
href="/?filter=completed"
class={selected: filter === 'completed'}
) Completed
filter
equals tab name.
Deleting todos
The next thing that we want to implement is deleting of our todos.li(
id="todo-" + todo.id
)
.view
label #{todo.name}
button.destroy(
hx-delete="/todos/" + todo.id
_="on htmx:afterOnLoad remove #todo-" + todo.id
)
/todos/id
DELETE API when clicked. As we want to remove the li
element we added a hypescript logic with htmx:afterOnLoad
and we tell that we want to remove the element by ID.Now we must implement this DELETE request.app.delete("/todos/:id", (req, res) => {
todos = todos.filter((todo) => todo.id !== req.params.id);
const itemCountTemplate = pug.compileFile(
path.join(__dirname, "views/includes/item-count.pug")
);
const itemCountMarkup = itemCountTemplate({ itemsLeft: getItemsLeft() });
res.send(itemCountMarkup);
});
todos
and compile itemCount
template which we return. We must do that because we want to update our todos counter.
Completing todos
The last thing that we want to implement is completing of our todos.li(
id="todo-" + todo.id
)
.view
input.toggle(
type="checkbox"
checked=todo.isCompleted
hx-patch="/todos/" + todo.id
hx-swap="outerHTML"
hx-target="#todo-" + todo.id
)
label #{todo.name}
button.destroy(
hx-delete="/todos/" + todo.id
_="on htmx:afterOnLoad remove #todo-" + todo.id
)
/todos/id
url. We want to rerender the whole element to apply different styling to it this is why it's hx-swap
outerHTML and we target our li
.Now let's add this route.app.patch("/todos/:id", (req, res) => {
const todo = todos.find((todo) => todo.id === req.params.id);
if (!todo) {
return res.sendStatus(404);
}
todo.isCompleted = !todo.isCompleted;
const todoItemTemplate = pug.compileFile(
path.join(__dirname, "views/includes/todo-item.pug")
);
const todoItemMarkup = todoItemTemplate({ todo });
const itemCountTemplate = pug.compileFile(
path.join(__dirname, "views/includes/item-count.pug")
);
const itemCountMarkup = itemCountTemplate({ itemsLeft: getItemsLeft() });
res.send(todoItemMarkup + itemCountMarkup);
});
todo-item
and for item-count
and return them as a response.