The first request
You want to publish thoughts without wrestling with a heavy framework. You open a terminal, initialize a module, and write a handful of lines. Suddenly you have a server listening on port 8080. That is the starting line. A real blog engine needs routing, template rendering, data loading, and graceful shutdowns. Go gives you the pieces in the standard library. You just need to wire them together without fighting the language.
How Go handles HTTP
Go treats HTTP as a stream of requests and responses. The net/http package provides a multiplexer that matches URL paths to handler functions. Think of the multiplexer as a restaurant host. Guests arrive with a destination in mind. The host checks the reservation book, points them to the correct table, and steps back. The waiter at that table takes the order, sends it to the kitchen, and returns the food. In Go, the host is http.ServeMux. The waiter is your handler function. The kitchen is whatever logic you write to fetch data or render templates.
Every incoming connection spawns a new goroutine. The handler runs inside that goroutine. When the handler returns, the response flushes to the client and the goroutine exits. The server keeps listening for the next connection. This model is explicit and predictable. You do not get hidden background threads or magic middleware pipelines unless you build them yourself.
Goroutines are cheap. Channels are not magic.
The skeleton server
Here is the simplest server that responds to every path. It demonstrates the handler signature, the response writer, and the blocking call that keeps the process alive.
package main
import (
"fmt"
"net/http"
)
// main starts the HTTP server and blocks until the process exits.
func main() {
// Register a handler for the root path.
// The multiplexer matches exact paths and path prefixes.
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Write the response header and body.
// Fprintf writes directly to the underlying connection.
fmt.Fprintf(w, "Hello, Blog!")
})
// Listen on port 8080 using the default server configuration.
// This call blocks the main goroutine indefinitely.
http.ListenAndServe(":8080", nil)
}
The handler receives two arguments. http.ResponseWriter lets you send headers and body bytes back to the client. *http.Request contains everything the client sent: method, headers, query parameters, and body. The multiplexer routes the request before your function runs. If no route matches, the server returns a 404 automatically.
What happens under the hood
When you call http.ListenAndServe, Go creates a net.Listener that binds to the specified port. The listener accepts TCP connections in a loop. Each accepted connection gets wrapped in a net.Conn. The HTTP server reads the request line and headers from that connection. It then looks up the path in the registered ServeMux. If a match exists, the server spawns a goroutine and calls your handler inside it.
The handler runs concurrently with every other request. This means you cannot rely on shared mutable state without synchronization. If two requests hit the same handler at the same time, they execute in parallel. The standard library handles the connection lifecycle. When your handler returns, the server flushes the response, closes the writer, and recycles the connection if keep-alive is enabled.
If you forget to import a package and you get undefined: pkg from the compiler. Forget to use one and you get imported and not used. Go catches unused imports at compile time because they indicate dead code or incomplete refactoring.
Trust the standard library. Build on top of it, not around it.
Adding templates and data
A blog needs to render HTML and load posts. Go's html/template package parses template files, escapes output to prevent injection, and executes them with a data structure. You load the templates once, then execute them per request. This keeps parsing overhead out of the hot path.
package main
import (
"html/template"
"net/http"
)
// Post represents a single blog entry.
type Post struct {
Title string
Content string
}
// posts holds the blog data in memory.
var posts = []Post{
{Title: "First Post", Content: "Hello world."},
{Title: "Second Post", Content: "Go is fast."},
}
// indexHandler renders the list of posts.
func indexHandler(w http.ResponseWriter, r *http.Request) {
// Parse the template string once in production.
// Parsing on every request wastes CPU cycles.
tmpl, err := template.New("index").Parse(`
<!DOCTYPE html>
<html><head><title>Blog</title></head>
<body>
{{range .}}<h1>{{.Title}}</h1><p>{{.Content}}</p>{{end}}
</body></html>
`)
if err != nil {
// Return a 500 error when template parsing fails.
// The client receives a plain text error message.
http.Error(w, "template parse error", http.StatusInternalServerError)
return
}
// Execute the template with the posts slice.
// The dot (.) in the template refers to this data.
if err := tmpl.Execute(w, posts); err != nil {
http.Error(w, "template execute error", http.StatusInternalServerError)
}
}
func main() {
http.HandleFunc("/", indexHandler)
http.ListenAndServe(":8080", nil)
}
The template uses {{range .}} to iterate over the slice. The dot refers to the data passed to Execute. Go's template engine automatically escapes HTML entities in dynamic values. This prevents cross-site scripting without extra effort. You pass the entire slice as the data source. The engine walks the slice, substitutes the fields, and writes the result to the response writer.
The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally swallow an error behind a silent return. Every failure point gets explicit handling.
Templates are compiled once. Data flows through them. Never trust user input.
Where things break
Blog engines fail in predictable ways. The most common issue is template injection. If you use text/template instead of html/template, you lose automatic escaping. The compiler will not stop you. It only checks types. If you pass a string where a struct is expected, the compiler rejects this with cannot use type string as type Post in argument. Runtime panics happen when you dereference a nil pointer. If you fetch a post by ID and the ID does not exist, returning nil and calling .Title on it crashes the handler. The server logs a stack trace and closes the connection.
Context cancellation is another silent killer. If you spawn a background goroutine to fetch external data and forget to pass a context, that goroutine outlives the request. The client disconnects, but your goroutine keeps running. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Context is plumbing. Run it through every long-lived call site.
Receiver naming follows a strict convention. The receiver name is usually one or two letters matching the type: (p *Post) Render(), NOT (this *Post) or (self *Post). Public names start with a capital letter. Private start lowercase. No keywords like public or private. The compiler enforces visibility through capitalization alone.
The worst goroutine bug is the one that never logs.
Picking the right pieces
You will face choices as the engine grows. Each choice trades simplicity for capability. Match the tool to the constraint.
Use the standard library net/http when you need zero dependencies and predictable behavior. Use a third-party router like chi or gorilla/mux when you need regex path matching, middleware groups, or nested route trees. Use an in-memory slice when you are prototyping or the dataset fits in RAM. Use a database driver when you need persistence, indexing, or concurrent writes. Use html/template when you render server-side HTML. Use a frontend framework when you need client-side interactivity or complex state management. Use a single binary when you want simple deployment and debugging. Use a microservice architecture when you need independent scaling or team isolation. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Don't fight the type system. Wrap the value or change the design.