A WordPress-style blog server in Go — posts, reader comments, admin login, session cookies, slug generation, and a SQLite backend. No frameworks, no CGO, no nonsense.
The CMS is running on this server right now with five sample blog posts and example comments on each post. Click through to see the homepage, read individual posts, and browse the comments section.
The demo shows pre-seeded comments. Comment submission and admin features (login, create, edit, delete) are disabled in the demo. The full source code below shows how everything works.
Public side:
/post/my-post-title — the slug is auto-generated from the title on save.Admin side (login required):
users table with bcrypt-hashed passwords.admin / changeme account automatically.cms.db — a single SQLite file created automatically on first run. Nothing else to set up.net/http — the web server and router. Standard library; no Gin or Chi.html/template — context-aware HTML templating that auto-escapes output to prevent XSS by default.database/sql — Go's standard SQL interface. Works with any driver via a blank import.modernc.org/sqlite — a pure-Go SQLite driver. No gcc required. Imported with _ "modernc.org/sqlite" for the driver registration side-effect only.golang.org/x/crypto/bcrypt — password hashing. One-way, salted, slow by design. The Go standard for storing passwords securely.crypto/rand + encoding/base64 — cryptographically secure session token generation.unicode + regexp — used by slugify() to turn any post title into a clean, hyphenated URL slug.modernc.org/sqlite and golang.org/x/crypto), both vendored into go.sum with go get. Everything else is standard library.Create a directory, initialise a Go module, and pull in the two external packages:
mkdir go-cms && cd go-cms
go mod init go-cms
go get modernc.org/sqlite
go get golang.org/x/crypto/bcrypt
Create main.go, paste the code from the next section, then:
go run . // build and run in one step
// or build a binary:
go build -o cms main.go
./cms
On first run you will see:
Default admin created — login: admin / changeme
GoCMS running on http://localhost:8080
Open http://localhost:8080/admin/login, log in with admin / changeme, and create your first post.
One file: main.go. The HTML templates are embedded as a multi-line backtick string constant — no external template files needed.
package main
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"html/template"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
var db *sql.DB
func initDB() {
var err error
db, err = sql.Open("sqlite", "cms.db")
if err != nil { log.Fatal(err) }
for _, q := range []string{
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
body TEXT NOT NULL,
published INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL REFERENCES posts(id),
author TEXT NOT NULL,
body TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
} {
if _, err := db.Exec(q); err != nil { log.Fatal(err) }
}
var n int
db.QueryRow("SELECT COUNT(*) FROM users").Scan(&n)
if n == 0 {
h, _ := bcrypt.GenerateFromPassword([]byte("changeme"), bcrypt.DefaultCost)
db.Exec("INSERT INTO users (username, password_hash) VALUES (?, ?)",
"admin", string(h))
log.Println("Default admin created — login: admin / changeme")
}
}
type Post struct {
ID int
Title string
Slug string
Body string
Published bool
CreatedAt string
}
func listPosts(publishedOnly bool) ([]Post, error) {
q := "SELECT id, title, slug, body, published, created_at FROM posts"
if publishedOnly { q += " WHERE published = 1" }
q += " ORDER BY created_at DESC"
rows, err := db.Query(q)
if err != nil { return nil, err }
defer rows.Close()
var out []Post
for rows.Next() {
var p Post; var pub int
rows.Scan(&p.ID, &p.Title, &p.Slug, &p.Body, &pub, &p.CreatedAt)
p.Published = pub == 1
out = append(out, p)
}
return out, nil
}
func getPostBySlug(slug string) (Post, error) {
var p Post; var pub int
err := db.QueryRow(
"SELECT id, title, slug, body, published, created_at FROM posts WHERE slug = ? AND published = 1",
slug,
).Scan(&p.ID, &p.Title, &p.Slug, &p.Body, &pub, &p.CreatedAt)
p.Published = pub == 1
return p, err
}
func getPostByID(id int) (Post, error) {
var p Post; var pub int
err := db.QueryRow(
"SELECT id, title, slug, body, published, created_at FROM posts WHERE id = ?", id,
).Scan(&p.ID, &p.Title, &p.Slug, &p.Body, &pub, &p.CreatedAt)
p.Published = pub == 1
return p, err
}
type Comment struct {
ID int
PostID int
Author string
Body string
CreatedAt string
}
func listComments(postID int) ([]Comment, error) {
rows, err := db.Query(
"SELECT id, post_id, author, body, created_at FROM comments WHERE post_id = ? ORDER BY id ASC",
postID,
)
if err != nil { return nil, err }
defer rows.Close()
var out []Comment
for rows.Next() {
var c Comment
rows.Scan(&c.ID, &c.PostID, &c.Author, &c.Body, &c.CreatedAt)
out = append(out, c)
}
return out, nil
}
// slugify turns a title into a URL slug: "Hello, World!" → "hello-world"
func slugify(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
} else if unicode.IsSpace(r) || r == '-' {
b.WriteByte('-')
}
}
return strings.Trim(
regexp.MustCompile(`-+`).ReplaceAllString(b.String(), "-"),
"-",
)
}
func randToken() string {
b := make([]byte, 32)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
func startSession(w http.ResponseWriter, userID int) error {
token := randToken()
exp := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
_, err := db.Exec(
"INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, ?)",
userID, token, exp,
)
if err != nil { return err }
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(24 * time.Hour),
})
return nil
}
// sessionUser looks up the logged-in user from the session cookie.
func sessionUser(r *http.Request) (id int, username string, ok bool) {
c, err := r.Cookie("session")
if err != nil { return }
err = db.QueryRow(`
SELECT u.id, u.username FROM users u
JOIN sessions s ON s.user_id = u.id
WHERE s.token = ? AND s.expires_at > datetime('now')`,
c.Value).Scan(&id, &username)
ok = err == nil
return
}
// requireAuth wraps a handler and redirects unauthenticated users to /admin/login.
func requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, _, ok := sessionUser(r); !ok {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
type Data struct {
User string // logged-in username, or "" for public pages
Posts []Post
Post Post
Comments []Comment
Err string
}
var fm = template.FuncMap{
"nl2br": func(s string) template.HTML {
return template.HTML(
strings.ReplaceAll(template.HTMLEscapeString(s), "\n", "<br>"),
)
},
}
var tmpl = template.Must(template.New("").Funcs(fm).Parse(allTemplates))
func render(w http.ResponseWriter, name string, d Data) {
if err := tmpl.ExecuteTemplate(w, name, d); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
log.Println(err)
}
}
Eight named templates in one string constant. css and nav are shared partials called by every page template via {{template "css" .}}.
const allTemplates = `
{{define "css"}}<style>
*{box-sizing:border-box}
body{font-family:Inter,system-ui,sans-serif;max-width:820px;margin:2rem auto;
padding:0 1.25rem;background:#f0f9ff;color:#1e1e2e}
nav{display:flex;gap:1.5rem;align-items:center;border-bottom:2px solid #bae6fd;
padding-bottom:1rem;margin-bottom:2.5rem}
nav a{text-decoration:none;color:#0891b2;font-weight:600}
nav a:hover{text-decoration:underline}
.brand{font-size:1.15rem;font-weight:800;color:#1e1e2e!important}
.spacer{flex:1}
h1{font-size:2rem;margin:0 0 .4rem}
.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;
padding:1.5rem;margin-bottom:1rem;box-shadow:0 1px 3px rgba(0,0,0,.05)}
.card h2{margin:0 0 .35rem}
.card a{color:#0891b2;text-decoration:none}
.card a:hover{text-decoration:underline}
.meta{font-size:.82rem;color:#9ca3af;margin-bottom:.6rem}
.body{line-height:1.75}
.btn{display:inline-block;padding:.45rem 1.1rem;border-radius:8px;border:none;
cursor:pointer;font-size:.88rem;font-weight:600;text-decoration:none}
.btn-primary{background:#0891b2;color:#fff}
.btn-danger{background:#ef4444;color:#fff}
.btn-sm{padding:.3rem .7rem;font-size:.78rem}
input[type=text],input[type=password],textarea{width:100%;padding:.5rem .75rem;
border:1px solid #d1d5db;border-radius:8px;font-size:.95rem;
margin-bottom:.9rem;background:#fff;font-family:inherit}
textarea{height:14rem;resize:vertical;line-height:1.6}
label{display:block;font-weight:600;margin-bottom:.3rem;color:#374151;font-size:.9rem}
.check-row{display:flex;align-items:center;gap:.5rem;margin-bottom:1rem}
.check-row input{width:auto;margin:0}
table{width:100%;border-collapse:collapse;background:#fff;
border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.05)}
th,td{padding:.75rem 1rem;text-align:left;border-bottom:1px solid #f3f4f6}
th{background:#f0f9ff;font-size:.78rem;text-transform:uppercase;
letter-spacing:.05em;color:#6b7280;font-weight:700}
.badge{padding:.2rem .6rem;border-radius:999px;font-size:.75rem;font-weight:700}
.badge-pub{background:#d1fae5;color:#065f46}
.badge-draft{background:#fef9c3;color:#92400e}
.actions{display:flex;gap:.4rem}
.error{color:#ef4444;background:#fef2f2;border:1px solid #fecaca;
border-radius:8px;padding:.7rem 1rem;margin-bottom:1rem;font-size:.9rem}
.empty{color:#9ca3af;text-align:center;padding:3rem;font-size:1rem}
.back{color:#0891b2;font-size:.88rem;font-weight:600;text-decoration:none;
display:inline-block;margin-bottom:1.5rem}
.comments{margin-top:2rem;border-top:2px solid #bae6fd;padding-top:1.5rem}
.comment{background:#f8fafc;border:1px solid #e5e7eb;border-left:3px solid #7dd3fc;
border-radius:8px;padding:1rem 1.25rem;margin-bottom:.75rem}
.comment-meta{display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem}
.comment-author{font-weight:700;font-size:.92rem;color:#1e1e2e}
.comment-date{color:#9ca3af;font-size:.8rem}
.comment-body{line-height:1.7;color:#374151}
.comment-form{margin-top:1.5rem;background:#f8fafc;border:1px solid #e5e7eb;
border-radius:8px;padding:1.25rem}
</style>{{end}}
{{define "nav"}}<nav>
<a class="brand" href="/">✍ GoCMS</a>
<a href="/">Blog</a>
<span class="spacer"></span>
{{if .User}}<a href="/admin/">Dashboard</a> <a href="/admin/logout">Log Out</a>
{{else}}<a href="/admin/login">Admin</a>{{end}}
</nav>{{end}}
{{define "home"}}<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GoCMS — Blog</title>{{template "css" .}}</head>
<body>{{template "nav" .}}
<h1>Blog</h1>
{{if .Posts}}{{range .Posts}}
<div class="card">
<div class="meta">{{.CreatedAt}}</div>
<h2><a href="/post/{{.Slug}}">{{.Title}}</a></h2>
<p class="body">{{.Body | nl2br}}</p>
</div>
{{end}}{{else}}<p class="empty">No posts yet. <a href="/admin/new">Write the first one.</a></p>{{end}}
</body></html>{{end}}
{{define "post"}}<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Post.Title}} — GoCMS</title>{{template "css" .}}</head>
<body>{{template "nav" .}}
<a class="back" href="/">← Back to Blog</a>
<h1>{{.Post.Title}}</h1>
<div class="meta">{{.Post.CreatedAt}}</div>
<div class="body">{{.Post.Body | nl2br}}</div>
<section class="comments">
<h2>{{len .Comments}} Comments</h2>
{{range .Comments}}
<div class="comment">
<div class="comment-meta">
<span class="comment-author">{{.Author}}</span>
<span class="comment-date">· {{.CreatedAt}}</span>
</div>
<div class="comment-body">{{.Body | nl2br}}</div>
</div>
{{end}}
<div class="comment-form">
<h3>Leave a Comment</h3>
<form method="POST" action="/post/{{.Post.Slug}}">
<label>Name</label>
<input type="text" name="author" required maxlength="100">
<label>Comment</label>
<textarea name="body" required maxlength="2000"></textarea>
<button type="submit" class="btn btn-primary">Post Comment</button>
</form>
</div>
</section>
</body></html>{{end}}
{{define "login"}}<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin Login — GoCMS</title>{{template "css" .}}</head>
<body>{{template "nav" .}}
<h1>Admin Login</h1>
{{if .Err}}<div class="error">{{.Err}}</div>{{end}}
<form method="POST" action="/admin/login" style="max-width:400px">
<label>Username</label>
<input type="text" name="username" autofocus required>
<label>Password</label>
<input type="password" name="password" required>
<button type="submit" class="btn btn-primary">Log In</button>
</form>
</body></html>{{end}}
{{define "dash"}}<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dashboard — GoCMS</title>{{template "css" .}}</head>
<body>{{template "nav" .}}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem">
<h1 style="margin:0">All Posts</h1>
<a href="/admin/new" class="btn btn-primary">+ New Post</a>
</div>
{{if .Posts}}
<table>
<thead><tr><th>Title</th><th>Status</th><th>Date</th><th>Actions</th></tr></thead>
<tbody>{{range .Posts}}<tr>
<td>{{.Title}}</td>
<td>{{if .Published}}<span class="badge badge-pub">Published</span>
{{else}}<span class="badge badge-draft">Draft</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
<td><div class="actions">
<a href="/admin/edit/{{.ID}}" class="btn btn-sm btn-primary">Edit</a>
<form method="POST" action="/admin/delete/{{.ID}}" style="margin:0">
<button class="btn btn-sm btn-danger"
onclick="return confirm('Delete this post?')">Delete</button>
</form>
</div></td>
</tr>{{end}}</tbody>
</table>
{{else}}<p class="empty">No posts yet. <a href="/admin/new">Create one.</a></p>{{end}}
</body></html>{{end}}
{{define "new"}}<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>New Post — GoCMS</title>{{template "css" .}}</head>
<body>{{template "nav" .}}
<a class="back" href="/admin/">← Back</a>
<h1>New Post</h1>
{{if .Err}}<div class="error">{{.Err}}</div>{{end}}
<form method="POST" action="/admin/new">
<label>Title</label>
<input type="text" name="title" autofocus required>
<label>Body</label>
<textarea name="body" required></textarea>
<div class="check-row">
<input type="checkbox" name="published" id="pub" value="1">
<label for="pub" style="margin:0">Publish immediately</label>
</div>
<button type="submit" class="btn btn-primary">Create Post</button>
</form>
</body></html>{{end}}
{{define "edit"}}<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Edit Post — GoCMS</title>{{template "css" .}}</head>
<body>{{template "nav" .}}
<a class="back" href="/admin/">← Back</a>
<h1>Edit Post</h1>
{{if .Err}}<div class="error">{{.Err}}</div>{{end}}
<form method="POST" action="/admin/edit/{{.Post.ID}}">
<label>Title</label>
<input type="text" name="title" value="{{.Post.Title}}" required>
<label>Body</label>
<textarea name="body" required>{{.Post.Body}}</textarea>
<div class="check-row">
<input type="checkbox" name="published" id="pub" value="1"
{{if .Post.Published}}checked{{end}}>
<label for="pub" style="margin:0">Published</label>
</div>
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>
</body></html>{{end}}
`
func handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { http.NotFound(w, r); return }
_, username, _ := sessionUser(r)
posts, err := listPosts(true)
if err != nil { http.Error(w, "server error", http.StatusInternalServerError); return }
render(w, "home", Data{User: username, Posts: posts})
}
func handlePost(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/post/")
if slug == "" { http.NotFound(w, r); return }
_, username, _ := sessionUser(r)
p, err := getPostBySlug(slug)
if err == sql.ErrNoRows { http.NotFound(w, r); return }
if err != nil { http.Error(w, "server error", http.StatusInternalServerError); return }
if r.Method == http.MethodPost {
author := strings.TrimSpace(r.FormValue("author"))
body := strings.TrimSpace(r.FormValue("body"))
if len(author) > 0 && len(body) > 0 && len(author) <= 100 && len(body) <= 2000 {
db.Exec("INSERT INTO comments (post_id, author, body) VALUES (?, ?, ?)", p.ID, author, body)
}
http.Redirect(w, r, "/post/"+slug, http.StatusSeeOther)
return
}
comments, _ := listComments(p.ID)
render(w, "post", Data{User: username, Post: p, Comments: comments})
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { render(w, "login", Data{}); return }
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
var id int; var hash string
err := db.QueryRow(
"SELECT id, password_hash FROM users WHERE username = ?", username,
).Scan(&id, &hash)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
render(w, "login", Data{Err: "Invalid username or password."}); return
}
startSession(w, id)
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("session"); err == nil {
db.Exec("DELETE FROM sessions WHERE token = ?", c.Value)
}
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", MaxAge: -1, Path: "/"})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleDash(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/admin/" { http.NotFound(w, r); return }
_, username, _ := sessionUser(r)
posts, _ := listPosts(false)
render(w, "dash", Data{User: username, Posts: posts})
}
func handleNew(w http.ResponseWriter, r *http.Request) {
_, username, _ := sessionUser(r)
if r.Method == http.MethodGet { render(w, "new", Data{User: username}); return }
title := strings.TrimSpace(r.FormValue("title"))
body := strings.TrimSpace(r.FormValue("body"))
pub := 0
if r.FormValue("published") == "1" { pub = 1 }
if title == "" || body == "" {
render(w, "new", Data{User: username, Err: "Title and body are required."}); return
}
_, err := db.Exec(
"INSERT INTO posts (title, slug, body, published) VALUES (?, ?, ?, ?)",
title, slugify(title), body, pub,
)
if err != nil {
render(w, "new", Data{User: username, Err: "A post with that title already exists."}); return
}
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
func handleEdit(w http.ResponseWriter, r *http.Request) {
idStr := strings.TrimPrefix(r.URL.Path, "/admin/edit/")
id, err := strconv.Atoi(idStr)
if err != nil { http.NotFound(w, r); return }
_, username, _ := sessionUser(r)
p, err := getPostByID(id)
if err == sql.ErrNoRows { http.NotFound(w, r); return }
if r.Method == http.MethodGet { render(w, "edit", Data{User: username, Post: p}); return }
title := strings.TrimSpace(r.FormValue("title"))
body := strings.TrimSpace(r.FormValue("body"))
pub := 0
if r.FormValue("published") == "1" { pub = 1 }
if title == "" || body == "" {
render(w, "edit", Data{User: username, Post: p, Err: "Title and body are required."}); return
}
_, err = db.Exec(
"UPDATE posts SET title=?, slug=?, body=?, published=?, updated_at=datetime('now') WHERE id=?",
title, slugify(title), body, pub, id,
)
if err != nil {
render(w, "edit", Data{User: username, Post: p, Err: "Could not save — title may already be taken."}); return
}
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
func handleDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { http.NotFound(w, r); return }
idStr := strings.TrimPrefix(r.URL.Path, "/admin/delete/")
id, err := strconv.Atoi(idStr)
if err != nil { http.NotFound(w, r); return }
db.Exec("DELETE FROM posts WHERE id = ?", id)
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
func main() {
initDB()
defer db.Close()
mux := http.NewServeMux()
mux.HandleFunc("/", handleHome)
mux.HandleFunc("/post/", handlePost)
mux.HandleFunc("/admin/login", handleLogin)
mux.HandleFunc("/admin/logout", handleLogout)
mux.HandleFunc("/admin/", requireAuth(handleDash))
mux.HandleFunc("/admin/new", requireAuth(handleNew))
mux.HandleFunc("/admin/edit/", requireAuth(handleEdit))
mux.HandleFunc("/admin/delete/", requireAuth(handleDelete))
log.Println("GoCMS running on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
users stores admin accounts. posts stores articles with a published flag (0 or 1) and a slug column that drives the public URL. sessions maps a random token to a user ID with an expiry — this is the server-side session store.
The slug column has a UNIQUE constraint. If you create two posts with the same title, the second INSERT returns a SQL error, which handleNew converts into a user-visible form error.
slugify() iterates the title rune by rune: letters and digits pass through (lowercased), spaces and hyphens become -, everything else is dropped. A regexp then collapses consecutive hyphens, and strings.Trim removes any leading or trailing ones.
“Hello, World! (2025)” → hello-world-2025. Works correctly for Unicode titles because Go's unicode package handles any script.
On login: startSession() generates 32 random bytes, base64-encodes them, inserts the token into the sessions table with a 24-hour expiry, and writes an HttpOnly cookie.
On each request: sessionUser() reads the cookie, does a JOIN to find the user, and checks expires_at > datetime('now') in one query. If anything fails — no cookie, bad token, expired — it returns ok = false.
On logout: the session row is deleted from the database and the cookie is expired (MaxAge: -1). Both steps are needed: deleting the row invalidates the token server-side; expiring the cookie tells the browser to discard it.
requireAuth takes a handler function and returns a new handler function. The returned handler checks the session; if the user is not authenticated it redirects to /admin/login. If the check passes it calls the original handler. This is Go's idiomatic middleware pattern — wrap a function with a function.
template.Must(template.New("").Funcs(fm).Parse(allTemplates)) compiles all eight named templates at startup. If any template has a syntax error the program panics immediately rather than serving a broken page later.
The custom nl2br function in fm converts newlines to <br> tags. It calls template.HTMLEscapeString first to escape the body text, then replaces literal newline characters with the <br> string. The return type template.HTML tells the template engine the value is already safe and must not be escaped again.
Comments are stored in a comments table with a post_id foreign key. The REFERENCES posts(id) constraint prevents orphaned comments from accumulating if a post is deleted.
handlePost handles both GET and POST on the same /post/ route. Go's mux sends all HTTP methods to the same handler for a given pattern, so no extra route registration is needed. On GET the handler loads the post and its comments. On POST it validates the author and body fields (non-empty, within length limits), inserts the comment, and redirects back to the post page — the Post-Redirect-Get pattern that prevents double-submission on refresh.
The {{len .Comments}} call in the template uses Go's built-in len function, which is available inside templates without a custom function map entry.
After a successful POST (create, update, delete, or comment), the handler calls http.Redirect(..., http.StatusSeeOther). The browser then does a GET to the target page. If the user refreshes, they re-issue the GET — not the original POST — so the form is never accidentally submitted twice.
go run .
# or build once and run the binary:
go build -o cms . && ./cms
The default admin / changeme is only created on first run when the users table is empty. Change it by running a small Go script or by updating the database directly:
# generate a bcrypt hash of your new password, then update the DB:
sqlite3 cms.db \
"UPDATE users SET password_hash='<hash>' WHERE username='admin'"
Or add a /admin/password route to the app if you want an in-browser change form.
Write a small Go helper that calls bcrypt.GenerateFromPassword and inserts the row, then run it once:
// adduser/main.go
package main
import (
"database/sql"
"fmt"
"log"
"os"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
func main() {
if len(os.Args) != 3 {
log.Fatal("usage: adduser <username> <password>")
}
db, _ := sql.Open("sqlite", "cms.db")
hash, _ := bcrypt.GenerateFromPassword([]byte(os.Args[2]), bcrypt.DefaultCost)
db.Exec("INSERT INTO users (username, password_hash) VALUES (?, ?)",
os.Args[1], string(hash))
fmt.Println("User created:", os.Args[1])
}
go run adduser/main.go editor s3cretPass
See the Go: Host a Web App on Linux tutorial for step-by-step instructions on running any Go binary as a systemd service behind nginx on a real server.