ZetCode

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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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.

main.go
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/.

templates/layout.html
<!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.

main.go
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.

main.go
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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.