--- name: go-web-app-no-deps description: Build a server-rendered web application in Go using ONLY the standard library (net/http, html/template, database/sql, embed) — no web framework (no Gin/Echo/Fiber/gorilla), no template engine beyond stdlib html/template, no third-party JS framework. Uses a Flask/Jinja2-style template inheritance pattern (a base.html layout with named blocks that page templates override) and a dark Tailwind CSS theme with a defined CSS custom-property palette. Use this whenever the user asks for a Go web app, admin panel, dashboard, file manager, webmail client, or any browser-based tool built in Go without third-party dependencies, or mentions wanting "Flask-like templates in Go" or a dark Tailwind theme for a Go backend. Also use when debugging a Go html/template project where blocks from different pages are bleeding into each other, or where static assets aren't updating in the browser after a change. --- # Go Web App, No Third-Party Dependencies Two real projects converged on this exact pattern: a multi-account webmail client and a browser-based file explorer, both pure Go + stdlib `html/template` + vanilla JS + Tailwind CSS dark theme. The patterns below are what actually worked after hitting real bugs — not a theoretical "here's how you'd probably do it." ## Project layout ``` project/ ├── cmd/appname/main.go ├── internal/ │ ├── handlers/ # HTTP handlers, one file per feature area │ ├── / # the actual app logic, no http.* imports here │ └── render/ # the template Renderer (see below) ├── web/ │ ├── templates/ │ │ ├── base.html # the ONE layout every page extends │ │ └── .html # one file per page (login.html, app.html, admin.html...) │ └── static/ │ ├── css/appname.css │ └── js/appname.js └── go.mod ``` Embed `web/` into the binary with `//go:embed web/templates web/static` on a package-level `embed.FS` var — ships as one binary, no separate asset deploy step. ## The template renderer — get this right first, it's where the real bug lives **The wrong way (looks fine until you have 2+ pages):** parsing every template file together with `template.ParseGlob("web/templates/*.html")` into one shared template set. Every page file typically defines a block named the same thing (`{{define "content"}}` or similar) to plug into the layout. When they're all parsed into one shared namespace, **the last one parsed silently wins for every page** — page A renders page B's content, or a block from one page bleeds into another. This is subtle: it can look correct for a single page and only breaks once a second page exists. **The right way:** parse the base layout + exactly one page file into a **fresh template instance per page**, so no other page's `{{define}}` blocks are ever in the same namespace. ```go package render import ( "bytes" "embed" "fmt" "html/template" "net/http" ) type Renderer struct { templates map[string]*template.Template } // pages lists every page template that extends base.html. var pages = []string{"login.html", "app.html", "admin.html"} func New(fsys embed.FS) (*Renderer, error) { r := &Renderer{templates: make(map[string]*template.Template, len(pages))} for _, page := range pages { // Fresh template.New("base") EACH iteration — this is the whole fix. t, err := template.New("base").ParseFS(fsys, "web/templates/base.html", "web/templates/"+page) if err != nil { return nil, fmt.Errorf("parsing %s: %w", page, err) } name := page[:len(page)-len(".html")] r.templates[name] = t } return r, nil } // Render executes "base" (the layout), which pulls in this page's block // overrides — rendered to a buffer first so a mid-execution template error // never sends a half-written response to the client. func (r *Renderer) Render(w http.ResponseWriter, status int, name string, data any) { t, ok := r.templates[name] if !ok { http.Error(w, "unknown page: "+name, http.StatusInternalServerError) return } var buf bytes.Buffer if err := t.ExecuteTemplate(&buf, "base", data); err != nil { http.Error(w, "render error: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) buf.WriteTo(w) } ``` Construct one `*Renderer` at startup (fails fast on any template syntax error) and pass it to every handler — don't reparse per-request. ## base.html — the Flask-`{% block %}` equivalent Go's `html/template` has `{{block "name" .}}...default...{{end}}` (defines a block with a default body, overridable) and `{{define "name"}}` (overrides it from another file) — this is structurally the same idea as Jinja2's `{% block %}` / `{% extends %}` + child-template overrides, just different syntax. ```html {{define "base"}} {{block "title" .}}App{{end}} {{block "head_extra" .}}{{end}} {{block "body" .}}{{end}} {{block "scripts" .}}{{end}} {{end}} ``` A page file overrides only the blocks it needs: ```html {{define "title"}}Sign in{{end}} {{define "body_class"}}login-page{{end}} {{define "body"}} {{end}} ``` `.AssetVersion` (a build timestamp or git short-hash passed into every `Render` call's data) matters more than it looks — see cache-busting below. ## Static assets: embed + version query param, always ```go staticFS, _ := fs.Sub(webFS, "web/static") mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) ``` Browsers aggressively cache `/static/*` files. During active development this reliably causes "I edited the CSS/JS and nothing changed" confusion. Fix it at the template level, not by fighting cache headers: put a version string in every static asset URL (`app.css?v=`) and bump it whenever the file changes — a build timestamp baked in at compile time via `-ldflags "-X main.buildVersion=..."` works well and needs no manual bumping. For endpoints returning dynamic content that must never be stale even mid-session (an edited file's raw content, a just-changed API response), also set explicit headers: `Cache-Control: no-cache` for GETs that should always revalidate, `Cache-Control: no-store` for mutating POSTs. ## Dark Tailwind theme — CSS custom properties, not hardcoded hex everywhere Use Tailwind's CDN build (`