256 lines
11 KiB
Markdown
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.
|