Go HTTP static files
last modified October 30, 2026
In this article we show how to serve static files from a Go HTTP server. Static files are CSS files, JavaScript files, images and plain HTML pages that are sent unchanged to the browser.
The net/http package contains a ready-made file server. We look at
mounting it at the root and under a prefix, directory listings, a custom 404
page, cache headers, files embedded into the binary, templates combined with
static assets, and the fallback used by single-page applications.
All examples were run with Go 1.27.0 against the small site below.
The demo site
The site has a home page, an about page, a stylesheet, a logo and a directory of text files without an index page. The logo is the 220-byte PNG from the serving images article.
site ├── about.html ├── css │ └── style.css ├── docs │ ├── a.txt │ └── b.txt ├── img │ └── logo.png └── index.html
Serving a directory with FileServer
http.FileServer returns a handler that serves files from an
http.FileSystem. http.Dir turns a directory path into
one, and the handler is registered at / so that it sees every
request.
package main
import (
"log"
"net/http"
)
func main() {
http.Handle("/", http.FileServer(http.Dir("site")))
log.Println("Listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
A request for a directory is answered with its index.html when one
exists. The content type comes from the file extension, so the stylesheet is
served as text/css and the image as image/png.
$ curl -s localhost:8080/
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Home page</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<h1>Home page</h1>
<p><a href="about.html">About page</a></p>
<img src="img/logo.png" alt="Logo">
</body>
</html>
$ curl -s localhost:8080/about.html | grep -E '<title>|<h1>'
<title>About page</title>
<h1>About page</h1>
$ curl -sI localhost:8080/css/style.css | grep -iE 'HTTP/|content-type|content-length'
HTTP/1.1 200 OK
Content-Length: 100
Content-Type: text/css; charset=utf-8
Mounting the server under a prefix
Assets are often kept under /static/ so that the rest of the URL
space stays free for application routes. http.StripPrefix removes
the prefix before the file server looks up the path.
package main
import (
"log"
"net/http"
)
func main() {
fs := http.FileServer(http.Dir("site"))
http.Handle("/static/", http.StripPrefix("/static/", fs))
log.Println("Listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
The trailing slash in the pattern matters: /static/ matches the
whole subtree, while /static would match only that exact path. A
request for /static/about.html is served from
site/about.html.
$ curl -s localhost:8080/static/about.html | grep '<title>'
<title>About page</title>
$ curl -s localhost:8080/static/ | grep '<title>'
<title>Home page</title>
Directory listings
When a directory has no index.html, the file server generates a
list of its entries. The listing is convenient during development but exposes
the names of all files in the directory.
package main
import (
"log"
"net/http"
)
func main() {
http.Handle("/", http.FileServer(http.Dir("site")))
log.Println("Listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
The docs directory contains two text files and no index, so the
response is an HTML page with links to both of them. Requests to subdirectories
receive the same treatment.
$ curl -s localhost:8080/docs/ <!doctype html> <meta name="viewport" content="width=device-width"> <pre> <a href="a.txt">a.txt</a> <a href="b.txt">b.txt</a> </pre>
A custom 404 page
The built-in file server answers a missing file with the plain text
404 page not found. A wrapper can check whether the target exists
and return an HTML page instead, while still delegating existing paths to the
file server.
package main
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
)
type notFound struct {
next http.Handler
root string
}
func (h notFound) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p := filepath.Join(h.root, filepath.Clean("/"+r.URL.Path))
if _, err := os.Stat(p); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, "<h1>Page not found</h1>\n")
return
}
h.next.ServeHTTP(w, r)
}
func main() {
fs := http.FileServer(http.Dir("site"))
http.Handle("/", notFound{next: fs, root: "site"})
log.Println("Listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
filepath.Clean on an absolute path removes .. elements,
so the check cannot be used to escape the root. The wrapper only decides whether
the path exists; the file server still handles ranges and content types.
$ curl -s -w ' [%{http_code}]\n' localhost:8080/missing.html
<h1>Page not found</h1>
[404]
$ curl -s localhost:8080/about.html | grep '<title>'
<title>About page</title>
Cache headers
A middleware can add a Cache-Control header to every static
response. Long lifetimes are safe for files whose names change when their
content changes, such as app.3f2a1c.css.
package main
import (
"log"
"net/http"
)
func cache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=86400")
next.ServeHTTP(w, r)
})
}
func main() {
fs := http.FileServer(http.Dir("site"))
http.Handle("/", cache(fs))
log.Println("Listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
The file server also sets Last-Modified and understands
If-Modified-Since, so the browser can revalidate after the lifetime
expires. The header has to be set before the handler writes the response.
$ curl -sI localhost:8080/css/style.css | grep -iE 'HTTP/|cache-control|content-type' HTTP/1.1 200 OK Cache-Control: public, max-age=86400 Content-Type: text/css; charset=utf-8
Serving files embedded in the binary
The embed package includes a directory in the executable. The
fs.Sub call removes the directory name from the paths, and
http.FS adapts the result to the file server.
package main
import (
"embed"
"io/fs"
"log"
"net/http"
)
//go:embed site
var content embed.FS
func main() {
sub, err := fs.Sub(content, "site")
if err != nil {
log.Fatal(err)
}
http.Handle("/", http.FileServer(http.FS(sub)))
log.Println("Listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
The embedded files are read-only and have a zero modification time, so the
server sends no Last-Modified header for them. Deployment is a
single binary without a site directory next to it.
$ curl -s localhost:8080/ | head -5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Home page</title>
$ curl -sI localhost:8080/css/style.css | grep -iE 'HTTP/|content-type|content-length'
HTTP/1.1 200 OK
Content-Length: 100
Content-Type: text/css; charset=utf-8
Templates with static assets
A dynamic page still needs its stylesheet from a static URL. The template is
parsed once at startup, not on every request, and the CSS is mounted under
/static/.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Users</title>
<link rel="stylesheet" href="/static/css/table.css">
</head>
<body>
<table>
<thead>
<tr><th>Name</th><th>Occupation</th></tr>
</thead>
<tbody>
{{- range . }}
<tr><td>{{ .Name }}</td><td>{{ .Occupation }}</td></tr>
{{- end }}
</tbody>
</table>
</body>
</html>
The {{- range . }} action trims the whitespace before it, which
keeps the generated rows on separate lines without blank lines between them.
The {{ .Name }} actions insert the struct fields and escape them
for HTML.
package main
import (
"html/template"
"log"
"net/http"
)
type User struct {
Name string
Occupation string
}
func main() {
tmpl := template.Must(template.ParseFiles("templates/layout.html"))
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public"))))
http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
users := []User{
{Name: "John Doe", Occupation: "gardener"},
{Name: "Roger Roe", Occupation: "driver"},
{Name: "Thomas Green", Occupation: "teacher"},
}
if err := tmpl.Execute(w, users); err != nil {
log.Println(err)
}
})
log.Println("Listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
template.Must panics at startup if the file cannot be parsed, which
is preferable to a broken page later. The handler writes the rendered table and
the browser fetches /static/css/table.css separately.
$ curl -s localhost:8080/users
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Users</title>
<link rel="stylesheet" href="/static/css/table.css">
</head>
<body>
<table>
<thead>
<tr><th>Name</th><th>Occupation</th></tr>
</thead>
<tbody>
<tr><td>John Doe</td><td>gardener</td></tr>
<tr><td>Roger Roe</td><td>driver</td></tr>
<tr><td>Thomas Green</td><td>teacher</td></tr>
</tbody>
</table>
</body>
</html>
$ curl -sI localhost:8080/static/css/table.css | grep -iE 'HTTP/|content-type'
HTTP/1.1 200 OK
Content-Type: text/css; charset=utf-8
Single-page application fallback
A single-page application handles routes in the browser, so the server has to
return index.html for paths that are not real files. API routes are
registered first so that they keep their own responses.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
p := filepath.Join("site", filepath.Clean("/"+r.URL.Path))
if info, err := os.Stat(p); err == nil && !info.IsDir() {
http.ServeFile(w, r, p)
return
}
http.ServeFile(w, r, "site/index.html")
})
fmt.Println("Listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", mux))
}
The handler checks the file system first, so about.html and
css/style.css are served as before. Everything else, including
/dashboard, receives the home page and the client-side router
decides what to display.
$ curl -s localhost:8080/dashboard | grep -E '<title>|<h1>'
<title>Home page</title>
<h1>Home page</h1>
$ curl -s localhost:8080/api/status
{"status":"ok"}
$ curl -s localhost:8080/about.html | grep '<title>'
<title>About page</title>
Source
Go net/http package - reference
Go embed package - reference
In this article we have served static files in Go with
http.FileServer: at the root and under a prefix, with directory
listings, a custom 404 page, cache headers, embedded files, templates and a
single-page fallback.
Author
List all Go tutorials.