Serving a Single-Page App
Fall back to index.html so client-side routes survive a reload, while missing assets and unknown API paths still return 404.
if _, err := fs.Stat(assets, name); err != nil { http.ServeFileFS(w, r, assets, "index.html") }A single-page app is one HTML file plus a bundle of JavaScript. Once it has loaded, the JavaScript decides what to show and rewrites the address bar itself, so the browser can display /settings although no file of that name exists. Clicking around never reaches the server. Press reload on /settings and the server does get a real GET /settings. A plain file server answers 404 and the user sees a broken page.
The fallback
The fix is a handler that looks the requested path up in the asset file system. If a file is there, serve it. If not, serve index.html with status 200 and let the JavaScript work out what the path means.
Keeping real 404s
Falling back on every path hides bugs. A mistyped script URL would return the HTML shell with status 200, the browser would try to parse HTML as JavaScript, and the console would show a syntax error instead of a missing file. A fetch to the wrong API path gets HTML, and decoding it as JSON fails on the character <. So fall back only when the path could be a page: no file extension, and not under the API prefix. Give the API its own pattern, because http.ServeMux picks the most specific match, so /api/ wins over / and an unknown API path still 404s. Patterns that begin with a method, such as GET /, need Go 1.22 or later. http.NotFound(w, r) is shorthand for http.Error(w, "404 page not found", http.StatusNotFound).
Caching
Asset files carry hashes in their names and can be cached for a year. index.html cannot: send it with Cache-Control: no-cache so the next deploy's filenames are picked up.
Example
package main
import (
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"path"
"strings"
"testing/fstest"
)
// Stands in for an embed.FS holding a compiled frontend.
var ui = fstest.MapFS{
"index.html": {Data: []byte("<!doctype html>app shell")},
"assets/app.js": {Data: []byte("console.log(1)")},
}
func spa(assets fs.FS) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Clean the path, then drop the leading slash: fs names never have one.
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if _, err := fs.Stat(assets, name); err != nil {
if path.Ext(name) != "" {
http.NotFound(w, r) // a missing file stays a missing file
return
}
name = "index.html" // a browser-side route: send the shell
}
http.ServeFileFS(w, r, assets, name)
})
}
func main() {
h := spa(ui)
for _, target := range []string{"/assets/app.js", "/settings", "/assets/missing.js"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", target, nil))
fmt.Println(target, rec.Code, strings.TrimSpace(rec.Body.String()))
}
}
// Output:
// /assets/app.js 200 console.log(1)
// /settings 200 <!doctype html>app shell
// /assets/missing.js 404 404 page not foundWhen to use it
- Serve a React or Vue build from the same Go binary that serves its API.
- Make a hard refresh on a deep link such as /orders/42 render the app instead of a 404.
- Keep fetch failures legible by returning JSON 404s from /api/ paths.
- Ship an internal admin interface with no separate web server, nginx config or CDN.
More examples
Wiring the fallback next to an API
This is the whole shape of a one-binary deployment: embedded frontend, API under its own prefix, fallback for everything else. The fallback is registered as "/" with no method on purpose. A method pattern over the whole path space conflicts with a methodless prefix pattern: mux.Handle("GET /", ...) next to mux.Handle("/api/", ...) panics at registration with "GET / matches fewer methods than /api/, but has a more general path pattern", because neither pattern matches a subset of the other. With the method test inside the handler, check it with curl -i localhost:8080/settings (HTML, 200), curl -i localhost:8080/api/nope (the API's 404) and curl -i -X POST localhost:8080/settings (405, from the handler's own method check).
package main
import (
"embed"
"io/fs"
"net/http"
)
//go:embed all:dist
var ui embed.FS
func newHandler(api http.Handler) (http.Handler, error) {
assets, err := fs.Sub(ui, "dist")
if err != nil {
return nil, err
}
mux := http.NewServeMux()
// "/api/" is more specific than "/", so it wins. An unknown API path
// reaches the API's own 404 instead of being answered with HTML.
mux.Handle("/api/", api)
// The fallback pattern carries no method. "GET /" matches fewer methods
// than "/api/" but a more general path, which ServeMux treats as a
// conflict, and registering both panics. So check the method here
// instead: a POST to a path that does not exist is not a page view and
// should not be handed index.html.
pages := spa(assets)
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
pages.ServeHTTP(w, r)
}))
return mux, nil
}Headers for the shell and the assets
Two policies in one handler, decided by which file is about to be written. Without the no-cache on index.html, users keep the old shell and ask for asset filenames that the new build no longer contains, which shows up as a blank page with 404s in the console.
package main
import (
"io/fs"
"net/http"
"strings"
)
func serve(w http.ResponseWriter, r *http.Request, assets fs.FS, name string) {
if name == "index.html" {
// The shell names the hashed asset files. Cache it and a deploy
// never reaches the browser.
w.Header().Set("Cache-Control", "no-cache")
} else if strings.HasPrefix(r.URL.Path, "/assets/") {
// Built files have a content hash in the name, so the bytes at this
// URL can never change.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
http.ServeFileFS(w, r, assets, name)
}An explicit list of client routes
Stricter and easier to reason about: a path is either a known page, a real file, or a 404. The cost is that the list must stay in step with the frontend's router, and routes with an id in them, such as /orders/42, need a prefix check rather than an exact match. Choose this when uptime checks or crawlers must get truthful statuses.
package main
import (
"io/fs"
"net/http"
)
// The file-extension test is a heuristic. If the frontend's routes are
// known at build time, list them and let everything else 404 honestly.
var clientRoutes = map[string]bool{
"/": true,
"/settings": true,
"/orders": true,
}
func strictSPA(assets fs.FS) http.Handler {
files := http.FileServerFS(assets)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if clientRoutes[r.URL.Path] {
http.ServeFileFS(w, r, assets, "index.html")
return
}
files.ServeHTTP(w, r) // a real file, or a real 404
})
}
Discussion