The GOTH Stack

Updated

July 30, 2026

The GOTH Stack: Go, Templ, HTMX, Tailwind

In 2026, the pendulum has swung back. The complexity of React/Next.js/Hydration/ServerComponents has pushed many developers toward simpler, server-driven architectures.

Enter the GOTH Stack: * Go: Backend logic and router. * O (Templ?): Usually Templ, a type-safe templating language for Go. * Tailwind: Utility-first CSS. * HTMX: High-power tools for HTML.

Why GOTH?

It combines the type safety and speed of Go with the interactivity of an SPA, without the JSON-serialization overhead or state synchronization nightmares (Redux/Zustand).

1. Templ: Type-Safe HTML

Standard Go html/template is loosely typed. If you misspell a variable, it renders nothing. Templ compiles .templ files into Go code.

// hello.templ
package main

templ Hello(name string) {
    <div class="p-4 bg-blue-100 text-blue-800 rounded">
        <h1>Hello, { name }!</h1>
    </div>
}

If you pass an int to Hello, the Go compiler screams. This is revolutionary for backend rendering.

2. HTMX: The Engine

HTMX allows you to swap parts of the page via AJAX attributes directly in HTML.

<!-- When clicked, POST to /clicked, and replace this button with the response -->
<button hx-post="/clicked" hx-swap="outerHTML">
    Click Me
</button>

No JavaScript required. The server returns the new HTML (rendered via Templ), and HTMX injects it.

3. Tailwind: The Style

Since HTMX swaps HTML chunks, scoping CSS classes is hard. Tailwind solves this by embedding styles in the HTML. When HTMX injects a new <div>, it brings its styles with it.

A Simple Handler Example

func Handler(w http.ResponseWriter, r *http.Request) {
    // 1. Logic
    user := db.GetUser()

    // 2. Render Component
    component := components.UserProfile(user)

    // 3. Serve
    component.Render(r.Context(), w)
}

When NOT to use GOTH

  • Offline-first Apps: If you need it to work in a tunnel, you need a heavy client (React/Flutter).
  • Complex Canvas/Games: WebGL implementations.

For 95% of CRUD apps, Dashboards, and SaaS tools, the GOTH stack is faster to build and orders of magnitude faster to run.

Worked example

Server-rendered form post that returns an HTML fragment (HTMX-style).

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "fmt"
    "html/template"
    "io"
    "net/http"
    "net/http/httptest"
    "strings"
)

var formTpl = template.Must(template.New("f").Parse(`
{{define "form"}}<form hx-post="/greet" hx-target="#out">
  <input name="name" value="{{.}}">
  <button type="submit">Greet</button>
</form>{{end}}
{{define "result"}}<p id="out">Hello, {{.}}!</p>{{end}}
`))

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        _ = formTpl.ExecuteTemplate(w, "form", "world")
    })
    mux.HandleFunc("POST /greet", func(w http.ResponseWriter, r *http.Request) {
        _ = r.ParseForm()
        name := r.FormValue("name")
        if name == "" {
            name = "friend"
        }
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        _ = formTpl.ExecuteTemplate(w, "result", name)
    })

    srv := httptest.NewServer(mux)
    defer srv.Close()

    res, _ := http.PostForm(srv.URL+"/greet", map[string][]string{"name": {"Ada"}})
    body, _ := io.ReadAll(res.Body)
    res.Body.Close()
    fmt.Println(strings.TrimSpace(string(body)))
}

Expected output:

<p id="out">Hello, Ada!</p>

More examples

Compose page + partial templates without external Templ.

package main

import (
    "fmt"
    "html/template"
    "strings"
)

func main() {
    t := template.Must(template.New("root").Parse(`
{{define "card"}}<div class="card">{{.}}</div>{{end}}
{{define "page"}}<main>{{template "card" .}}</main>{{end}}
`))
    var b strings.Builder
    _ = t.ExecuteTemplate(&b, "page", "Revenue OK")
    fmt.Println(strings.TrimSpace(b.String()))
}

Expected output:

<main><div class="card">Revenue OK</div></main>

Runnable example

Note: Production GOTH uses Templ, HTMX, and Tailwind. This stdlib-only stand-in shows the same idea: the server returns HTML fragments, and a client can request partial updates.

Save as main.go. Then:

go mod init example
go run .
package main

import (
    "fmt"
    "html/template"
    "io"
    "net/http"
    "net/http/httptest"
    "strings"
    "sync/atomic"
)

var clicks atomic.Int64

// One template set: page embeds the "counter" partial (HTMX-style fragment).
var tpls = template.Must(template.New("root").Parse(`
{{define "counter"}}<button hx-post="/clicked">Clicks: {{.}}</button>{{end}}
{{define "page"}}<!doctype html>
<html><body>
  <h1>GOTH-style (stdlib)</h1>
  <div id="counter">{{template "counter" .}}</div>
</body></html>{{end}}
`))

func main() {
    mux := http.NewServeMux()

    // Full page (initial load)
    mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        _ = tpls.ExecuteTemplate(w, "page", clicks.Load())
    })

    // HTMX-style partial: return only the button HTML
    mux.HandleFunc("POST /clicked", func(w http.ResponseWriter, r *http.Request) {
        clicks.Add(1)
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        _ = tpls.ExecuteTemplate(w, "counter", clicks.Load())
    })

    // Drive requests without binding a port forever
    srv := httptest.NewServer(mux)
    defer srv.Close()

    resp, err := http.Get(srv.URL + "/")
    if err != nil {
        panic(err)
    }
    body, _ := io.ReadAll(resp.Body)
    resp.Body.Close()
    fmt.Println("status:", resp.StatusCode)
    fmt.Println("page contains counter:", strings.Contains(string(body), "Clicks: 0"))

    post, err := http.Post(srv.URL+"/clicked", "text/plain", nil)
    if err != nil {
        panic(err)
    }
    frag, _ := io.ReadAll(post.Body)
    post.Body.Close()
    fmt.Println("fragment:", strings.TrimSpace(string(frag)))
}

Expected output:

status: 200
page contains counter: true
fragment: <button hx-post="/clicked">Clicks: 1</button>

What to notice: Full page vs fragment is just different handlers. httptest.NewServer exercises real HTTP without leaving a process listening. Production Templ/HTMX would swap #counter automatically; here we print the fragment.

Try next: Add a GET /clicked that returns the same fragment, and chain three POSTs to watch the count rise.