Introduction
If you are a Go developer, you have probably felt the gravitational pull of the JavaScript ecosystem. You want to build a web application, and suddenly you are reading about React, Next.js, Vite, TypeScript, state management libraries, client-side routers, and build pipelines that have more configuration files than your entire Go project. You just wanted to show some data on a page.
There is another way.
HTMX is a small JavaScript library that lets you build dynamic, interactive web applications by extending HTML with a handful of attributes. Instead of writing JavaScript to fetch data and manipulate the DOM, you add hx-get, hx-post, hx-swap, and hx-trigger to your HTML elements, and the server returns HTML fragments that HTMX swaps into the page. There is no need for a separate project, your Go server renders the HTML, and HTMX makes it interactive.
This is not a new idea. The web was designed around hypermedia, servers returning documents that browsers render. HTMX simply extends that model to handle partial updates, so you get the interactivity of a single page application without abandoning the server rendered architecture that Go excels at.
In this blog series, we will learn HTMX by building a text adventure game.
The Adventure
When I was in college, I had to take a class in Prolog. If you have never heard of Prolog, it is a logic programming language used for building expert systems. I remember my first encounter with it. For a “logic” programming language, it felt like the least logical thing I had ever experienced. The syntax made no sense. The execution model was alien. I was crushed that it did not click for me instantly.
So I decided to spend my entire weekend figuring it out. I went to the library and was fortunate to find Adventures in Prolog by Dennis Merritt. The author didn’t explain the language by walking through syntax rules and abstract examples. Instead, he built a text adventure game. Piece by piece, room by room, he introduced simple concepts, built on them, and layered in more complex ideas. By the time you had a working game, you understood Prolog.
That was the moment I not only learned Prolog quickly, but also discovered something fundamental about how I learn. Learning is infinitely easier when you are building something, especially something that is fun. When you have a goal, a tangible thing you are constructing, every new concept has a reason to exist. You do not learn about state management in the abstract. You learn it because your adventurer needs to remember which rooms they have visited.
That experience has stayed with me for decades. And when I sat down to write a blog about building web frontends with Go and HTMX, I knew exactly what shape it needed to take.
We will start with a blank Go file and end with a fully functional game: a retro terminal UI with text, a verb-noun command parser, a live dungeon map with a patrolling goblin, an equipment system, clickable items and exits, GitHub authentication, and persistent state in a SQL database.
Along the way, you will learn every major HTMX pattern.
What is HTMX?
HTML is good at two things: displaying documents and navigating between them. You click a link, the browser loads a new page. You submit a form, the browser loads a new page. Every interaction follows the same cycle, request a whole document, throw away the old one, render the new one from scratch.
This approach was fine when the web consisted of static documents, but it breaks down when the web functions as an application. Reloading an entire page, and causing a white flash between screens, simply because a checkbox was toggled is highly inefficient and bad user experience. Instead, the goal is to update a single component of the page while leaving the rest of the interface untouched. Because HTML lacks a built-in mechanism for a partial updates, developer must rely on JavaScript. Once JavaScript is introduced to handle these updates, it also expands into managing application state, manipulation the DOM, and reconciling the gap between server-side data and client-side rendering. This exact friction point is where frameworks like React, Vue, and Angular operate.
HTMX takes a different angle. Instead of giving JavaScript more power to manage the page, it gives HTML more power to talk to the server. Any element can make any type of HTTP request. The server responds with a fragment of HTML, instead of a whole page or JSON payload, and HTMX swaps that fragment into the right place. The update cycle stays the same (request, response, render), it just gets more precise.
Two Ways to Build a Web App
There are two dominant architectures for web applications.
The SPA approach loads a JavaScript application into the browser. That application takes over. It manages its own state, fetches JSON from an API, transforms that JSON into DOM elements, and handles routing, caching, and error recovery on the client. Go server becomes a data pipeline. It serializes structs to JSON and hopes the frontend knows what to do with them. This means building two applications. A Go API service and a JavaScript frontend. Two build systems. Two deployment targets. Two set of bugs. React, Vue, and Angular all follow this model.
The hypermedia approach is what the web was built on. The server is the application. It holds the state, makes the decisions, and renders the UI as HTML. The browser’s job is to display what it receives and let the user click links and submit forms. This was the only architecture for the first fifteen years of the web. It fell out of fashion not because it was wrong, but because plain HTML could only do full page navigation. If you wanted to update one <div> without reloading the entire page, you had to write JavaScript. So people wrote a lot of JavaScript. And then they wrote frameworks to manage all that JavaScript. And here we are.
HTMX gives the hypermedia model the one thing it was missing, partial page updates, without dragging in the rest of the SPA machinery. HTMX handles the plumbing between the server’s response and the browser’s DOM.
What We Are Building
In this series of articles, we’re going to learn how to build web applications with Go and HTMX by doing something considerably more fun than building a todo app. We’re building a text adventure game.
In case you missed the 1980s or were too busy playing games that had graphics, a text adventure is a game where the entire world exists as words on a screen. The computer describes where you are. “You are standing in a dimly lit hallway.” You type what you want to do. go north. take lamp. fight goblin. The computer tells you what happens next. They were the open world games of their era, except the world renderer was your brain.
These games were also brilliant software. Behind that simple text interface sits a state machine, a command parser, an inventory system, a world graph, and an event loop. In other words, exactly the kind of problems web applications solve every day, just dressed up in torchlight and dungeon corridors instead of dashboards and admin panels.
But first, we need to start with the basics.
The Server & Rendering
In this article, we establish the foundation of our game engine. Before we can apply HTMX patterns, we need a functioning server-side application.
Our goal is to build the Core Request/Response Loop:
- The client requests a location (e.g.,
/room/hallway). - The server retrieves the game state.
- The server renders that state into HTML using Go templates.
- The client displays the new page.
By the end of this chapter, you will have a working (albeit very small) text adventure engine running on localhost.
Prerequisites
You will need standard Go environment:
- Go 1.22+ (We’ll use standard library routing).
- Air (Optional but recommended for hot-reloading templates).
Project Initialization
Let’s set up the project structure. We’ll keep it flat for now and build on top of it in later chapters.
mkdir adventures-in-htmx-and-go
cd adventures-in-htmx-and-go
go mod init example.com/adv-htmx
The View Layer
In a Go + HTMX stack, we don’t build a separate frontend application that talks to an API. Instead, the server generates the final HTML directly. This means our “View” is just a standard Go HTML template.
We want the server to be the single source of truth. If the server thinks you are in a dungeon, it renders a dungeon. The browser’s only job is to display what it is told.
Create a templates directory and add a file named room.html.
templates/room.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Adventures in HTMX and Go</title>
<style>
body { font-family: sans-serif; max-width: 700px; margin: 2rem auto; padding: 1rem; }
.room { border: 2px solid #333; padding: 20px; border-radius: 8px; }
.exits { list-style: none; padding: 0; }
.exits li { margin: 10px 0; }
a { background: #eee; text-decoration: none; padding: 5px 10px; border: 1px solid #ccc; color: black; }
a:hover { background: #ddd; }
</style>
</head>
<body>
<div class="room">
<h1>{{.Room.Name}}</h1>
<p>{{.Room.Description}}</p>
<h2>Exits</h2>
<ul class="exits">
{{range .Room.Exits}}
<li>
<a href="/room/{{.To}}">{{.Label}}</a>
</li>
{{else}}
<li>(There are no exits. You are trapped.)</li>
{{end}}
</ul>
</div>
</body>
</html>
Breaking down the Template
If you are coming from React or Vue, this might look primitive but the simplicity is the point.
{{.Room.Name}}: We are injecting data straight from our Go struct. There is no “state” to manage on the client.{{range...}}: We use Go’s standard control structures to loop over the exits.<a href="...">: This is the most critical part. We aren’t using a<div>with anonclickevent. We are using a standard HTML anchor tag.
The Engine
Now that we have web page (the template), we need a brain.
We are going to implement a basic web server. For now we will implement a basic in-memory “database” and a handler to serve our room template.
Create main.go in your project root.
Domain Models
First, we need to define the “physics” of our universe. What is a Room? What is an Exit?
Add this to main.go:
package main
import (
"html/template"
"log"
"net/http"
"strings"
)
// Exit represents a navigable path.
type Exit struct {
Label string
To string
}
// Room represents a node in our graph.
type Room struct {
ID string
Name string
Description string
Exits []Exit
}
// RoomPageData acts as the ViewModel passed to the template.
type RoomPageData struct {
Room Room
}
The Server Implementation
Now for the wiring. We need to:
- Load the world data.
- Prepare the templates.
- Tell the server how to handle requests.
Add the main function:
func main() {
// In a real app, this would be a Repository connecting to a DB.
rooms := map[string]Room{
"hallway": {
ID: "hallway",
Name: "The Hallway",
Description: "You are standing in a dimly lit hallway. Dust motes dance in the air.",
Exits: []Exit{
{Label: "Go North", To: "atrium"},
},
},
"atrium": {
ID: "atrium",
Name: "The Atrium",
Description: "You step into a grand atrium. The ceiling is glass, revealing a grey sky.",
Exits: []Exit{
{Label: "Go South", To: "hallway"},
},
},
}
// We parse once at startup for performance and to catch syntax errors early.
tmpl := template.Must(template.ParseFiles("templates/room.html"))
mux := http.NewServeMux()
// Redirect root to the starting area
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/room/hallway", http.StatusFound)
})
// The Room Handler
// Serves the state of a specific room based on URL path.
mux.HandleFunc("GET /room/{id}", func(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
room, exists := rooms[roomID]
if !exists {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html")
// Inject the Go struct into the HTML template
if err := tmpl.Execute(w, RoomPageData{Room: room}); err != nil {
log.Printf("Template execution error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
})
// Start Server
addr := ":4040"
log.Printf("Server starting on http://localhost%s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatal(err)
}
}
Run The Game
Run the server:
go run .
Navigate to http://localhost:4040. You should be immediately redirected to /room/hallway and see the “Hallway” description. Clicking “Go North” should trigger a full page reload and take you to /room/atrium.
This confirms our state management (the map) and our view layer (the template) are correctly wired up.
Developer Experience - Air
Working with templates in Go can be tedious if you have to manually restart the server to see HTML changes. We highly recommend running this project using Air.
Run this to install it:
go install github.com/air-verse/air@latest
Initialize the config:
air init
Run the dev server:
air
Now, any changes to main.go or templates/room/html will trigger an automatic rebuild and restart.
Summary
Currently, every action performs a “hard” navigation, the browser destroys the current DOM, downloads the new HTML, and renders it from scratch.
In the next part of this series, we’ll introduce HTMX to intercept these requests. We will upgrade this hard navigation into a smooth, app like transition without writing a single line of custom JavaScript or changing our server logic.
Code
You can find full code on GitHub.