Serve static files

Use the standard library's `http.FileServer` wrapped around `http.Dir` to serve static assets directly from your file system.

The static file trap

You've built a Go API. It handles JSON, talks to the database, and returns responses in milliseconds. Now you need to serve a frontend. You drop a public folder with index.html, style.css, and logo.png next to your binary. You write a handler, run the server, and click the link.

The browser asks to download style.css as a binary blob instead of rendering it. Or you request /static/css/main.css and get a 404. You check the code. http.FileServer(http.Dir("./public")) looks correct. The bug is invisible. You forgot to strip the URL prefix. The handler is looking for ./public/static/css/main.css. The file doesn't exist. The server returns 404. You fix the prefix. Now it works. You deploy. Traffic spikes. Your Go process spends most of its time reading files from disk. The API latency jumps. You realize Go is great at logic, but a dedicated web server is better at file I/O.

How file serving works in Go

Go's standard library includes http.FileServer. It's a handler that reads files from disk and writes them to the HTTP response. You wrap a directory with http.Dir and pass it to http.FileServer. The handler takes the request URL path, appends it to the directory path, opens the file, and streams it back.

The tricky part is the mapping. The URL path is not the file path. If you serve /static/css/main.css, the file system needs /var/www/public/css/main.css. The handler doesn't know about the /static prefix unless you tell it. http.Dir implements the fs.FS interface introduced in Go 1.16. This interface abstracts file system access. http.FileServer works with any fs.FS, not just the local disk. You can serve files from a zip archive, a database, or an embedded binary using the same handler.

http.FileServer does more than read bytes. It sets Content-Type based on the file extension. It handles Range requests for partial downloads. It checks If-Modified-Since headers and returns 304 Not Modified when the client already has a fresh copy. You get caching support for free. The handler also generates directory listings by default. If you request a path that maps to a folder, it returns an HTML page listing the files.

File servers map URLs to paths. Get the mapping wrong and you get 404s.

Minimal setup

Here's the simplest way to serve a directory. You create a file server, strip the prefix, and register the handler.

package main

import (
	"log"
	"net/http"
)

func main() {
	// http.Dir wraps the directory path and implements fs.FS
	// This tells the file server where to look for files on disk
	fs := http.Dir("./public")

	// http.FileServer creates a handler that serves files from the fs.FS
	// It handles content types, range requests, and conditional caching
	fileHandler := http.FileServer(fs)

	// StripPrefix removes "/static/" from the URL before passing to FileServer
	// Without this, FileServer looks for ./public/static/css/main.css
	// which causes 404 errors for all requests
	handler := http.StripPrefix("/static/", fileHandler)

	// Register the handler for paths starting with /static/
	// The trailing slash is required for prefix matching
	http.Handle("/static/", handler)

	log.Fatal(http.ListenAndServe(":8080", nil))
}

Run this code and request http://localhost:8080/static/style.css. The server strips /static/, looks for ./public/style.css, and returns the file. If you omit StripPrefix, the server looks for ./public/static/style.css and returns 404. The compiler won't catch this. It's a runtime logic error.

Trust gofmt. Run it on save. The formatting is standardized so you can focus on the logic, not indentation.

What happens at runtime

When a request hits /static/css/main.css, the router matches the /static/ prefix. StripPrefix removes /static/ from r.URL.Path, leaving css/main.css. FileServer receives the modified request. It cleans the path to prevent directory traversal attacks. Clean removes .. segments and extra slashes. This makes the handler safe by default.

FileServer calls fs.Open with the cleaned path. http.Dir translates this to an OS call. If the file exists, Open returns a File. FileServer calls Stat to get the size and modification time. It sets Content-Length and Last-Modified headers. It checks the If-Modified-Since header from the client. If the client's copy is fresh, FileServer returns 304 Not Modified with no body. The browser uses its cached version. This saves bandwidth and reduces latency.

If the file is a directory, FileServer reads the entries and generates an HTML index. It returns 200 OK with the listing. If you want to disable directory listing, you need to wrap the handler. FileServer doesn't expose a flag to turn it off.

The standard library handles caching headers automatically. You don't need to write conditional logic for 304 responses.

Realistic handler with caching and root redirect

Real applications often need custom headers or a root redirect. You might want to serve index.html for / to support single-page apps. Or you might want to add Cache-Control headers for assets with hash-based filenames. http.FileServer doesn't support custom headers. You wrap it in a custom handler.

Here's a handler that adds caching headers and serves a root index.

package main

import (
	"log"
	"net/http"
	"path/filepath"
)

// serveStatic wraps FileServer to add cache headers and handle the root path
func serveStatic(w http.ResponseWriter, r *http.Request) {
	// Redirect root to index.html for SPA-style frontends
	// This allows users to navigate to / and see the app
	if r.URL.Path == "/" {
		http.ServeFile(w, r, filepath.Join("./public", "index.html"))
		return
	}

	// Add immutable cache header for assets with file extensions
	// Hashed filenames change when content changes, so browsers can cache forever
	if filepath.Ext(r.URL.Path) != "" {
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
	}

	// Create the file server handler for this request
	// FileServer is cheap to create, but reusing it is better practice
	// In production, create it once and store it in a struct
	fs := http.FileServer(http.Dir("./public"))
	fs.ServeHTTP(w, r)
}

func main() {
	// Use HandleFunc to get the request object for header manipulation
	// This allows checking the path and setting custom headers
	http.HandleFunc("/static/", serveStatic)

	log.Fatal(http.ListenAndServe(":8080", nil))
}

This handler checks the path. If it's /, it serves index.html. If it has an extension, it adds a long cache header. Otherwise, it delegates to FileServer. The wrapper pattern is common in Go. You compose handlers by calling ServeHTTP on the inner handler.

HTTP handlers write errors to the response, not to logs. http.ServeFile returns a 404 or 500 directly to the client. The convention is to let the handler manage the response. You don't need to check errors from ServeFile.

Pitfalls and compiler errors

The most common bug is the doubled path. You register /static/ but forget StripPrefix. The handler looks for ./public/static/... and returns 404. You spend time debugging file permissions or directory names. The fix is StripPrefix. Always strip the prefix when serving a subdirectory.

Another pitfall is directory listing. FileServer lists directories by default. In production, you usually want to disable this. If an attacker scans your server, they see every file in the directory. Wrap the handler and check IsDir. Return 404 for directories. Or serve an index file.

Path traversal is a risk if you do manual path joining. FileServer cleans paths internally. If you build paths yourself using string concatenation, you might allow ../ sequences. Use filepath.Join or filepath.Clean. The compiler won't catch traversal bugs. They're runtime issues.

If you pass a string directly to http.FileServer, the compiler rejects it. http.FileServer expects an fs.FS. http.Dir implements fs.FS. A string does not. The compiler complains with cannot use "./public" (untyped string constant) as fs.FS value in argument. Wrap the string with http.Dir.

MIME types are usually correct. FileServer uses http.DetectContentType. It checks the extension and the file magic bytes. If you have a custom extension, register it with http.RegisterExtension. Otherwise, the browser might treat the file as binary.

The worst static file bug is the one that returns 404 silently. Check your paths and prefixes.

When to use what

Use http.FileServer when you need a quick way to serve assets during development or for a small app where a separate server adds complexity.

Use http.ServeFile when you need to serve a single specific file, like a download link or a fixed robots.txt, without exposing a directory.

Use a reverse proxy like Nginx or Caddy when you have high traffic, need advanced compression, or want to offload I/O from your Go process.

Use an embedded filesystem when you want to bundle assets into the binary so users only download one executable.

Go can serve files. Nginx serves files faster. Pick the tool that matches your scale.

Where to go next