Right, so you want to serve some static files—CSS, JavaScript, images, that sort of thing. Your first instinct might be to write a handler that opens a file and streams it out. Please, for the love of all that is holy, don’t do that. You’ll get the path wrong, forget to set the Content-Type header, and introduce a hilarious directory traversal vulnerability before lunch.

Instead, you’re going to use http.FileServer. It’s a workhorse, it’s battle-tested, and it does almost everything right. I say almost because, well, we’ll get to its quirks.

At its core, http.FileServer takes a http.FileSystem and returns a http.Handler that serves files from it. The most common way to use it is with http.Dir, which is a type that represents a directory on your local filesystem.

package main

import (
    "log"
    "net/http"
)

func main() {
    // This serves files from the "./static" directory
    fs := http.FileServer(http.Dir("./static"))
    
    // We use http.StripPrefix to remove the "/static/" prefix before 
    // the FileServer looks for the file.
    http.Handle("/static/", http.StripPrefix("/static", fs))
    
    log.Println("Listening on :3000...")
    log.Fatal(http.ListenAndServe(":3000", nil))
}

Why http.StripPrefix is Your Best Friend

This trips everyone up. Let’s say a request comes in for /static/css/style.css. Your FileServer is pointed at the ./static directory. If you just did http.Handle("/static/", fs), the FileServer would receive the entire path, /static/css/style.css, and try to find a file with that exact name inside ./static. It would look for ./static/static/css/style.css. That’s not going to work.

http.StripPrefix acts as a middleware that chops off the specified prefix from the request URL before it gets passed to the next handler. So, http.StripPrefix("/static", fs) will take the request for /static/css/style.css, strip the /static part, and pass a request for /css/style.css to the FileServer. The FileServer then correctly looks for ./static/css/style.css. See? It makes sense once you’ve been angrily staring at a 404 for twenty minutes.

The Index File Dilemma (and Security Implications)

Here’s where things get a bit… opinionated. What do you think happens when someone requests a directory, like /static/images/? The FileServer looks for an index.html file. If it finds one, it serves it. If it doesn’t, it does something spectacularly unhelpful: it returns a 200 OK and renders a paltry HTML listing of the directory’s contents.

This is a terrible default. It’s an information leak. You’re just handing a curious visitor a map of your static directory structure. We need to kill this with fire.

The proper way to handle this is to wrap the FileServer in a middleware that intercepts requests for directories and checks if an index file exists before the FileServer gets its grubby little hands on the request.

func noListing(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Check if the requested path ends with a slash, indicating a directory.
        if strings.HasSuffix(r.URL.Path, "/") {
            http.NotFound(w, r)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// Then, in your setup:
fs := http.FileServer(http.Dir("./static"))
http.Handle("/static/", noListing(http.StripPrefix("/static", fs)))

This middleware catches any directory request (path ending with /) and immediately returns a 404, preventing the file server from ever generating its directory listing.

Controlling the Cache: Your Secret Weapon

The vanilla FileServer is pretty dumb about caching. It doesn’t set any Cache-Control headers. For a production application, this is unacceptable. You want your CSS and JS to be cached aggressively by browsers and CDNs, but you also need to be able to break that cache when you deploy a new version.

The solution? Wrap it again. This pattern of wrapping handlers is the entire reason Go’s handler interface is so powerful.

func cacheControl(maxAge int, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Only set cache headers for successful responses.
        // We wrap the ResponseWriter to capture the status code.
        wrapped := &responseWriter{ResponseWriter: w}
        next.ServeHTTP(wrapped, r)
        
        if wrapped.status == http.StatusOK {
            w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
        }
    })
}

type responseWriter struct {
    http.ResponseWriter
    status int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.status = code
    rw.ResponseWriter.WriteHeader(code)
}

// Usage: Wrap your entire file server stack
staticHandler := noListing(http.StripPrefix("/static", fs))
http.Handle("/static/", cacheControl(86400, staticHandler)) // Cache for 1 day

Now you’re serving static files like a pro: securely, without directory listings, and with sensible caching. It’s a few more lines of code, but it’s the difference between a amateur hour and a robust, production-ready setup.