How to Serve HTML Pages from a Go HTTP Server

Web
Serve HTML pages from a Go HTTP server using http.FileServer and http.Dir to handle static file requests.

Serving HTML from Go

You have an index.html file. You built a Go server. Now you want visitors to see that page when they hit localhost:8080. The instinct is often to open the file in Go, read the bytes, and dump them into the response. That approach works for a single file. It falls apart the moment you need caching headers, partial downloads, directory listings, or a folder full of assets. You end up rewriting logic that the standard library already handles perfectly.

Go provides http.FileServer, a handler that serves files from the file system. It understands content types, supports range requests for large files, respects If-Modified-Since headers, and streams content without loading everything into memory. It is a complete static file server wrapped in a single function call.

The standard library solution

http.FileServer takes a directory wrapper and returns an http.Handler. The wrapper is usually http.Dir, which points to a path on disk. When a request arrives, the handler maps the URL path to a file path, checks permissions, and sends the content.

Think of http.FileServer as a concierge for your static files. You tell it where the files live. When a guest asks for a file, the concierge finds it, checks if it exists, hands it over with the right description, and even remembers if the guest already has a copy. You don't need to manage the inventory yourself.

Minimal example

Here's the simplest way to serve a directory. Point the server at a folder, and Go handles the rest.

package main

import (
	"log"
	"net/http"
)

func main() {
	// http.Dir wraps the path so FileServer knows where to look.
	// It resolves relative paths against the current working directory.
	dir := http.Dir("./static")

	// FileServer creates a handler that serves files from the directory.
	// It automatically handles GET, HEAD, and OPTIONS methods.
	handler := http.FileServer(dir)

	// Handle mounts the handler at the root path.
	// Requests to / will look for files in ./static.
	http.Handle("/", handler)

	// ListenAndServe starts the server on port 8080.
	// nil means use the default ServeMux.
	// log.Fatal exits the program if the server fails to start.
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Place your HTML files in a folder named static relative to where you run the executable. A request to http://localhost:8080/index.html will serve ./static/index.html. A request to http://localhost:8080/ will serve ./static/index.html if it exists, or show a directory listing.

What happens under the hood

When a request hits http.FileServer, the handler does more than just read a file. It performs a sequence of checks that make it production-ready.

The handler resolves the requested path against the directory root. It checks if the file exists and if it is a regular file. If the file is a directory, it looks for an index.html inside. If that exists, it serves the index file. If not, it generates an HTML directory listing.

The handler detects the MIME type based on the file extension using mime.TypeByExtension. It sets the Content-Type header automatically. You don't need to configure that style.css is CSS or that image.png is an image. Go knows.

The handler supports HTTP range requests. If a client asks for bytes 0-1024 of a large file, the server sends only that chunk with a 206 Partial Content status. This enables video streaming and resumable downloads without extra code.

The handler checks If-Modified-Since and If-None-Match headers. If the client already has a cached version and the file hasn't changed, the server responds with 304 Not Modified and sends no body. This saves bandwidth and speeds up repeated visits.

FileServer is a complete static server, not a toy.

Realistic routing

In a real application, you rarely serve files from the root path. You usually have an API or dynamic routes at / and static assets in a subpath like /assets/. You also want to separate the URL structure from the file system structure. The URL might be /assets/, but the files live in ./public/.

Use http.StripPrefix to bridge the gap. It removes a prefix from the URL path before passing the request to the file server.

Here's how to serve assets from a subpath while keeping the file system path clean.

package main

import (
	"log"
	"net/http"
)

func main() {
	// StripPrefix removes /assets/ from the URL path before FileServer sees it.
	// A request to /assets/style.css becomes /style.css for the handler.
	// This maps the URL path to the file system path correctly.
	fileHandler := http.StripPrefix("/assets/", http.FileServer(http.Dir("./public")))

	// Mount the file handler under /assets/.
	// Requests to / will still hit your main application logic.
	// The trailing slash ensures subpaths like /assets/foo match.
	http.Handle("/assets/", fileHandler)

	// Define a root handler for the main app.
	// This prevents the file server from catching requests to /.
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Main app running"))
	})

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

The StripPrefix wrapper is essential when the URL prefix and directory name differ. Without it, FileServer would look for ./public/assets/style.css instead of ./public/style.css.

The slash trap

The ServeMux router treats trailing slashes as significant. This is a common source of confusion.

If you register a handler with http.Handle("/assets", handler), the router matches only the exact path /assets. A request to /assets/style.css does not match. It falls through to the next pattern or returns a 404.

If you register with http.Handle("/assets/", handler), the router matches /assets/ and every path that starts with /assets/. This catches /assets/style.css, /assets/js/app.js, and so on.

Always use a trailing slash when mounting a directory handler. The slash tells the router to match the prefix and everything below it.

The compiler will not warn you about this mistake. The code compiles fine. You only notice when requests return 404 errors. Watch your slashes carefully.

Pitfalls and conventions

Serving files seems simple, but a few details trip up beginners.

Relative paths depend on the working directory. http.Dir("./static") resolves ./static relative to where you run the binary. If you run the program from a different folder, the path breaks. In production, use absolute paths or resolve paths relative to the executable using os.Executable or filepath.Abs.

The http.Dir type implements the fs.FS interface. Modern Go code often uses fs.FS directly. http.FileServer accepts any fs.FS. This allows you to serve files from archives, memory, or embedded resources using the same handler. The interface is the contract; the implementation can vary.

Convention dictates that log.Fatal is acceptable in main for server startup errors. The program should exit if it cannot listen on the port. For other functions, return the error and let the caller decide. The if err != nil { return err } pattern keeps error handling visible and explicit.

If you forget to import the net/http package, the compiler rejects the program with undefined: http. If you try to call the handler like a function, you get handler is not a function because FileServer returns a handler object, not a callable function. Pass the handler to Handle or HandleFunc, do not invoke it.

Trust gofmt. The formatting tool enforces a consistent style. Run it on save. It removes debates about indentation and braces. Focus on logic, not layout.

Decision matrix

Choosing the right tool depends on your requirements. Use the parallel structure below to pick the right approach.

Use http.FileServer when you have a directory of static files and want automatic directory listings, caching headers, range support, and MIME type detection.

Use http.ServeContent when you need to serve a single file with custom headers, dynamic content generation, or when the content comes from a reader rather than a file path.

Use the embed package when you want to bundle static files into the binary so users do not need a separate folder. This is ideal for single-binary deployments where the HTML and assets must travel with the executable.

Use a custom handler with os.ReadFile when you need to transform the HTML on the fly, such as injecting user data into a template or modifying content based on request parameters.

Use http.StripPrefix whenever the URL path prefix differs from the file system directory structure.

Where to go next