---
title: "6 Tips for Building Web Apps with Go"
date: "2026-05-26"
author: "Maykel Farha"
excerpt: "Go is an excellent choice for web applications — efficient, cost-effective, and surprisingly capable out of the box. Here are six tips I learned along the way that will save you time and keep your codebase clean."
slug: "go-web-app-tips"
image: "/blog-images/6-go-web-tips-thumb.webp"
---

# 6 Tips for Building Web Apps with Go

Go is an excellent choice for web applications — efficient, cost-effective, and surprisingly capable out of the box. The stdlib alone gets you surprisingly far, and when you need a little more structure, the ecosystem has solid, stable options that don't fight you.

In this post I'm sharing six practical tips I've learned building Go web apps. Not theory — actual patterns I reach for every time.

---

Check out the video version here:
<iframe width="560" height="315" src="https://www.youtube.com/embed/WJ8_mQpdoe8" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

---

## Tip 1: Use chi as your router

You could use the standard library's `net/http` ServeMux, and for simple projects that's perfectly reasonable. But once you need named path parameters, route grouping, or scoped middleware, you'll want a router — and [chi](https://github.com/go-chi/chi) is the right call.

Chi is lightweight, actively maintained, and 100% compatible with `net/http`. Every handler and middleware you write works with chi without any adaptation. It also ships with a solid set of middlewares out of the box: logger, recoverer, request ID, timeout, and more — things you'd have to write yourself otherwise.

Here's a quick setup:

```go
import (
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

r := chi.NewRouter()

// Global middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)

// Public route
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Welcome to the public page"))
})

// Protected routes — middleware scoped to this group only
r.Group(func(r chi.Router) {
    r.Use(authMiddleware)
    r.Get("/dashboard", dashboardHandler)
})

http.ListenAndServe(":8080", r)
```

The grouping feature is one of chi's best qualities. You apply middleware — like authentication — only to the routes that need it, keeping the rest of your app unaffected.

---

## Tip 2: Use Go templates

Go's `html/template` package is more capable than most people give it credit for. You write standard HTML and replace the dynamic parts with variables, conditionals, and loops — all with built-in XSS protection since the template engine context-escapes everything automatically.

<img src='/blog-images/go_template_variables.webp'>

```html
{{ range .Tips }}
  <div class="tip">
    <h2>{{ .Title }}</h2>
    <p>{{ .Description }}</p>
  </div>
{{ end }}
```

On the Go side, you parse the template once and execute it per request:

```go
type Tip struct {
    Title       string
    Description string
}

type PageData struct {
    Tips []Tip
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
    tmpl := template.Must(template.ParseFiles("templates/home.html"))

    data := PageData{
        Tips: []Tip{
            {Title: "Use chi", Description: "Great router for Go web apps."},
            {Title: "Use templates", Description: "Powerful and built-in."},
        },
    }

    tmpl.Execute(w, data)
}
```

A couple of things worth noting for production: parse your templates once at startup and cache the result — not on every request. And switch from `os.DirFS` (disk reads, useful in development) to `embed.FS` (compiled into the binary) when you deploy. An environment variable toggle makes this easy.

Templates also support `define` and `block` for layout composition, so you can build shared layouts and reuse them across pages without a framework.

---

## Tip 3: Use singleflight to handle concurrent calls

Here's a scenario: your cache expires at an inopportune moment, and you have 1k concurrent requests all asking for the same resource at the same time. Without any protection, that's 1k hits to your database simultaneously — a thundering herd that can bring your app down.

<img src='/blog-images/go_singleflight.webp'>

`golang.org/x/sync/singleflight` solves this elegantly. When multiple requests call the same key at the same time, singleflight executes the function once and shares the result with all callers.

```go
import "golang.org/x/sync/singleflight"

var group singleflight.Group

func getUser(id string) (*User, error) {
    v, err, _ := group.Do("user:"+id, func() (interface{}, error) {
        return db.FetchUser(id) // called once, no matter how many waiters
    })
    return v.(*User), err
}
```

The difference is significant: without singleflight you make N expensive calls, with it you make one and share the result. It's also faster in aggregate since all waiters get the response as soon as the first call finishes.

One gotcha: errors are shared too. If the underlying call fails, all waiters get that same error. Make sure you're not accidentally caching failures downstream.

---

## Tip 4: Middleware

Go's `http.Handler` interface makes middleware composable without any magic — it's just a function that wraps another function. You can chain as many as you need.

Chi already gives you a set of useful middlewares, and you should use them. But it's also worth writing your own at least once — it's around ten lines and demystifies exactly how the chain works.

A common and practical example is authentication middleware:

```go
func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        username, password, ok := r.BasicAuth()
        if !ok || !isValid(username, password) {
            w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}
```

If the auth check passes, it calls `next.ServeHTTP` and the chain continues. If it fails, it returns early — the next handler never runs.

One caution: don't use middleware to pass arbitrary data between layers. It obfuscates the flow of data in your application. Use `context.WithValue` sparingly, and only for things like request IDs or the authenticated user — not general application state.

---

## Tip 5: Dependency injection with fx

As your application grows, you accumulate dependencies: a database connection, a cache, a config struct, multiple services, multiple handlers. Wiring all of these by hand in `main.go` becomes a sprawling mess — and changing one constructor signature means updating it in ten places.

<img src='/blog-images/dependency_injection.webp'>

[uber-go/fx](https://github.com/uber-go/fx) solves this cleanly. You register constructors with `fx.Provide`, and fx builds the dependency graph automatically. Each constructor declares what it needs as parameters, and fx figures out the wiring.

```go
func main() {
    fx.New(
        fx.Provide(
            config.Load,        // Config
            database.New,       // *sql.DB      ← needs Config
            NewTipsService,     // TipsService  ← needs *sql.DB
            NewHomeHandler,     // Handler      ← needs TipsService
            NewRouter,          // chi.Router   ← needs Handler
        ),
        fx.Invoke(StartServer), // ← needs chi.Router + Config
    ).Run()
}
```

fx also has lifecycle hooks, which integrate cleanly with graceful shutdown:

```go
func StartServer(lc fx.Lifecycle, r *chi.Mux, cfg config.Config) {
    srv := &http.Server{Addr: ":" + cfg.Port, Handler: r}
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            go srv.ListenAndServe()
            return nil
        },
        OnStop: func(ctx context.Context) error {
            return srv.Shutdown(ctx)
        },
    })
}
```

The result is a `main.go` that reads like a declaration of what the app needs, not a manual wiring diagram. As you add more handlers, services, and dependencies, the structure stays clean.

One honest trade-off: fx uses reflection under the hood, which can make stack traces harder to read. It's worth the trade-off once you have more than three or four layers. If you prefer compile-time guarantees, [google/wire](https://github.com/google/wire) is the alternative.

---

## Tip 6: Hot reloads with Air

During development, you should not be manually stopping and restarting your server every time you change a file. [Air](https://github.com/air-verse/air) handles this for Go.

You tell Air which files and directories to watch, and it automatically rebuilds and restarts the app when it detects a change. Go files, HTML templates, static assets — whatever matters to you.

Install it and add an `.air.toml` to your project:

```toml
[build]
  cmd = "go build -o ./tmp/main ."
  bin = "./tmp/main"
  include_ext = ["go", "html"]
  exclude_dir = ["tmp", "vendor"]
```

Then run `air` instead of `go run`:

```bash
air
```

One thing to pair with this: if you're caching templates at startup (which you should be in production), add an environment variable to switch between disk reads and embedded files. In development, read from disk so Air picks up template changes without a restart.

```go
var tmpl *template.Template

func init() {
    if os.Getenv("ENV") == "dev" {
        tmpl = template.Must(template.ParseGlob("templates/*.html"))
    } else {
        tmpl = template.Must(template.ParseFS(embeddedFiles, "templates/*.html"))
    }
}
```

The combination of Air and disk-based template reads gives you instant feedback on both Go code changes and template changes — which is exactly what you want when iterating quickly.

---

## nuzur

If you're building a Go web app and want to skip the schema boilerplate entirely, check out [nuzur](https://nuzur.com). You design your data model visually — tables, columns, relationships — and nuzur can generate Go structs, protobuf definitions, repository interfaces and more directly from it. No manual SQL migrations, no copy-pasting schema definitions.

The [MCP connector](https://nuzur.com/blog/nuzur-claude-mcp) also lets your AI assistant query your live schema while you code, so it actually knows your model when you're asking it questions.

---

## Recap

1. **Use chi** — lightweight, idiomatic, great middleware grouping
2. **Use Go templates** — parse once at startup, use `embed.FS` in production
3. **Use singleflight** — prevent thundering herd on cache misses
4. **Write middleware** — composable, explicit, keep `context.WithValue` minimal
5. **Use fx for dependency injection** — clean wiring, built-in lifecycle hooks
6. **Use Air for hot reloads** — never restart your server manually again

Go's simplicity is the feature. These patterns keep that simplicity intact as your app grows.
