Files
gomail/.claude/go-web-app-no-deps-SKILL.md
2026-08-09 18:03:09 +01:00

11 KiB

name, description
name description
go-web-app-no-deps 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
│   ├── <domain logic>/  # the actual app logic, no http.* imports here
│   └── render/          # the template Renderer (see below)
├── web/
│   ├── templates/
│   │   ├── base.html    # the ONE layout every page extends
│   │   └── <page>.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.

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.

{{define "base"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}App{{end}}</title>
<link rel="stylesheet" href="/static/css/app.css?v={{.AssetVersion}}">
{{block "head_extra" .}}{{end}}
</head>
<body class="{{block "body_class" .}}{{end}}">
{{block "body" .}}{{end}}
<script src="/static/js/app.js?v={{.AssetVersion}}"></script>
{{block "scripts" .}}{{end}}
</body>
</html>
{{end}}

A page file overrides only the blocks it needs:

{{define "title"}}Sign in{{end}}
{{define "body_class"}}login-page{{end}}
{{define "body"}}
<div class="login-card">
  <h1>Sign in</h1>
  <form id="login-form">...</form>
</div>
{{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

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=<version>) 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 (<script src="https://cdn.tailwindcss.com">) for utility classes, but define the actual palette as CSS custom properties once, referenced from both Tailwind's arbitrary-value syntax and any hand-written CSS — changing the theme later means editing one :root block, not hunting hex codes through every template.

A palette that's worked well across real dark-themed Go apps:

:root{
  --bg:#0d0f14; --surface:#111318; --surface2:#161920; --surface3:#1c1f28;
  --border:#1e2330; --border2:#252a38;
  --text:#dde1ed; --text2:#9aa0b8; --muted:#5a6278;
  --accent:#5b8def; --accent-dim:rgba(91,141,239,.12); --accent-glow:rgba(91,141,239,.2);
  --danger:#ef4444; --success:#22c55e; --warn:#f59e0b;
}
html,body{height:100%;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif}

--surface / --surface2 / --surface3 give you three stacked-panel depths (page background → card → nested card) without inventing new grays each time. --accent-dim / --accent-glow are for hover/active states and focus rings — a translucent version of the accent color reads as "highlighted" without needing a second color.

Common components worth having a convention for from the start: a CSS spinner (border-based, no image/SVG dependency), a toast notification container (position:fixed, stacked, auto-dismiss), and a context-menu pattern (position:fixed, toggled via a .open class) — all three show up in nearly every admin/dashboard/file-manager style app.

JavaScript: vanilla, but watch function scoping

No framework, no bundler — plain <script> tags, functions attached via onclick="..." or addEventListener. The one recurring bug: don't declare a function inside a conditional block if anything outside that block (including inline onclick handlers in the HTML) needs to call it:

// WRONG — startUpload is undefined outside this if-block's scope
if (ALLOW_UPLOAD) {
  function startUpload() { ... }
}

// RIGHT — always declared, gate the *behavior* inside instead
function startUpload() {
  if (!ALLOW_UPLOAD) return;
  ...
}

Fetch calls follow one plain convention: JSON in, JSON out, X-CSRF-Token header on every mutating request (see below), and a shared api() helper that throws on non-2xx so callers can try/catch uniformly instead of checking res.ok everywhere by hand.

Security basics worth building in from the start, not bolting on later

  • CSRF (double-submit cookie): generate a random token (crypto/rand), set it as a SameSite=Strict cookie, and require the same value echoed back in an X-CSRF-Token header on every mutating request; reject if they don't match. No session-store dependency needed.
  • Path traversal: for any handler that takes a path from the client (file browser, static content by name), resolve it with filepath.EvalSymlinks and verify the result still has the intended root directory as a prefix before touching the filesystem.
  • Atomic writes: write to a temp file in the same directory, then os.Rename over the target — never write in place, so a crash mid-write can't corrupt existing content.
  • CSP header: set a Content-Security-Policy on HTML responses even in a no-framework app — it's one header, not a dependency.

What "no third-party dependencies" means in practice

Pure stdlib covers further than people expect for this kind of app: net/http (routing via http.ServeMux — Go 1.22+'s method+pattern matching like mux.HandleFunc("POST /api/users", ...) removes most of the reason people reach for a router library), html/template, encoding/json, database/sql (+ a driver — mattn/go-sqlite3 or similar is the one common exception, since Go has no built-in SQL driver), crypto/rand for tokens, embed for shipping assets in the binary. Reach for a third-party package only when the standard library has no answer at all (a specific DB driver, CGO-only bindings) — not for convenience on things stdlib already does.