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 aSameSite=Strictcookie, and require the same value echoed back in anX-CSRF-Tokenheader 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.EvalSymlinksand 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.Renameover the target — never write in place, so a crash mid-write can't corrupt existing content. - CSP header: set a
Content-Security-Policyon 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.