The HATE Stack - Simple and Efficent
The established frontend frameworks are powerful, stable, and more than capable of handling any use case you might think of. However, we’ve come a long way since the initial days of basic single page apps. In the meantime, the web dev space became known for its complexity, and the amount of things devs have to keep track of just to build a simple application is getting out of hand.
I for one am a fan of feeling like a junior with each release or new idea in the space, and wasting hours comparing frameworks is one of my main passions. [No question about it - I am ready to be hurt again] However, for those of you who might be interested in actual results, a simple stack like hate might be the smarter choice.
HATE
Hate leverages some powerful tools and proven concepts.
HTMX is used to enhance DOM elements and simplify the way the client communicates with the backend.
Modern apps offer great user experiences, and this is usually done via client side JavaScript. However, there is no need to overcomplicate things, and a lightweight framework like Alpine offers the perfect balance between simplicity and power.
Data storage is key when building apps that should scale, and Turso is one of those new services that automatically takes care of all the scalability and maintenance issues you usually run into when serving data to your users.
Finally, we’ll let Go, one of the most popular and powerful languages in recent years do all the heavy lifting on the backend in this stack. Go is known for its powerful standard library which can handle almost any task, but I am looking for the best dev experience, so we’ll use Echo as the web framework and Templ as our templating engine.
We’ll build our micro blogging platform in 3 big phases which should be familiar if you have prior experience in building apps.
-
We’ll start with defining our database structure.
-
Then we’ll build our backend web service which accepts incoming HTTP requests, performs the business logic, communicates with the database, and returns some HTML response back to the client.
-
Finally, we’ll focus on the frontend where we’ll mix together HTMX and Alpine to add interactivity to our app.
Database Setup
Setting up the database will be really straightforward. Let’s start by creating a free Turso account and downloading the CLI locally.
curl -sSfL https://get.tur.so/install.sh | bash
We can then create an SQLite database using the db create command.
turso db create awesome-db --region fra
One of Turso’s main selling points is its seamless replication support, and we can easily define a replica of our data in a different region of the world.
turso db replicate awesome-db syd
turso db replicate awesome-db lax
This helps a lot in production, when your users can retrieve data from the location closer to them, and save precious time thanks to the lower latencies.
Once connected to the database,
turso db shell awesome-db
let’s create an article table with title and body fields,
CREATE TABLE article (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL
);
and a comment table with entities linked to the article table.
CREATE TABLE comment (
id TEXT PRIMARY KEY,
article_id TEXT NOT NULL,
body TEXT NOT NULL,
FOREIGN KEY (article_id) REFERENCES article(id)
);
With this simple setup in place, we can now jump into a new Go project, and build our backend service.
Go Backend Service
Let’s initialize a new Go module, and add a couple of dependencies to the project.
go mod init awesome
go get github.com/labstack/echo/v4
go get github.com/a-h/templ
As I already mentioned, we’ll use Echo as our web framework and Templ to seamlessly render HTML code for the client. We’ll look at these in detail in a second.
First however, let’s quickly review the project structure and some of Go’s best practices we’ll enforce while building our service.
Go is well known for its fast compile times, and its seamless build process. We’ll define a Makefile with a run command which first generates our templates, compiles the code and starts the resulting executable file with a PORT environment variable. That’s it!
// src/Makefile
export PORT=:8080
run:
templ generate
PORT=$(PORT) go run cmd/main.go
This simplicity is a breath of fresh air, especially if your background is in a language like Java, where you have to jump through a lot of hoops when building and packaging your code.
Next, let’s look at the main function, which is the entry point into our program. Here we’ll define a new Echo instance, serve CSS and JavaScript files from the static directory, register handlers for various HTTP requests which will be received from the client, and then start our app at the port we received from the make command.
// src/cmd/main.go
func main() {
app := echo.New()
app.Static(pathPrefix: "/static", fsRoot: "static")
app.GET(path: "/write", func(ctx echo.Context) error {
//TODO
})
port := os.Getenv(key: "PORT")
if port == "" {
port = ":3000"
}
app.Logger.Fatal(app.Start(port))
}
We could write our handlers directly in the main file, but, since this project might grow in size, it’s always a good idea to enforce some separation of concerns. However, we shouldn’t spend too much time on premature optimizations.
// src/cmd/main.go
func main() {
app := echo.New()
app.Static(pathPrefix: "/static", fsRoot: "static")
articleService := service.ArticleService{}
basicHandler := handler.BasicHandler{ArticleService: articleService}
articleHandler := handler.ArticleHandler{ArticleService: articleService}
app.GET(path: "/", basicHandler.ShowHome)
app.GET(path: "/write", basicHandler.ShowWrite)
app.GET(path: "/articles/:id", articleHandler.ShowArticle)
app.POST(path: "/articles", articleHandler.CreateArticle)
port := os.Getenv(key: "PORT")
if port == "" {
port = ":3000"
}
app.Logger.Fatal(app.Start(port))
}
We’ll store our handlers in the handler’s package, and all our business logic in the service package. Since we need to map entities to table structures, we’ll define the needed structures in the model directory, and we’ll use the view folder for our templates.
At a minimum, our users should see a landing page when they first access the app, have access to a form to write new articles, and perform some basic operations on the articles and comments tables.
Let’s explore the entire workflow from receiving user data in the handler, to performing logic in the service layer, to storing data into the database and then to rendering the HTML via Templ. Know that the following example can be extrapolated to all other workflows.
// src/handler/article_handler.go
type ArticleHandler struct {
ArticleService service.ArticleService
}
func (h ArticleHandler) ShowArticle(ctx echo.Context) error {
id := ctx.Param("id")
a := h.ArticleService.Read(id)
return render(ctx, article.Show(a))
}
func (h ArticleHandler) CreateArticle(ctx echo.Context) error {
title := ctx.FormValue("title")
body := ctx.FormValue("body")
a := h.ArticleService.Create(title, body)
return render(ctx, article.Show(a))
}
The article handler is a plain structure with an article service field injected from the main function.
The Show Article value receiver is pretty straight forward, so let’s look in more detail at Create Article.
// src/handler/article_handler.go
func (h ArticleHandler) CreateArticle(ctx echo.Context) error {
title := ctx.FormValue("title")
body := ctx.FormValue("body")
a := h.ArticleService.Create(title, body)
return render(ctx, article.Show(a))
}
The user will submit a form to create a new article instance, and we can retrieve the user data from the echo context. This is beyond the scope of this article, but remember that in real world scenarios user data should always be sanitized and validated.
Then, we are sending the data to the Article Service which will perform a call to insert the values in the Turso instance. Turso is a service exposing some Rest endpoints, and we can use Go’s standard library to perform requests to that API.
// src/service/article_service.go
type ArticleService struct {}
func (s ArticleService) Create(t string, b string) model.Article {
resp, _ := DbCall(fmt.Sprintf("INSERT INTO article(title, body) VALUES ('%s', '%s')", t, b))
id := ExtractValue(resp, key: "last_insert_rowid")
return model.Article{
Id: id,
Title: t,
Body: b,
}
}
func (s ArticleService) Read(id string) model.Article {
data, _ := DbCall(fmt.Sprintf("SELECT * FROM article where id = %s", id))
result, _ := ExtractRows(data, key: "rows")
return model.Article{
Id: result[0][0].Value,
Title: result[0][1].Value,
Body: result[0][2].Value,
}
}
In the Database Call function I’m defining a command structure which will be serialized to the JSON format expected by the Turso API, define an Authorization header, and then perform the actual Post request. After some “in your face” error handling that Go is famous for, the result of our REST call is returned to the service method.
// src/service/db.go
func DbCall(query string) (string, error) {
command := DbCommand{
Tp: "execute",
Stmt: &DbStatement{Sql: query},
}
commands := []DbCommand{command, CloseCommand}
data, _ := json.Marshal(map[string][]DbCommand{"requests": commands})
req, err := http.NewRequest(method: "POST", url: DbUrl, body: bytes.NewBuffer(data))
if err != nil { return "", err }
req.Header.Set(key: "Content-Type", value: "application/json")
req.Header.Set(key: "Authorization", value: "Bearer "+DbAuthToken)
resp, err := httpClient.Do(req)
if err != nil { return "", err }
content, err := io.ReadAll(resp.Body)
if err != nil { return "", err }
return string(content), nil
}
Note that we’ll use the Database Call method in all our services for the create, read, update, and delete operations (check out the code above from src/service/article_service.go). When entities are retrieved, the extract result method handles deserialization via an external library.
Back in the Article Handler, with the entity persisted in the database, we can now send some information back to our users.
// src/handler/article_handler.go
return render(ctx, article.Show(a))
SSR (Server Side Rendering)
As you might know, server side rendering is a pretty hot topic, and all JS frameworks are spending a lot of time and effort to optimize this process. With Go and Echo you get SSR out of the box, and, thanks to Templ, the developer experience is close to what you are already familiar with if you have some experience with tools like JSX.
Let me show you how flexible Templ is.
The view directory contains a collection of components like headers, footers or buttons which can be reused anywhere in the UI, and layout files that define areas sharing the same functionality. I’m also defining directories for all other main views in our app.
The article file showcases most of the core features Templ is offering.
// src/view/article/show.templ
templ Show(article model.Article) {
@layout.Base() {
<div class="article">
<h1>{{ article.Title }}</h1>
<p>{{ article.Body }}</p>
<footer x-data="{ comment: false, love: false }">
<button x-on:click="comment = !comment">
@component.CommentIcon()
</button>
<button x-show="!love" x-on:click="love = true" hx-post={"/article/" + article.Id + "/love"}>
@component.LoveIcon()
</button>
<section class="comment-pop" x-show="comment" x-transition>
@component.CommentForm(component.CommentFormProps{
ArticleId: article.Id,
})
</section>
</footer>
</div>
}
}
We’ll start with a plain go function receiving some properties, which are then rendered on the page. This content will be decorated with our base layout, and we can easily mix our code with various components. There is some other markup in here we’ll come back to in a second, but first, please note that our view files don’t have a go extension. Whenever the “templ generate” command is executed, our templates are compiled into go files. This is performed almost instantly due to the power of the Go compiler, and, thanks to this approach, we can enjoy Go code suggestions and validations directly in the template files.
UX & UI
Finally, let’s take a deeper look at our UI. Thanks to SSR the client receives the expected HTML directly, but we still need ways to trigger async calls back to the server or perform small UI updates directly on the client when needed.
HTMX enjoys a lot of publicity at the moment, and, as you’ll see in a second, this is quite deserved.
The idea behind HTMX is surprisingly simple - the plain old HTML is enhanced thanks to a few attributes and directives. As a result, elements can both trigger async HTTP requests to a server and reload parts of the UI when the response is received. So this is how we can trigger async create or delete requests with HTML markup, and with 0 lines of JavaScript.
<button
x-show="!love"
x-on:click="love = true"
hx-post={"/article/" + article.Id + "/love"}
>
@component.LoveIcon()
</button>
While HTMX can seamlessly update the DOM based on the received HTML responses, a small library like Alpine can improve the user experience quite a bit.
Alpine builds on top of the same HTML markup idea and, despite its lightweight size and small API, it allows you to define client state x-data="{ comment: false, love: false }"
, conditionally render elements x-show="!love"
or handle events in a straightforward manner x-on:click="love = true"
.
Of course, you could do this frontend work with just one of these two libraries, but combining HTMX with Alpine will provide a way better dev experience.
If you’ve enjoyed this article, you should read other features from this blog.
Until next time, thank you for reading!