How to Serve Static Files in Go

Web
Serve static files in Go using http.FileServer and http.Dir to map a local directory to a web path.

Serving static files in Go

You're building a web app in Go. The backend logic works. The API returns JSON. Now you need to serve the frontend: the HTML, the CSS, the images. You could write a handler that reads every file from disk on every request, but that reinvents the wheel and misses edge cases like caching headers, MIME type detection, and directory handling. Go's standard library includes a ready-made solution that handles the heavy lifting.

The handler that does the work

The core tool is http.FileServer. It returns an http.Handler that serves files from a directory tree. You pass it a filesystem implementation, usually http.Dir, which wraps a path on your local disk. The handler maps URL paths to file paths, checks permissions, sets content types, and streams the data back. It implements the standard ServeHTTP method, so it plugs directly into http.ServeMux or the default mux.

The Go community convention for handlers is simple: accept interfaces, return structs. http.FileServer returns a handler that satisfies the http.Handler interface. You don't need to know the internal struct type. You just call ServeHTTP indirectly through the mux.

http.FileServer handles the plumbing. You handle the routing.

Minimal setup

Here's the simplest setup: wrap a directory, mount it, start the server.

package main

import (
	"net/http"
)

func main() {
	// Wrap the directory path in http.Dir to create a filesystem interface.
	// This abstracts the path so the handler can query files without hardcoding strings.
	fs := http.FileServer(http.Dir("./static"))

	// Mount the handler at the root path.
	// Requests to /index.html will look for ./static/index.html.
	http.Handle("/", fs)

	// Start the server on port 8080.
	// nil uses the default ServeMux where we registered the handler.
	// Always check the error to catch port conflicts or startup failures.
	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

Mount the handler and move on.

What happens under the hood

When a request arrives for /css/style.css, the handler translates the URL path to a file path. It appends the URL path to the directory root, resulting in ./static/css/style.css. The handler checks if the file exists and is readable.

If the path points to a directory, the handler looks for an index.html file inside that directory. If index.html exists, it serves that file. If not, it generates an HTML page listing the directory contents. This behavior is convenient for development but can leak information in production.

For regular files, the handler opens the file and determines the MIME type based on the extension. It sets headers like Content-Type and Content-Length. If the client sends an If-Modified-Since header, the handler checks the file's modification time. If the file hasn't changed since that time, the handler returns a 304 Not Modified response with no body, saving bandwidth. Go 1.18 and later also support ETag headers for more robust caching validation.

The handler also supports range requests. If the client sends a Range header, the handler can return a partial response with 206 Partial Content. This allows browsers to resume interrupted downloads or seek within large files.

Trust the standard library for MIME types and caching headers.

Real-world patterns

In a real app, static files rarely live at the root. You usually want them under a prefix like /assets/ while your API handles /api/. You need to strip the prefix so the file server sees the correct relative path.

Here's how to mount static files under a sub-path:

package main

import (
	"net/http"
)

func main() {
	// Create the file server handler for the static directory.
	fs := http.FileServer(http.Dir("./public"))

	// Strip the /assets/ prefix before passing the path to the file server.
	// Without this, a request to /assets/logo.png would look for ./public/assets/logo.png.
	handler := http.StripPrefix("/assets/", fs)

	// Register the handler under /assets/.
	// Requests starting with /assets/ are routed here.
	http.Handle("/assets/", handler)

	// Handle API routes separately.
	http.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("ok"))
	})

	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

For production deployments, you often want a single binary that contains the static assets. Go's embed package lets you compile files directly into the executable. You create an embedded filesystem and pass it to http.FileServer.

Here's how to embed static files:

package main

import (
	"embed"
	"io/fs"
	"net/http"
)

//go:embed static/*
var staticFS embed.FS

func main() {
	// Strip the leading "static/" directory from the embedded filesystem.
	// This makes /index.html resolve to static/index.html inside the embed.
	subFS, err := fs.Sub(staticFS, "static")
	if err != nil {
		panic(err)
	}

	// Create a file server from the embedded filesystem.
	// The handler serves files from the binary, not the disk.
	handler := http.FileServer(http.FS(subFS))

	http.Handle("/", handler)

	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

Embed for portability. Strip prefixes for clarity.

Security and customization

Sometimes you need to prevent directory listings for security. You can wrap the handler to intercept directory requests. The http.FileServer doesn't have a built-in flag to disable listings, so a small wrapper does the job.

Here's a wrapper that disables directory listings:

package main

import (
	"net/http"
)

// noDirListing wraps a handler to return 403 for directory requests.
// This prevents the server from exposing directory contents.
func noDirListing(handler http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Check if the request path ends with a slash, indicating a directory.
		// The handler normalizes paths, so a slash means the client requested a directory.
		if r.URL.Path[len(r.URL.Path)-1] == '/' {
			// Return 403 Forbidden instead of listing directory contents.
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		// Delegate to the file server for file requests.
		handler.ServeHTTP(w, r)
	})
}

func main() {
	fs := http.FileServer(http.Dir("./static"))
	// Wrap the file server to disable directory listings.
	handler := noDirListing(fs)

	http.Handle("/", handler)

	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

The Go community expects gofmt to format code. Run it on save. Don't argue about indentation. Error handling is explicit. If http.ListenAndServe fails, log the error and exit. The receiver name in methods is usually one or two letters, but http.HandlerFunc uses the function value directly, so you don't need a receiver here.

Disable directory listings unless you have a reason to show them.

Pitfalls and errors

If you pass a string directly to http.FileServer, the compiler rejects the code with cannot use "./static" (untyped string constant) as http.FileSystem value in argument. You must wrap the path with http.Dir.

If you forget to import net/http, you get undefined: http from the compiler. If you import a package but don't use it, you get imported and not used. Go requires all imports to be used.

http.Dir follows symlinks. If your static directory contains a symlink pointing outside the tree, the server might serve files you didn't intend. Review your directory structure.

If you mount a handler at /static/ but don't strip the prefix, the handler looks for ./static/static/file.css. The path doubles, and requests return 404. Use http.StripPrefix to align the URL path with the file system path.

http.ListenAndServe returns an error. If the port is in use, the program exits silently if you don't check the error. Always handle the return value.

Check your paths. Secure your directories.

When to use what

Use http.FileServer with http.Dir when you need to serve files from a local directory during development or when assets live on disk in production.

Use http.FileServer with embed.FS when you want a single binary deployment that includes all static assets without external dependencies.

Use http.StripPrefix when your static files are mounted under a URL path that doesn't match the directory structure on disk.

Use a custom handler wrapping http.FileServer when you need to disable directory listings, add custom headers, or restrict access based on authentication.

Use a reverse proxy like Nginx or Caddy when you need advanced caching, compression, SSL termination, or high-performance static serving for a public-facing site.

Pick the tool that matches your deployment model.

Where to go next