Back to Go Examples

Project: Blog CMS

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.

Live Demo

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.

→ Open the Blog CMS Demo

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.

Contents

  1. What It Does
  2. The Stack
  3. Project Setup
  4. The Complete Code
  5. How It Works
  6. Run It Yourself

1. What It Does

Public side:

Admin side (login required):

All data lives in cms.db — a single SQLite file created automatically on first run. Nothing else to set up.

2. The Stack

Two external packages (modernc.org/sqlite and golang.org/x/crypto), both vendored into go.sum with go get. Everything else is standard library.

3. Project Setup

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.

4. The Complete Code

One file: main.go. The HTML templates are embedded as a multi-line backtick string constant — no external template files needed.

Imports & Database

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")
    }
}

Post Model

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
}

Comment Model

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
}

Helpers & Sessions

// 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)
    }
}

Template Data & Renderer

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)
    }
}

Templates (embedded HTML)

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="/">&#9997; 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="/">&#8592; 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">&middot; {{.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/">&#8592; 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/">&#8592; 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}}
`

Public Handlers

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})
}

Admin Handlers

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)
}

main()

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))
}

5. How It Works

Three-table schema

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.

Slug generation

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.

Session mechanism

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 middleware

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 system

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

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.

Post-Redirect-Get

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.

6. Run It Yourself

Start the server

go run .
# or build once and run the binary:
go build -o cms . && ./cms

Change the admin password

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.

Add a second admin user

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

Deploy with nginx + systemd

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.