Building a Modern Website with Go, HTMX, and Templ

Tags: web,go,htmx

Building a Modern Website with Go, HTMX, and Templ

In this article, we'll explore how to build a modern, dynamic website using Go, HTMX, and Templ. We will also setup Server-Sent Events(SSE) for real-time data streaming.
This stack combines the performance and simplicity of Go with the power of server-side rendering and modern frontend interactivity.

Following this guide you will end up with a website with this stack that displays real-time data:

Screenshot 2

The Stack

  • Go: For when Python is too slow and Rust is too scary
  • HTMX: Because 2025 is the year of hypermedia
  • Templ: HTML templates that won't let you shoot yourself in the foot (much)
  • Tailwind CSS: For developers who love typing class names more than writing CSS

Project Structure

The program follows this structure:

server/
├── handlers/
│   └── usageSSE.go         # SSE event handler
├── view/
│   ├── components/
│   │   └── usages.templ    # System metrics components
│   ├── layout/
│   │   └── base.templ      # Base layout template
│   └── index.templ         # Main page template
├── static/
│   └── css/
│       ├── src.css         # Tailwind source
│       └── tailwind.css    # Generated styles
├── main.go                 # Server entry point
├── package.json            # Node.js dev dependencies
└── tailwind.config.js      # Tailwind configuration

Setup

1. Project Initialization

First, let's create the project directory and initialize a Go project:

mkdir server
cd server
go mod init server

Install required Go dependencies:

go get github.com/a-h/templ@latest
go install github.com/a-h/templ/cmd/templ@latest

2. Tailwind CSS Setup

Initialize Node.js project and install Tailwind:

npm init -y
npm install --save-dev tailwindcss @tailwindcss/cli

Create tailwind.config.js:

// server/tailwind.config.js

export default {
  content: ["./view/**/*.templ"],
  theme: {
    extend: {},
  },
  plugins: [],
}

Create static/css/src.css:

// server/static/css/src.css

@import "tailwindcss"

Update package.json to add the build scripts:

{
  "name": "server",
  "version": "0.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "npx @tailwindcss/cli -i ./static/css/src.css -o ./static/css/tailwind.css",
    "watch": "npx @tailwindcss/cli -i ./static/css/src.css -o ./static/css/tailwind.css --watch"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "tailwindcss": "^4.0.3",
    "@tailwindcss/cli": "^4.0.3"
  }
}

3. Basic Server Setup

Create view/layout/base.templ:

// view/layout/base.templ
package layout

templ Base(children ...templ.Component) {
    <!DOCTYPE html>
    <html lang="en">
        <head>
            <meta charset="UTF-8"/>
            <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
            <title>CEO of HTMX</title>
            <link href="/static/css/tailwind.css" rel="stylesheet"/>
        </head>
        <body class="bg-black text-green-400">
            for _, child := range children {
                @child
            }
        </body>
    </html>
}

Create view/index.templ

// view/index.templ
package view

templ Index() {
    <div class="container mx-auto px-4 py-8">
        <header class="mb-8">
            <h1 class="text-4xl font-bold text-center">System Monitor</h1>
        </header>
        <main>
            <div class="max-w-4xl mx-auto">
                Coming Soon
            </div>
        </main>
    </div>
}

To use these templates, first we need to generate them.
In project root, run:

templ generate

Create main.go:

package main

import (
    "log"
    "net/http"
    "server/view"
    "server/view/layout"
    "github.com/a-h/templ"
)

func main() {
    // Serve static files
    fs := http.FileServer(http.Dir("./static"))
    http.Handle("/static/", http.StripPrefix("/static/", fs))

    // Serve main page with a templ component
    http.Handle("/", templ.Handler(layout.Base(view.Index())))

    log.Fatal(http.ListenAndServe(":8080", nil))
}

Now we have a Go server that uses Templ to generate HTML server-side and uses tailwind for styles.

To run the server, let's generate tailwind classes and start the server;

npm run build && go run main.go

If you navigate to localhost:8080 you will see this page.

Screenshot 1

HTMX and Real-Time Updates

Now let's add HTMX and SSE to the mix.

1. Adding HTMX Scripts

Update view/layout/base.templ to include HTMX and its SSE extension:

// view/layout/base.templ
package layout

templ Base(children ...templ.Component) {
    <!DOCTYPE html>
    <html lang="en">
        <head>
            <meta charset="UTF-8"/>
            <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
            <title>CEO of HTMX</title>
            <link href="/static/css/tailwind.css" rel="stylesheet"/>

            <!-- ADD THESE -->
            <script src="https://unpkg.com/htmx.org@1.9.10"></script>
            <script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/sse.js"></script>
        </head>
        <body class="bg-black text-green-400">
            for _, child := range children {
                @child
            }
        </body>
    </html>
}

2. Creating Real-Time Components

Create view/components/usages.templ

// view/components/usages.templ
package components

templ Usages() {
	<div
		hx-ext="sse"
		sse-connect="/usage"
		class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6"
	>
		<div class="bg-gray-900 p-4 border-2 border-green-400">
			<h3 class="text-2xl mb-2">CPU Usage: <span sse-swap="cpu"></span></h3>
		</div>
		<div class="bg-gray-900 p-4 border-2 border-green-400">
			<h3 class="text-2xl mb-2">Memory Usage: <span sse-swap="ram"></span></h3>
		</div>
		<div class="bg-gray-900 p-4 border-2 border-green-400">
			<h3 class="text-2xl mb-2">Disk Usage: <span sse-swap="disk"></span></h3>
		</div>
	</div>
}

Update view/index.templ to include the new Usages component:

// view/index.templ
package view

import "server/view/components"

templ Index() {
	<div class="container mx-auto px-4 py-8">
		<header class="mb-8">
			<h1 class="text-4xl font-bold text-center">System Monitor</h1>
		</header>
		<main>
			<div class="max-w-4xl mx-auto">
                <!-- Replace "Coming Soon" with this -->
				@components.Usages()
			</div>
		</main>
	</div>
}

3. Server-Sent Events Setup

Create handlers/usageSSE.go:

// handlers/usageSSE.go
package handlers

import (
    "fmt"
    "net/http"
    "time"
    "math/rand" // For demo purposes
)

func UsageSSE(w http.ResponseWriter, r *http.Request) {
    // Set SSE headers
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("Access-Control-Allow-Origin", "*")

    for {
        select {
        case <-r.Context().Done():
            return
        default:
            // Simulated metrics (replace with real metrics)
            cpu := rand.Intn(100)
            ram := rand.Intn(100)
            disk := rand.Intn(100)

            // Send events for each metric
            fmt.Fprintf(w, "event: cpu\ndata: %d%%\n\n", cpu)
            fmt.Fprintf(w, "event: ram\ndata: %d%%\n\n", ram)
            fmt.Fprintf(w, "event: disk\ndata: %d%%\n\n", disk)

            w.(http.Flusher).Flush()
            time.Sleep(1 * time.Second)
        }
    }
}

4. Adding SSE Route

Update main.go to include the SSE endpoint:

// main.go
package main

import (
    "log"
    "net/http"
    "server/view"
	"server/handlers"
    "server/view/layout"
    "github.com/a-h/templ"
)

func main() {
    fs := http.FileServer(http.Dir("./static"))
    http.Handle("/static/", http.StripPrefix("/static/", fs))

    // Add SSE endpoint
    http.HandleFunc("/usage", handlers.UsageSSE)
    http.Handle("/", templ.Handler(layout.Base(view.Index())))

    log.Fatal(http.ListenAndServe(":8080", nil))
}

Now, when you run the server and visit the page, you'll see real-time updates of system metrics without any page refreshes
or complex JavaScript. HTMX handles the SSE connection and DOM updates automatically, while Go efficiently streams the data.

templ generate && npm run build && go run main.go

Screenshot 2

Extras

Now, this is all cool and stuff, but in my current workflow I use some additions for this setup.

1. air Live Reload Server

Running templ generate && npm run build && go run main.go is tiring and not something you should do after every single change.

To fix that let's install and configure air to handle that for us:

go install github.com/air-verse/air@latest

Create an .air.toml file with this config:

# file: server/.air.toml

root = "."
tmp_dir = "bin"

[build]
  bin = "./bin/main"
  cmd = "templ generate && npm run build && go build main.go -o ./bin/main ."
  delay = 500
  exclude_dir = ["static", "node_modules"]
  exclude_regex = [".*_templ.go"]
  exclude_unchanged = false
  follow_symlink = false
  include_ext = ["go", "tpl", "tmpl", "templ", "html"]
  kill_delay = "0s"
  log = "build-errors.log"
  send_interrupt = false
  stop_on_error = true

[color]
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  time = false

[misc]
  clean_on_exit = true

[screen]
  clear_on_rebuild = true
  keep_scroll = true

# Enable live-reloading on the browser.
[proxy]
  enabled = true
  app_port = 8080
  proxy_port = 8090

Now in the server directory you can run air and it will setup a live-reload server alongside with a proxy listening on localhost:8090
that will automatically detect changes in your server and run the build commands and reload the proxied server.

2. Sending Partial HTMLs

When using HTMX we need to be able send some parts as html.

2.1 templ.Handler

We have already seen the templ.Handler() function from the a-h/templ library.
It receives a templ.Component and returns an http.Handler interface so you can use it to handle various requests:

Let's say we have an html element that uses hx-get to swap itself with the server response:

<button hx-get="/foo" hx-swap="outerHTML" class="btn bg-teal-200 p-4 rounded-lg cursor-pointer">
    Foo
</button>

We can define a templ component Bar:

package partials

templ Bar() {
    <div>
        You Clicked on Foo
    </div>
}

And then handle this interaction in our router:

http.Handle("/foo", templ.Handler(partials.Bar()))

2.2 Rendering Comonents to strings

We sometimes may need to use just the string content of the final templ component.
For example you want to send a new partial html as a part of your Server-Sent Event

The SSE has the following structure:

event: event-name
data: any-data

Now, to replace the data in the msg with a partial component, we need to turn it into a string and interpolate it.
templ.Component has a Render(ctx context.Context, w io.Writer) Method, so we can just:

var buf strings.Builder
_ = partials.Bar().Render(context.Background(), &buf)

sseMsg := fmt.Sprintf("event: %s\ndata: %s\n\n", "event-name", buf.String())

the sseMsg now has the partial component embedded in its data field.

References

Check out hybr. Where I use this stack to build a self-hosted service management platform.