Static file serving and full web apps in Go
Go’s standard library already gives us most of what we need for HTTP servers. When static content is part of the picture, http.FileServer and a couple of related helpers handle a surprising amount of the work. The examples below build up from a bare file server to a complete web app compiled into one binary.
A minimal file server
Assume a files directory in the current working directory:
$ tree files
files
├── file1.txt
├── file2.txt
└── subdir
└── file1.txt
Serving that directory at the root route takes only a few lines:
package main
import "net/http"
func main() {
port := ":9999"
handler := http.FileServer(http.Dir("files"))
http.ListenAndServe(port, handler)
}
Requests for a directory path come back as an HTML listing; requests for a file return the file with a MIME type guessed from its contents. Opening localhost:9999 shows the directory listing, and the same content is visible with a plain curl:
$ curl localhost:9999/ <pre> <a href="file1.txt">file1.txt</a> <a href="file2.txt">file2.txt</a> <a href="subdir/">subdir/</a> </pre>
...
Note: the listing HTML shown in the browser is rendered by Go’s default directory index; no extra code is involved.
Mounting files under a route prefix
The default server exposes the filesystem at /. To serve it under something like /static, we need to wrap the file server so requests like /static/file2.txt are translated into filesystem paths without the prefix. http.StripPrefix does exactly that — it’s a small middleware that rewrites the request URL before passing it on:
package main
import "net/http"
func main() {
port := ":9999"
handler := http.StripPrefix("/static/", http.FileServer(http.Dir("files")))
http.Handle("/static/", handler)
http.ListenAndServe(port, nil)
}
So a request to localhost:9999/static/file2.txt is received by the mux, stripped of its /static prefix, and handed to the FileServer rooted at files:
$ curl localhost:9999/static/file2.txt hello in file 2 $ curl localhost:9999/static/subdir/ <pre> <a href="file1.txt">file1.txt</a> </pre>
Mixing in dynamic handlers
File serving and programmatic responses coexist easily in the same mux. We can keep a static route for the file tree while also registering a regular handler function for other paths:
package main
import (
"fmt"
"net/http"
"time"
)
func timeHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, time.Now().Format("02 Jan 2006 15:04:05 MST"))
}
func main() {
port := ":9999"
fileHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("files")))
http.Handle("/static/", fileHandler)
http.HandleFunc("/time", timeHandler)
http.ListenAndServe(port, nil)
}
This server answers /static/… from disk and handles a separate /time route that returns the current server time from timeHandler. In a browser, that response comes back raw on the /time URL:
$ curl localhost:9999/time 27 Sep 2022 18:21:30 PDT $ curl localhost:9999/static/subdir/ <pre> <a href="file1.txt">file1.txt</a> </pre> $ curl localhost:9999/time 27 Sep 2022 18:21:39 PDT
From file server to web application
A typical web app splits into a client (HTML/CSS/JS served to the browser) and a server (Go functions responding to the AJAX calls the client makes). The previous example already has all the pieces needed to serve both halves from one Go program. The project layout looks like this:
$ tree . ├── go.mod ├── public │ ├── css │ │ └── style.css │ ├── index.html │ └── js │ └── timescript.js └── server.go
All client files live under public. The server code does two things: it serves that directory at /, and it exposes the current time at /time:
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func timeHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, time.Now().Format("02 Jan 2006 15:04:05 MST"))
}
func main() {
http.HandleFunc("/time", timeHandler)
http.Handle("/", http.FileServer(http.Dir("public/")))
port := ":9999"
log.Fatal(http.ListenAndServe(port, nil))
}
The client here is minimal but complete: an HTML page that asks the server for the time every second using the fetch API, then updates the displayed value:
<html> <head> <link rel="stylesheet" type="text/css" href="/css/style.css"> </head> <body> <h2>System clock (updates every second)</h2> <div id="output"></div> <script src="/js/timescript.js"></script> </body> </html>
let outputBox = document.querySelector('#output');
window.addEventListener('DOMContentLoaded', (event) => {
outputBox.innerHTML = "initializing...";
tick();
setInterval(tick, 1000);
});
function tick() {
fetch('/time')
.then((response) => {
if (!response.ok) {
throw new Error("error response");
}
return response.text();
})
.then((text) => {
outputBox.innerHTML = text;
})
.catch((error) => {
outputBox.innerHTML = "network error";
});
}
A browser at localhost:9999 shows the page, and the clock refreshes each second as long as the server runs.
Embedding the client in the binary
The above approach has a deployment wrinkle: the public directory must remain next to the running binary. Go’s embed package eliminates that requirement entirely. The only change is in the server source — the directory layout and client files stay the same:
package main
import (
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"time"
)
//go:embed public
var public embed.FS
func timeHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, time.Now().Format("02 Jan 2006 15:04:05 MST"))
}
func main() {
// We want to serve static content from the root of the 'public' directory,
// but go:embed will create a FS where all the paths start with 'public/...'.
// Using fs.Sub we "cd" into 'public' and can serve files relative to it.
publicFS, err := fs.Sub(public, "public")
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/time", timeHandler)
http.Handle("/", http.FileServer(http.FS(publicFS)))
port := ":9999"
log.Fatal(http.ListenAndServe(port, nil))
}
Instead of pointing http.FileServer at a local directory, we now embed public into the binary at compile time and serve it through an fs.FS adapter. The resulting server is fully self-contained: the client code exists only inside the executable, and the directory is needed only while building. This is the recommended structure for a real application; the remaining two samples are shown mainly for completeness.
Streaming everything from a single handler
It’s also technically possible to skip separate client files entirely and return the full HTML, CSS and JavaScript from one handler:
package main
import (
"fmt"
"log"
"net/http"
"strings"
"time"
)
var page = `
<html>
<head>
// ...
// ... the rest of our HTML, CSS and JS code
// ...
</body>
</html>
`
func rootHandler(w http.ResponseWriter, r *http.Request) {
// The root handler "/" matches every path that wasn't match by other
// matchers, so we have to further filter it here. Only accept actual root
// paths.
if path := strings.Trim(r.URL.Path, "/"); len(path) > 0 {
http.NotFound(w, r)
return
}
fmt.Fprint(w, page)
}
func timeHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, time.Now().Format("02 Jan 2006 15:04:05 MST"))
}
func main() {
http.HandleFunc("/time", timeHandler)
http.HandleFunc("/", rootHandler)
port := ":9999"
log.Fatal(http.ListenAndServe(port, nil))
}
This avoids a static file server altogether. It’s a legitimate trick, but not a great blueprint for anything beyond a toy — for any real app, the embedding approach from the previous section is a better fit.
Adding headers with middleware
Custom headers on static responses, such as CORS, fit naturally into Go’s handler composition model. addCORS below wraps a handler and injects headers into every response it sends:
package main
import "net/http"
func addCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
next.ServeHTTP(w, req)
})
}
func main() {
port := ":9999"
handler := addCORS(http.FileServer(http.Dir("files")))
http.ListenAndServe(port, handler)
}
Since http.Handler values compose, a function returning a wrapped handler is all the middleware machinery we need. The basic file server from the start of this post becomes CORS-enabled by passing it through addCORS.



