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

256 lines
11 KiB
Markdown

---
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
│ ├── <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.
```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"}}<!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:
```html
{{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
```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=<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:
```css
: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:
```js
// 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.