Skip to content
gohacks.dev
Web

Build a Blog Engine in Go Powered by Markdown Files

Build a blog engine in Go that embeds Markdown files, parses front matter, hides drafts and scheduled posts and serves index, tag and post pages.

18 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the Markdown logo.

A blog does not need a database, an admin panel or a content management system. For a technical writer with a git repository, it needs a folder of Markdown files, a program that turns them into pages, and a deploy that copies one binary. Go is well suited to it: embed puts the content in the executable, the standard library has the HTTP server and the template engine, and goldmark renders Markdown. The whole engine fits in a few hundred lines and has no moving parts to break at night.

Tested with Go 1.25.6 and goldmark v1.8.6, macOS on Apple Silicon (arm64)

This project builds one: embedded content, front matter, drafts and scheduled posts, tags, an index, and tests. It belongs to the web cluster that ends in a production HTTP server, uses go:embed for static files and html/template, and its follow-ups are rendering Markdown safely with goldmark, an RSS feed and a sitemap. Everything ran on Go 1.25.6 with goldmark v1.8.6.

The content

Each post is a Markdown file with a short block of metadata at the top, the convention known as front matter. The file name carries the date and the slug, which keeps the directory listing sorted and the URL stable.

content/posts/2026-01-10-hello-go.mdText
---
title: Hello from a Go blog
date: 2026-01-10
tags: go, web
---
This site is **one binary**. The posts you are reading were embedded at build time.

## Why embed

A deploy is a single file copy, and there is nothing to mount.

~~~go
fmt.Println("hello, blog")
~~~
content/posts/2026-02-03-embedding-files.mdText
---
title: Embedding files with go:embed
date: 2026-02-03
tags: go, tooling
---
The `embed` package gives you an `fs.FS` backed by the binary.

- no runtime file paths
- no missing assets in production
content/posts/2026-03-20-scheduled.mdText
---
title: A scheduled post
date: 2099-01-01
tags: web
---
This one is dated far in the future, so the engine should not show it yet.
content/posts/2026-02-10-half-written.mdText
---
title: Half written
date: 2026-02-10
tags: go
draft: true
---
Not ready.

(The code fence inside the first file uses tildes, a valid Markdown fence, only so it can sit inside this page's own code block.) Two of those files are traps on purpose: one is dated in the year 2099 and one is marked draft. Neither should appear on the site.

The loader

blog.goGo
package main

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"html/template"
	"io/fs"
	"path"
	"sort"
	"strings"
	"time"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
)

type Post struct {
	Slug        string
	Title       string
	Date        time.Time
	Tags        []string
	HTML        template.HTML
	ReadMinutes int
}

// parseFrontMatter splits a file into its key: value header and the Markdown body.
func parseFrontMatter(src []byte) (map[string]string, []byte, error) {
	rest, ok := bytes.CutPrefix(src, []byte("---\n"))
	if !ok {
		return nil, nil, errors.New("missing front matter: the file must start with a line of three dashes")
	}
	header, body, ok := bytes.Cut(rest, []byte("\n---\n"))
	if !ok {
		return nil, nil, errors.New("front matter is not closed with a line of three dashes")
	}
	meta := map[string]string{}
	sc := bufio.NewScanner(bytes.NewReader(header))
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" {
			continue
		}
		k, v, ok := strings.Cut(line, ":")
		if !ok {
			return nil, nil, fmt.Errorf("front matter line %q is not key: value", line)
		}
		meta[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
	}
	return meta, body, sc.Err()
}

var markdown = goldmark.New(
	goldmark.WithExtensions(extension.GFM),
	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
)

// LoadPosts reads content/posts/*.md from fsys. Drafts and posts dated after now are left out unless preview is set.
func LoadPosts(fsys fs.FS, now time.Time, preview bool) ([]Post, error) {
	files, err := fs.Glob(fsys, "content/posts/*.md")
	if err != nil {
		return nil, err
	}
	var posts []Post
	seen := map[string]string{}
	for _, name := range files {
		src, err := fs.ReadFile(fsys, name)
		if err != nil {
			return nil, err
		}
		meta, body, err := parseFrontMatter(src)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", name, err)
		}
		base := strings.TrimSuffix(path.Base(name), ".md")
		if len(base) < 12 || base[10] != '-' {
			return nil, fmt.Errorf("%s: file names must look like 2026-01-31-my-slug.md", name)
		}
		slug := base[11:]
		if other, dup := seen[slug]; dup {
			return nil, fmt.Errorf("%s: slug %q is already used by %s", name, slug, other)
		}
		seen[slug] = name

		date, err := time.Parse("2006-01-02", meta["date"])
		if err != nil {
			return nil, fmt.Errorf("%s: date must be YYYY-MM-DD", name)
		}
		if meta["title"] == "" {
			return nil, fmt.Errorf("%s: title is required", name)
		}
		if !preview && (meta["draft"] == "true" || date.After(now)) {
			continue
		}
		var out bytes.Buffer
		if err := markdown.Convert(body, &out); err != nil {
			return nil, fmt.Errorf("%s: %w", name, err)
		}
		p := Post{
			Slug: slug, Title: meta["title"], Date: date, HTML: template.HTML(out.String()),
			ReadMinutes: max(1, (len(strings.Fields(string(body)))+219)/220),
		}
		for _, t := range strings.Split(meta["tags"], ",") {
			if t = strings.ToLower(strings.TrimSpace(t)); t != "" {
				p.Tags = append(p.Tags, t)
			}
		}
		posts = append(posts, p)
	}
	sort.Slice(posts, func(i, j int) bool { return posts[i].Date.After(posts[j].Date) })
	return posts, nil
}

The loader is a function of an fs.FS, not of the disk, so the same code reads the embedded files in production and a map in tests. It validates everything it can, and fails loudly with the file name: a missing date, a malformed header, a file name that does not follow the convention, two posts with the same slug. A blog that quietly drops a post because of a typo in its front matter is worse than one that refuses to start, and since loading happens once at startup, "refuse to start" means the problem is caught in the deploy and not by a reader.

The template.HTML conversion needs a word. It tells html/template that the string is already safe HTML, which it is only because goldmark's default behavior discards raw HTML in the Markdown source and filters dangerous link schemes. The authors here are trusted, since the files live in your repository. If posts ever come from users, that trust no longer holds, and the article on goldmark and XSS shows what to add.

Front matter here is hand-parsed, because a blog needs title, date, tags and draft and nothing nested. When you want lists, nested maps or multi-line values, use a real YAML parser and goldmark-meta; the cost is a dependency and its parsing edge cases (the Norway problem: an unquoted no becomes a boolean).

The site

site.goGo
package main

import (
	"embed"
	"html/template"
	"io/fs"
	"net/http"
	"slices"
	"time"
)

//go:embed content
var content embed.FS

const layout = `{{define "layout"}}<!doctype html><html lang="en"><head><meta charset="utf-8"><title>{{.Title}}</title></head>
<body><header><a href="/">Notes</a></header><main>{{template "main" .}}</main></body></html>{{end}}`

var indexTmpl = template.Must(template.New("layout").Parse(layout + `{{define "main"}}
<h1>{{.Heading}}</h1>
<ul>{{range .Posts}}<li><a href="/posts/{{.Slug}}">{{.Title}}</a> <time>{{.Date.Format "2006-01-02"}}</time>{{range .Tags}} <a href="/tags/{{.}}">#{{.}}</a>{{end}}</li>
{{else}}<li>Nothing here yet.</li>{{end}}</ul>{{end}}`))

var postTmpl = template.Must(template.New("layout").Parse(layout + `{{define "main"}}
<article><h1>{{.Post.Title}}</h1>
<p><time>{{.Post.Date.Format "2 January 2006"}}</time>, {{.Post.ReadMinutes}} min read</p>
{{.Post.HTML}}</article>{{end}}`))

type Blog struct {
	posts  []Post
	bySlug map[string]Post
}

func NewBlog(fsys fs.FS, now time.Time, preview bool) (*Blog, error) {
	posts, err := LoadPosts(fsys, now, preview)
	if err != nil {
		return nil, err
	}
	b := &Blog{posts: posts, bySlug: map[string]Post{}}
	for _, p := range posts {
		b.bySlug[p.Slug] = p
	}
	return b, nil
}

func (b *Blog) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		render(w, indexTmpl, map[string]any{"Title": "Notes", "Heading": "All posts", "Posts": b.posts})
	})
	mux.HandleFunc("GET /tags/{tag}", func(w http.ResponseWriter, r *http.Request) {
		tag := r.PathValue("tag")
		var matching []Post
		for _, p := range b.posts {
			if slices.Contains(p.Tags, tag) {
				matching = append(matching, p)
			}
		}
		if len(matching) == 0 {
			http.NotFound(w, r)
			return
		}
		render(w, indexTmpl, map[string]any{"Title": "#" + tag, "Heading": "Tagged " + tag, "Posts": matching})
	})
	mux.HandleFunc("GET /posts/{slug}", func(w http.ResponseWriter, r *http.Request) {
		p, ok := b.bySlug[r.PathValue("slug")]
		if !ok {
			http.NotFound(w, r)
			return
		}
		render(w, postTmpl, map[string]any{"Title": p.Title, "Post": p})
	})
	return mux
}

func render(w http.ResponseWriter, t *template.Template, data any) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := t.ExecuteTemplate(w, "layout", data); err != nil {
		http.Error(w, "render error", http.StatusInternalServerError)
	}
}

Everything expensive happens in NewBlog, once: reading, parsing, rendering Markdown. A request does a map lookup and executes a template, which is microseconds of work and is why a blog like this can serve a lot of traffic from a modest machine. The index and tag pages share one template, and the layout is defined once and wrapped around each page's main block.

The routes use the Go 1.22 patterns: GET /{$} pins the index to exactly /, so it does not swallow every unknown path (see custom 404s), and unknown posts and tags return 404 from the handler.

Running it

main.goGo
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"regexp"
	"strings"
	"time"
)

func main() {
	now := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
	blog, err := NewBlog(content, now, false)
	if err != nil {
		panic(err)
	}
	srv := httptest.NewServer(blog.Routes())
	defer srv.Close()

	get := func(path string) (int, string) {
		res, _ := http.Get(srv.URL + path)
		defer res.Body.Close()
		b, _ := io.ReadAll(res.Body)
		return res.StatusCode, string(b)
	}
	titles := regexp.MustCompile(`<a href="/posts/[^"]+">([^<]+)</a>`)

	_, index := get("/")
	fmt.Print("index: ")
	for _, m := range titles.FindAllStringSubmatch(index, -1) {
		fmt.Printf("[%s] ", m[1])
	}
	fmt.Println()

	_, tagged := get("/tags/tooling")
	fmt.Println("tag tooling shows embedding post:", strings.Contains(tagged, "Embedding files"), "| shows hello post:", strings.Contains(tagged, "Hello from"))

	code, post := get("/posts/hello-go")
	fmt.Println("post page:", code,
		"| heading has an id:", strings.Contains(post, `<h2 id="why-embed">`), "| code block:", strings.Contains(post, `<code class="language-go">`),
		"| bold:", strings.Contains(post, "<strong>one binary</strong>"), "| reading time shown:", strings.Contains(post, "min read"))

	for _, path := range []string{"/posts/half-written", "/posts/scheduled", "/posts/nope", "/tags/missing", "/anything-else"} {
		code, _ := get(path)
		fmt.Printf("%-22s -> %d\n", path, code)
	}

	preview, _ := NewBlog(content, now, true)
	fmt.Println("preview mode loads", len(preview.posts), "posts instead of", len(blog.posts))
}
Text
index: [Embedding files with go:embed] [Hello from a Go blog] 
tag tooling shows embedding post: true | shows hello post: false
post page: 200 | heading has an id: true | code block: true | bold: true | reading time shown: true
/posts/half-written    -> 404
/posts/scheduled       -> 404
/posts/nope            -> 404
/tags/missing          -> 404
/anything-else         -> 404
preview mode loads 4 posts instead of 2

The index lists the two published posts, newest first. The tag page for tooling shows only the post tagged that way. The post page has the rendered pieces: automatic heading IDs for anchor links, a fenced code block with the language-go class that a highlighter can target, the bold text and the reading time. The draft and the 2099 post are 404s, as is anything that does not exist, and preview mode, which an author would enable locally, loads all four.

Tests without files

Because the loader reads an fs.FS, its tests can build a tiny file system in memory with testing/fstest, one entry per case.

blog_test.goGo
package main

import (
	"strings"
	"testing"
	"testing/fstest"
	"time"
)

func file(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }

var now = time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)

func TestFrontMatterErrorsNameTheFile(t *testing.T) {
	cases := map[string]string{
		"no front matter": "just text",
		"unclosed":        "---\ntitle: x\n",
		"bad line":        "---\ntitle x\n---\nbody",
		"missing title":   "---\ndate: 2026-01-01\n---\nbody",
		"bad date":        "---\ntitle: x\ndate: January\n---\nbody",
	}
	for name, src := range cases {
		t.Run(name, func(t *testing.T) {
			fsys := fstest.MapFS{"content/posts/2026-01-01-a.md": file(src)}
			_, err := LoadPosts(fsys, now, false)
			if err == nil || !strings.Contains(err.Error(), "2026-01-01-a.md") {
				t.Fatalf("want an error that names the file, got %v", err)
			}
		})
	}
}

func TestDuplicateSlugsAreRejected(t *testing.T) {
	post := "---\ntitle: x\ndate: 2026-01-01\n---\nbody"
	fsys := fstest.MapFS{
		"content/posts/2026-01-01-same.md": file(post),
		"content/posts/2026-02-01-same.md": file(post),
	}
	if _, err := LoadPosts(fsys, now, false); err == nil || !strings.Contains(err.Error(), "already used") {
		t.Fatalf("want a duplicate slug error, got %v", err)
	}
}

func TestOrderingAndVisibility(t *testing.T) {
	mk := func(title, date, extra string) *fstest.MapFile {
		return file("---\ntitle: " + title + "\ndate: " + date + "\n" + extra + "---\nbody text")
	}
	fsys := fstest.MapFS{
		"content/posts/2026-01-01-old.md":    mk("Old", "2026-01-01", ""),
		"content/posts/2026-03-01-new.md":    mk("New", "2026-03-01", ""),
		"content/posts/2026-02-01-draft.md":  mk("Draft", "2026-02-01", "draft: true\n"),
		"content/posts/2027-01-01-future.md": mk("Future", "2027-01-01", ""),
	}
	posts, err := LoadPosts(fsys, now, false)
	if err != nil || len(posts) != 2 || posts[0].Slug != "new" || posts[1].Slug != "old" {
		t.Fatalf("got %+v, %v; want [new old]", posts, err)
	}
	if all, _ := LoadPosts(fsys, now, true); len(all) != 4 {
		t.Errorf("preview should load all four posts, got %d", len(all))
	}
}

func TestRawHTMLInMarkdownIsNotPassedThrough(t *testing.T) {
	fsys := fstest.MapFS{"content/posts/2026-01-01-x.md": file("---\ntitle: x\ndate: 2026-01-01\n---\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))")}
	posts, _ := LoadPosts(fsys, now, false)
	if h := string(posts[0].HTML); strings.Contains(h, "<script>") || strings.Contains(h, `href="javascript:`) {
		t.Fatalf("unsafe output: %s", h)
	}
}

The last test pins the claim made earlier: raw HTML in a post is not passed through, and a javascript: link target is neutralised, with goldmark's defaults. If someone later turns on the unsafe renderer option for convenience, this test fails and names the reason.

What it costs to serve

The claim that serving is cheap deserves a measurement. The benchmark below generates 200 posts of about 2 KB each in a fstest.MapFS, times the whole load (parse, render, sort), and times a request for one post through the handler.

bench_test.goGo
package main

import (
	"fmt"
	"net/http/httptest"
	"strings"
	"testing"
	"testing/fstest"
	"time"
)

func corpus(n int) fstest.MapFS {
	fsys := fstest.MapFS{}
	body := strings.Repeat("A paragraph with `inline code`, a [link](https://go.dev) and some *emphasis*, repeated to give the renderer work.\n\n", 12)
	for i := 0; i < n; i++ {
		name := fmt.Sprintf("content/posts/2025-%02d-%02d-post-%03d.md", i%12+1, i%27+1, i)
		fsys[name] = &fstest.MapFile{Data: []byte(fmt.Sprintf("---\ntitle: Post %d\ndate: 2025-%02d-%02d\ntags: go, web\n---\n## Heading\n\n%s", i, i%12+1, i%27+1, body))}
	}
	return fsys
}

func BenchmarkLoad200Posts(b *testing.B) {
	fsys := corpus(200)
	now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		if _, err := LoadPosts(fsys, now, false); err != nil {
			b.Fatal(err)
		}
	}
}

func BenchmarkServeOnePost(b *testing.B) {
	blog, err := NewBlog(corpus(200), time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), false)
	if err != nil {
		b.Fatal(err)
	}
	h := blog.Routes()
	req := httptest.NewRequest("GET", "/posts/post-042", nil)
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkLoad200Posts-10    	     120	   9635826 ns/op	11444311 B/op	   72854 allocs/op
BenchmarkServeOnePost-10    	  184890	      6549 ns/op	    9254 B/op	      63 allocs/op
PASS
ok  	example.test/lab	3.664s

On the recorded run (Apple M1 Max, Go 1.25.6), loading all 200 posts took about 9.7 ms and allocated 11 MB, once, at startup. Serving one post took about 6.5 microseconds and 63 allocations per request. That is roughly 150,000 post pages per second per core before any network overhead, and the process spends its time on TLS and the kernel, not on your code. The ratio is the point: rendering all the Markdown costs less than ten milliseconds in total, and a request is a map lookup plus a template execution over pre-rendered HTML, so there is no reason to render per request and no reason to add a page cache in front of it. What would hurt is rendering Markdown inside the request path for every visitor, which is what many dynamic setups do by accident. The numbers are a guide to the order of magnitude for this corpus; measure your own if the posts are much larger.

Things you will want next

Syntax highlighting. Goldmark has a goldmark-highlighting extension built on Chroma that emits highlighted HTML on the server, with inline styles or CSS classes. The alternative is a client-side library loaded from your own origin. I prefer the server: no flash of unstyled code, no script on the page, and the output is plain HTML that feeds and crawlers see. The cost is a heavier dependency. I have not run Chroma for this article, so check its documentation for the current API before wiring it in.

Images and assets. Put images next to the content (content/images/...), embed the whole content directory, and serve it with http.FileServerFS under /images/. Reference images from Markdown with absolute paths, and set a long Cache-Control on them once their names include a hash, as in cache headers.

Changed slugs. Published URLs are promises. If you rename a post, keep the old address working: add an aliases field to the front matter and register a 301 from each alias, so inbound links and search results survive. The mux makes this a loop over the posts at startup.

Related posts and series. With tags in memory, "more like this" is a function that scores other posts by shared tags. Keep it simple and deterministic, and put the result in the template data so the template stays dumb.

Search and comments. Client-side search over a generated JSON index works for a few hundred posts; a server-side approach is in adding search to a Go website. Comments are a moderation burden; if you need them, link to a discussion on a forum rather than hosting user content next to your posts.

Choosing the parts

Each piece of this engine has a plausible replacement, and it helps to know why I chose what I did. embed over reading files at runtime, because it removes a class of deployment errors (a missing directory, wrong working directory, a forgotten mount) at no cost. goldmark over blackfriday and other renderers, because it follows the CommonMark specification, which means the Markdown your editor previews renders the same way here, and its extension points (the GFM tables, strikethrough and task lists used above) are well documented. html/template over a third-party template engine, because it escapes by context and keeps the dependency list short; if you prefer typed components, templ is the alternative. And hand-rolled routing over a router package, for the reasons in the router comparison.

The part I would swap first as the blog grows is front matter. Moving to YAML via a maintained parser buys lists and nested fields (series, authors, alternative titles) in exchange for one dependency and a quoting convention to teach authors. Do it when you need the structure, not before.

A content check in CI

Because the loader validates every file and fails with the file name, the cheapest safeguard for authors is a test that loads the real embedded content. It runs in CI on every pull request, and a malformed date or a duplicate slug fails the build instead of the deploy.

content_test.goGo
package main

import (
	"testing"
	"time"
)

func TestRealContentLoads(t *testing.T) {
	posts, err := LoadPosts(content, time.Now(), true) // preview mode: drafts must be valid too
	if err != nil {
		t.Fatalf("content is not publishable: %v", err)
	}
	if len(posts) == 0 {
		t.Fatal("no posts were found; is the content directory embedded?")
	}
	for _, p := range posts {
		if len(p.Title) > 90 {
			t.Errorf("%s: a title over 90 characters is cut off in search results", p.Slug)
		}
	}
}
Text
=== RUN   TestFrontMatterErrorsNameTheFile
=== RUN   TestFrontMatterErrorsNameTheFile/no_front_matter
=== RUN   TestFrontMatterErrorsNameTheFile/unclosed
=== RUN   TestFrontMatterErrorsNameTheFile/bad_line
=== RUN   TestFrontMatterErrorsNameTheFile/missing_title
=== RUN   TestFrontMatterErrorsNameTheFile/bad_date
--- PASS: TestFrontMatterErrorsNameTheFile (0.00s)
    --- PASS: TestFrontMatterErrorsNameTheFile/no_front_matter (0.00s)
    --- PASS: TestFrontMatterErrorsNameTheFile/unclosed (0.00s)
    --- PASS: TestFrontMatterErrorsNameTheFile/bad_line (0.00s)
    --- PASS: TestFrontMatterErrorsNameTheFile/missing_title (0.00s)
    --- PASS: TestFrontMatterErrorsNameTheFile/bad_date (0.00s)
=== RUN   TestDuplicateSlugsAreRejected
--- PASS: TestDuplicateSlugsAreRejected (0.00s)
=== RUN   TestOrderingAndVisibility
--- PASS: TestOrderingAndVisibility (0.00s)
=== RUN   TestRawHTMLInMarkdownIsNotPassedThrough
--- PASS: TestRawHTMLInMarkdownIsNotPassedThrough (0.00s)
=== RUN   TestRealContentLoads
--- PASS: TestRealContentLoads (0.00s)
PASS
ok  	example.test/lab	1.305s

Loading with preview set matters: a draft with broken front matter would otherwise pass CI and fail on the day someone publishes it. Add whatever house rules you have as assertions here, such as at least one tag per post or no future date older than a year, and the check becomes the style guide the machine enforces so reviewers do not have to.

Writing and deploying

The authoring loop should be: edit a file, save, refresh. Support that with a -content flag that, when set, serves from os.DirFS(dir) and reloads on every request in preview mode, while the production build uses the embedded copy. That keeps a single code path through LoadPosts and costs one if in main. For deployment, go build produces one static binary; copy it to the server or put it in a container whose only layer is the binary, and the "release" is a git tag. Roll back by deploying the previous binary, which also restores the previous content, a property that a database-backed blog does not have.

Accessibility and the details readers notice

A few small choices in the template carry weight. Keep lang="en" on the root element so screen readers pick the right voice, use a single <h1> per page (the post title) and let Markdown headings start at ##, as the posts above do, and keep link text meaningful rather than "here". The <time> element gets a machine-readable datetime attribute in a finished layout, which the version above omits for brevity. Set a canonical URL in the head so tag pages and the index do not compete with the post for the same content, and add the Open Graph tags that link preview services read, so a shared link shows a title and a description instead of a bare URL.

When this is the wrong tool

A file-based blog assumes the people writing are comfortable with git and Markdown. If your authors are not, you need an editor with accounts, drafts, images and review, and that is a CMS, which is a different project. It also assumes that publishing is a deploy: a typo fix means a new binary, and scheduled posts appear at the next start unless you add a timer. For a technical blog with a handful of authors, those are features. For a newsroom, they are not. Choose with the authors in mind, not the code.

Running it for real

For development, add a -preview flag that loads drafts and re-reads files from disk (os.DirFS("content/..")) on each request or on a file change, so writing is a save-and-refresh loop; embed only in production builds. For deployment, build one binary with go build, put it behind your TLS proxy (or use autocert), and publish by pushing to git and rebuilding. A scheduled post becomes visible on the first start after its date; if you need it to appear on time without a restart, reload the posts on a timer or re-run LoadPosts at midnight and swap the result in under a lock.

Add the pieces that make it a blog people can follow: an RSS feed, a sitemap, canonical URLs and Open Graph tags in the layout, cache headers on posts (they change only when you deploy, so a long max-age is safe, and an ETag costs nothing), and gzip for the HTML. The package documentation for embed and goldmark cover the building blocks used here.

Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.