Most forum tutorials reach for a framework and a hosted database on day one. Working on a four-person team building a forum from scratch in Go with no framework, SQLite as the only datastore, and server-side rendering instead of a JavaScript frontend, taught me more about what a web framework is actually doing for you than any amount of reading framework documentation ever did. This article walks through the real architectural decisions and problems that come up when you build this stack directly on top of Go's standard library.
Why no framework, and why SQLite
The constraint was deliberate: standard library net/http for routing and handlers, html/template for rendering, and SQLite as the only database, all wrapped in Docker for a reproducible environment across four different development machines. No Gin, no Echo, no ORM.
This sounds like unnecessary hardship until you actually do it, at which point it becomes clear how much conceptual clarity you get in exchange. Every request handler, every SQL query, every session check is something you wrote and can trace, which matters enormously when four people are debugging the same codebase and nobody wants to also be debugging an unfamiliar framework's internals at the same time.
SQLite specifically is a better fit for this kind of project than people expect. It is a single file, it needs no separate server process, it supports concurrent reads without configuration, and it is genuinely fast enough for a forum's read-heavy workload. The one thing you have to be deliberate about is write concurrency, which I will come back to.
Project structure and team ownership
With four people working in one repository, structure is not a nicety, it is what prevents constant merge conflicts. We split ownership along clear boundaries: one person owned the Docker setup and README, others owned authentication, post/thread handling, and the templating layer respectively. That division only works if the package structure actually enforces it.
cmd/
server/
main.go
internal/
auth/
session.go
handlers.go
internal/
forum/
thread.go
post.go
handlers.go
internal/
db/
schema.sql
queries.go
web/
templates/
static/
Dockerfile
docker-compose.yml
README.md
Keeping db as its own package with a single queries.go file that every other package imports through, rather than letting auth and forum each open their own connections and write their own SQL, turned out to be the single decision that saved the most integration pain. It meant schema changes had one place to happen, and it meant the SQLite write-concurrency issue described below only needed to be solved once.
Schema-first with plain SQL
No ORM means the schema is the actual source of truth, hand-written and versioned:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL REFERENCES threads(id),
user_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at DATETIME NOT NULL
);
Queries are written as plain SQL strings paired with typed Go functions, using database/sql with the mattn/go-sqlite3 driver:
func GetThreadWithPosts(db *sql.DB, threadID int64) (*Thread, []Post, error) {
var t Thread
err := db.QueryRow(
`SELECT id, user_id, title, created_at FROM threads WHERE id = ?`,
threadID,
).Scan(&t.ID, &t.UserID, &t.Title, &t.CreatedAt)
if err != nil {
return nil, nil, err
}
rows, err := db.Query(
`SELECT id, user_id, body, created_at FROM posts WHERE thread_id = ? ORDER BY created_at ASC`,
threadID,
)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var posts []Post
for rows.Next() {
var p Post
if err := rows.Scan(&p.ID, &p.UserID, &p.Body, &p.CreatedAt); err != nil {
return nil, nil, err
}
posts = append(posts, p)
}
return &t, posts, rows.Err()
}
Writing SQL by hand instead of through an ORM makes N+1 query problems visible immediately, because you feel every extra round trip you write. The thread-and-posts fetch above is intentionally two queries rather than a join, because SQLite handles this pattern well and it keeps the Go-side struct mapping simple, but it is a decision made consciously rather than one an ORM made invisibly on your behalf.
The SQLite write-concurrency problem
SQLite handles concurrent reads without issue, but by default only one write can happen at a time across the entire database file, and a second writer arriving while one is in progress gets a database is locked error rather than waiting. On a forum, where posting is the single most common write action and four people are hammering the same dev database while testing, this surfaces almost immediately.
The fix has two parts. First, enable write-ahead logging, which lets reads proceed concurrently with a single writer instead of blocking on it:
db.Exec("PRAGMA journal_mode=WAL;")
Second, set a busy timeout so that a write which arrives while another write is in progress waits and retries instead of failing immediately:
db.Exec("PRAGMA busy_timeout=5000;")
Both of these are one-line fixes, but only if you know to look for them. Without them, the first real concurrent-write test looks like a serious database bug rather than a two-line configuration gap, and it is exactly the kind of thing that eats an afternoon of debugging effort chasing the wrong cause.
Authentication with plain sessions
No JWT library, no third-party auth service. Sessions are random tokens stored in the sessions table and set as an HTTP-only cookie:
func CreateSession(db *sql.DB, userID int64) (string, error) {
token := generateSecureToken(32)
expires := time.Now().Add(24 * time.Hour)
_, err := db.Exec(
`INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)`,
token, userID, expires,
)
return token, err
}
func SetSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: "/",
Expires: time.Now().Add(24 * time.Hour),
})
}
HttpOnly prevents JavaScript from reading the cookie, which matters for a forum where user-submitted content eventually ends up rendered on the page and you want session theft via a missed escaping bug to be as hard as possible even as a fallback layer. SameSiteStrictMode cuts down on cross-site request forgery exposure for a login-gated app like this.
Middleware checks the session on every protected route:
func RequireAuth(db *sql.DB, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
var userID int64
var expiresAt time.Time
err = db.QueryRow(
`SELECT user_id, expires_at FROM sessions WHERE id = ?`,
cookie.Value,
).Scan(&userID, &expiresAt)
if err != nil || time.Now().After(expiresAt) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := context.WithValue(r.Context(), userContextKey, userID)
next(w, r.WithContext(ctx))
}
}
Server-side rendering with html/template, and why escaping matters here specifically
html/template, not text/template, is the entire reason server-rendered user content is safe by default in Go. It automatically contextually escapes output based on where in the HTML it lands, so a post body containing <script> tags gets rendered as inert text rather than executed. This one package-name difference is doing a substantial amount of the security work that a framework would otherwise be doing for you, and it is easy to not appreciate how much until you deliberately try text/template on user content and watch it happily inject raw HTML.
var tmpl = template.Must(template.ParseFiles(
"web/templates/layout.html",
"web/templates/thread.html",
))
func ThreadHandler(w http.ResponseWriter, r *http.Request) {
threadID, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
thread, posts, err := forum.GetThreadWithPosts(db, threadID)
if err != nil {
http.Error(w, "thread not found", http.StatusNotFound)
return
}
data := struct {
Thread *forum.Thread
Posts []forum.Post
}{thread, posts}
tmpl.ExecuteTemplate(w, "layout.html", data)
}
Dockerizing a CGo-dependent binary
mattn/go-sqlite3 uses CGo, which is the detail that trips people up when they try to build a minimal container the way they would for a pure-Go binary. A standard scratch-based multi-stage build fails because CGo binaries need libc at runtime, which scratch does not provide.
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o forum ./cmd/server
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/forum .
COPY --from=builder /app/web ./web
COPY --from=builder /app/internal/db/schema.sql ./internal/db/schema.sql
VOLUME /app/data
EXPOSE 8080
CMD ["./forum"]
debian:bookworm-slim as the runtime base instead of scratch or alpine is the fix: it has the glibc that the CGo-linked SQLite driver needs, at a much smaller footprint than the full golang build image. The VOLUME directive for the SQLite file matters too, since without it the database resets every time the container is recreated, which is exactly the kind of thing that looks like a data-loss bug during development but is actually just default container filesystem behavior doing what it always does.
What this stack teaches you that a framework hides
Building this without a framework surfaces decisions that are normally made invisibly on your behalf: how sessions are stored and validated, how a database driver's linking model interacts with your container's base image, how a templating engine's escaping behavior is the actual security boundary between user input and rendered HTML. None of these are exotic problems. They are the ordinary, load-bearing decisions that a framework typically makes for you by default, and building without one is less about reinventing wheels and more about finally seeing the wheels that were always there.
Top comments (1)
Frameworks don’t just save you from writing code—they also encode years of production lessons.
I enjoyed the article because it explains what Go’s standard library is doing instead of hiding it behind abstractions. That’s valuable.
A few production considerations I’d add:
Understanding these implementation details is exactly what makes working without a framework educational. Even if you later adopt Gin, Echo, Chi, or another framework, you’ll understand why those abstractions exist instead of simply relying on them.
Nice write-up.