The Simplest Tech Stack

Since the web is neither as complex as game dev nor as sensitive as the embedded systems, web developers have to create their own job security through useless complexity.

What you might not realize though, since you are being inundated by all these different options, is that there are just a couple of concepts you need to understand and apply in order to easily build a state of the art, performant application. So let’s see these concepts in practice while building a basic web app.

Things are actually really simple:

All you need is a web server listening for incoming HTTP requests, perform your business logic, maybe store some data in a storage solution, render an HTML response, and send it back to the browser.

The browser will then parse and display that HTML. The HTML standard allows users to interact with the web page via Links and Forms. Whenever a link is clicked or a form is submitted, another HTTP request is sent to the server and we go through the same process again.

<a href="https://www.awesome.club/">Awesome Club</a>

<form method="post" action="/save">
  <label>
    Email:
    <input type="email" name="email" />
  </label>

  <button>Send</button>
</form>

When the HTML response comes back, the browser will parse it and replace the existing page with the new one. We call this a Multi-Page Application Architecture.

The alternative is the Single-Page Application architecture, where the HTML is usually rendered on the client using JavaScript files and JSON data sent from the server. This approach has some benefits but comes with a lot more complexity.

On top of that, the focus shifted fully towards Multi-Page Applications and server side rendering in recent years mainly because of performance reasons.

Advantages of Multi-Page Applications

So in this article we’ll focus on building Multi-Page applications, but if you are interested in a proper deep dive into SPAs as well please let me know.

I mentioned that users can interact with HTML on the browser via links and forms, but these elements will cause a full page refresh. This is not the user experience potential clients are expecting. Modern web apps can perform server actions in an async manner, and, when the response is received, update only parts of the page instead of doing a full page refresh.

Of course, this is done via a JavaScript library. While React, Angular and Vue are the most popular options, all these frameworks are better suited for building Single-Page Applications. In our case, since we are building a MPA, options like Alpine, Petite Vue or HTMX are better suited.

How to choose a stack?

There are a couple of ideas you have to keep in mind when deciding on a stack.

  1. First of all, performance is a key aspect when building for the web. This is true for both the server and the client. Your backend services need to return fast responses while being easily deployable, and scalable. The client on the other hand has to quickly display meaningful content to the user, while relying on an efficient network communication. Your users might run on bad internet or mobile data, so you have to avoid sending unnecessary bits over the wire.

  2. Second, the popularity and adoption of your tools is really important. Whenever you are committing to a stack you have to think long term, so always try to pick frameworks and libraries which have a good chance of still being around in 10 years. Wide adoption usually implies good community support, frequent releases and a rich collection of 3rd party libraries.

So in this article we’ll use Golang together with Gin on the server and HTMX on the client.

The reasons behind my decision are straightforward.

Go is one of the fastest growing languages on github, and is the perfect mix between simplicity and performance. On top of that go programs are built into a single binary executable file, which is a god sent in the deployment process.

Go also comes with a very powerful standard library which covers the basics of a web server. However, I will add Gin into the mix for convenience reasons even though the Go purists will roll their eyes.

GO on the server

Start by installing Go locally if you haven’t already.

$ tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz

Then we’ll initialize a new project and add our gin dependency.

$ go mod init awesome-go
$ go get -u github.com/gin-gonic/gin

If you are not familiar with Go - don’t worry. Go is famous for its simplicity, and the next couple of minutes will be a crash course on the subject.

We’ll start by defining a main package* and function in a new main.go file. These two lines are mandatory in any application, since this is the entry point* into our execution.

// main.go

package main

func main() {
    ...
}

Then, we’ll declare and initialize a name variable using the shorthand syntax*. Most of the type the Go compiler infers the type based on the value assigned to it, but you can also use the standard syntax if you want to be more explicit.

// main.go

package main

func main() {
    name := "World"
}

Finally, we’ll import* the format package, and print our formatted string to the console.

// main.go

package main

import (
    "fmt"
)

func main() {
    name := "World"
    fmt.Printf("Hello, %s!\n", name)
}

Back in the terminal we can execute the go run command, and we just finished the Go hello world example. Note that the entire process is really streamlined, and the Go compiler is famous for its speed.

$ go run main.go

Now let’s move to something a bit more interesting and add a web server into the mix. Once gin is imported, we can create an engine instance and register a GET listener to the root path. When a request comes in from the client, the code inside this method is executed, and some sort of response is sent back to the client.

// main.go

package main

import (
    "github.com/gin-gonic/gin"
)

func main() {
    e := gin.Default()

    e.GET("/", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "name": "Awesome",
        })
    })

    e.Run(":8080")
}

The handler is an anonymous function* which receives the context of the current HTTP request as an argument. This (“*gin”) is actually a pointer* used for performance reasons. The context could be a large structure, and passing it by value would require the entire entity to be copied on the stack.This can be expensive in terms of both time and memory. By passing a pointer, only the address of the struct is copied, which is a small, constant-size piece of data.

Then, we’ll simply set the response content type to JSON, and provide some data in the body.

Finally, we can start our HTTP server and listen to incoming requests at the 8080 port with the “run” method. You can now jump in the browser and see the result.

{
  "name": "Awesome"
}

However, we mentioned at the beginning of the video that in a Multi-page Application Architecture the browser should receive HTML instead of JSON.

So let’s go ahead and do that. Under the templates directory I’ll add a new index.html file which contains a simple header for now.

<!-- templates/index.html -->

<!doctype html>
<html lang="en">
  <body>
    <h1>Hello, {{ .name }}!</h1>
  </body>
</html>

Back in the main.go file we’ll first register the templates directory into the Gin engine, and then update the GET handler to return HTML. Note that we can easily pass properties from the Go context into the HTML template.

// main.go

package main

import (
    "net/http"

    "github.com/gin-gonic/gin"
)

func main() {
    e := gin.Default()

    e.LoadHTMLGlob("templates/*")

    e.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{
            "name": "Awesome",
        })
    })

    e.Run(":8080")
}

Of course, in real world scenarios users perform a myriad of operations, so most often than not you’ll need to handle POST, PUT and DELETE requests as well.

In order to do this, we have to make a quick detour into data storage solutions.

Storing Data

When it comes to databases, the options are really diverse. However, we’ll keep things simple here as well, so let’s go ahead and add SQLite in our Go project.

$ go get -u gorm.io/driver/sqlite

Then, we’ll create a new service.go file which will contain all our database logic and interactions.

// service.go

package main

import (
        "database/sql"
        "log"

        _ "github.com/mattn/go-sqlite3"
)

type ToDo struct {
        Id     int    `json:"id"`
        Title  string `json:"title"`
        Status string `json:"status"`
}

var DB *sql.DB

In Go we can use structures* to represent a to-do item in our system. The fields are capitalized to mark them as exported and accessible from outside the package* and we use the json tag to provide metadata* which can be used to convert the entity into JSON when sending data over the network

We’ll then define an InitDatabase function which will open our database.

// service.go

package main

import (
        "database/sql"
        "log"

        _ "github.com/mattn/go-sqlite3"
)

type ToDo struct {
        Id     int    `json:"id"`
        Title  string `json:"title"`
        Status string `json:"status"`
}

var DB *sql.DB

func InitDatabase() {
    var err error

    DB, err = sql.Open("sqlite3", "./awesome.db")
    if err != nil {
        log.Fatal(err)
    }

    _, err = DB.Exec(`
    CREATE TABLE IF NOT EXISTS todos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT,
            status TEXT
    );`)

    if err != nil {
        log.Fatal(err)
    }
}

For SQLite, this is just a file on the disk. Next we’ll create a todos table if it doesn’t exist already.

Then, we can add the Create and Delete methods, and there are a couple of things to note here. First, Go lets you return multiple values from the same function.

// service.go

package main

import (
        "database/sql"
        "log"

        _ "github.com/mattn/go-sqlite3"
)

type ToDo struct { ... }

var DB *sql.DB

func InitDatabase() { ... }

func CreateToDo(title string, status string) (int64, error) {
    result, err := DB.Exec("INSERT INTO todos (title, status) VALUES (?, ?)", title, status)
    if err != nil {
        return 0, err
    }

    id, err := result.LastInsertId()
    if err != nil {
        return 0, err
    }
    return id, nil
}

func DeleteToDo(id int64) error {
    _, err := DB.Exec("DELETE FROM todos WHERE id = ?", id)
    return err
}

Second, Go errors are treated as values. While this makes your error handling a tad more verbose than in other languages, it forces you to actively think about exceptions and build more reliable products.

Finally, the compiler will complain every time declared variables are not used, so you can use the blank identifier to ignore such values.

The ReadToDoList method is a bit more involved, since we need to convert the query response to our structure.

// service.go

package main

import (
        "database/sql"
        "log"

        _ "github.com/mattn/go-sqlite3"
)

type ToDo struct { ... }

var DB *sql.DB

func InitDatabase() { ... }

func CreateToDo(
    title string,
    status string
) (int64, error) { ... }

func DeleteToDo(id int64) error { ... }

func ReadToDoList() []ToDo {
    rows, err := DB.Query("SELECT id, title, status FROM todos")
    if err != nil {
        // Handle error
    }
    defer rows.Close()

    todos := make([]ToDo, 0)
    for rows.Next() {
        var todo ToDo
        err := rows.Scan(&todo.Id, &todo.Title, &todo.Status)
        if err != nil {
            // Handle error
        }
        todos = append(todos, todo)
    }
    if err := rows.Err(); err != nil {
        // Handle error
    }
    return todos
}

We’ll use the make keyword to initialize a slice and use append to add new todos in the existing collection.

Back in the main.go file we’ll first initialize the database, and then defer the closing of the database connection.

// main.go

func main() {
    InitDatabase()
    defer DB.Close()
    ...
}

Closing connections is really important, and the defer key makes sure this will happen once the main() function finishes execution.

Then, we’ll update the GET handler to render and return an HTML page containing all the todo entities, and we’ll define handlers for the POST and DELETE requests, which will simply call the database methods we defined a bit earlier.

// main.go

package main

import (
    "net/http"
    "strconv"

    "github.com/gin-gonic/gin"
)

func main() {
    InitDatabase()
    defer DB.Close()

    e := gin.Default()

    e.LoadHTMLGlob("templates/*")

    e.GET("/", func(c *gin.Context) {
        todos := ReadToDoList()
        c.HTML(http.StatusOK, "index.html", gin.H{"todos": todos})
    })

    e.POST("/todos", func(c *gin.Context) {
        title := c.PostForm("title")
        status := c.PostForm("status")
        id, _ := CreateToDo(title, status)
        c.HTML(http.StatusOK, "task.html", gin.H{
            "title": title,
            "status": status,
            "id": id
        })
    })

    e.DELETE("/todos/:id", func(c *gin.Context) {
        param := c.Param("id")
        id, _ := strconv.ParseInt(param, 10, 64)
        DeleteToDo(id)
        // Handle response, e.g., return a success message
    })

    e.Run(":8080")
}

All these templates are defined in the templates directory, and we’ll jump into index.html to finally add some client interactivity.

HTMX on th Client

I mentioned that standard HTML can interact with the server in a synchronous manner via Links and Forms. So I could define a simple form that will trigger POST requests to our todos endpoint whenever the save button is clicked. This will perform a full page refresh, and we can do better than this.

Enter HTMX. The appeal of the HTMX is that it is a small library that allows you to sprinkle in JavaScript interactivity directly through HTML markup. Consider the following bit of HTML [htmx-demo]. What this markup tells the browser is that when a user clicks on a button, an HTTP post request should be issued, and the content from the response should replace the element with the parent-div id.

In our specific example, we can easily convert the synchronous form post request into an asynchronous AJAX based request.

First we need to add the HTMX script in our page header.

<!-- index.html -->

<head>
  <script
    src="https://unpkg.com/htmx.org@2.0.0"
    integrity="sha384-wS5L5IKJBvK6sPTKa2WZ1"
    crossorigin="anonymous"
  ></script>
</head>
<body>
  <h1>Tasks</h1>
  <ul id="tasks">
    {{range .todos}}
    <li>
      {{.Title}} - {{.Status}}
      <button hx-delete="/todos/{{.Id}}">Delete</button>
    </li>
    {{else}}
    <li>No tasks found</li>
    {{end}}
  </ul>
  <form hx-post="/todos" hx-target="#tasks" hx-swap="beforeend">
    <input name="title" />
    <input name="status" />
  </form>
  <button>Save</button>
</body>

Then, we can replace the action and method attributes with an hx-post special attribute. The Ajax call will now be dispatched, and we are expecting an HTML snippet displaying the newly added task as the response.

<!-- task.html -->

<li>
  {{.title}} - {{.status}}
  <button hx-delete="/todos/{{.id}}">Delete</button>
</li>

When the response is received we’ll append it to the tasks list.

Using the same approach, we’ll add in a Delete button which will trigger a DELETE http request when clicked.

By the way, if you like this tech stack, but you don’t want to reinvent the wheel, Pocketbase could be a great starter for your next project. This is an open source backend solution which comes with a lot of features out of the box. Let me know in the comments if you are interested in a proper deep dive into pocketbase and check some of my other crash courses if you found this video useful.

Until next time, thank you for reading!