first commit
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
---
|
||||
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.
|
||||
Reference in New Issue
Block a user