Serve a Single Page App From a Go Backend the Clean Way
Serve a React, Vue or Svelte build from Go: embedded assets, the index.html fallback for client routes, 404s for missing files and caching rules.

A single page application is a pile of static files plus a router that runs in the browser. The files are an index.html, some JavaScript and CSS, and images; the router decides what to show at /settings/profile without asking the server. That arrangement is easy to host, and it has one requirement the server must meet: for any URL the client router owns, the server must return index.html, because the browser will ask the server first when a user refreshes the page or opens a deep link. A server that answers 404 for /settings/profile makes the app break the first time anyone bookmarks a page.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
A Go backend is a fine host for a SPA, and the usual shape is one binary serving both the API and the built front end. This article builds the handler that does it correctly, which means getting the fallback rule right, keeping API errors out of the HTML shell, and setting cache headers that make deploys safe. It builds on embedding assets, serving static files safely and the router patterns, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6.
The fallback rule
Every request falls into one of three classes, and the handler must tell them apart:
- An existing file:
/assets/app.3f9a1c.js,/favicon.ico. Serve it. - A missing file:
/assets/old.js,/images/gone.png. Return 404. These are requests for a resource that the HTML references, and answering withindex.htmlmakes things worse. The browser asked for a script, got HTML with a 200 status, and fails to parse it as JavaScript with an error that points nowhere near the cause. - A client route:
/settings/profile,/orders/42. Serveindex.htmlwith status 200 and let the router take over.
How to distinguish class 2 from class 3 without knowing the router's routes? A practical heuristic is the file extension: client routes rarely have one, assets always do. A request whose final path segment contains a dot is treated as a file request, and a miss is a 404. Where routes legitimately contain dots (/files/report.v2), route those explicitly. Another approach is to ask whether the Accept header includes text/html: browser navigations do, script and image loads do not. The extension rule is simpler and works with curl as well.
The handler
package main
import (
"io/fs"
"net/http"
"path"
"strings"
)
// spaHandler serves files from fsys and falls back to index.html for client side routes.
func spaHandler(fsys fs.FS) http.Handler {
files := http.FileServerFS(fsys)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if name == "" {
name = "index.html"
}
if info, err := fs.Stat(fsys, name); err == nil && !info.IsDir() {
setCache(w, name)
files.ServeHTTP(w, r) // an existing file
return
}
if path.Ext(name) != "" {
http.NotFound(w, r) // looks like a file, but it is not there: do not return HTML
return
}
// A client side route: send the shell and let the router take over.
w.Header().Set("Cache-Control", "no-cache")
r2 := r.Clone(r.Context())
r2.URL.Path = "/" // FileServer serves index.html for the root without redirecting
files.ServeHTTP(w, r2)
})
}
// setCache gives fingerprinted assets a year and everything else revalidation.
func setCache(w http.ResponseWriter, name string) {
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
}Three details deserve a note. path.Clean normalizes .. and duplicate slashes before the lookup, and fs.Stat on an fs.FS cannot escape the file system, so traversal is not an issue here (the leaks FileServer has). The shell is served by rewriting the path to / on a cloned request, so that FileServer returns index.html with the right content type and conditional-request support, and without the redirect it issues for a direct /index.html request. And the caching split matters more than it looks.
Caching, and why deploys depend on it
A build tool like Vite or webpack emits assets with the content hash in the file name, assets/index-3f9a1c2b.js. A new build produces new names, so the old and new files never conflict, and the files are safe to cache for a year with immutable: the browser never asks again. The one file whose name never changes is index.html, and it is the file that references the current hashes. If the browser caches it, users keep loading the old shell, with old script names, after you deploy. So index.html gets no-cache, which means "store it, but check with the server before using it", and the check is a conditional request that costs one round trip and usually a 304.
Get this wrong in one direction and a deploy does not reach users until their cache expires. Get it wrong in the other and every page view re-downloads megabytes of unchanged JavaScript. The rule of thumb that works: hashed assets get long and immutable; everything else gets revalidation.
Wiring it with the API
The SPA handler is the catch-all, so the API has to be registered first and must not fall through to it. Give the API its own 404 in JSON, so a mistyped endpoint is not answered with the HTML shell and a 200:
package main
import (
"encoding/json"
"net/http"
)
func newServer(dist http.Handler) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"message": "hello from the API"})
})
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { // any other /api path
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
})
mux.Handle("/", dist) // everything else belongs to the front end
return mux
}The pattern /api/ (a subtree) catches every API path that has no more specific handler, which is what turns an unknown endpoint into a JSON 404. The specific GET /api/hello wins by the router's precedence rules. / is the lowest-priority catch-all, matching everything that is not an API call.
Trying it
The test uses fstest.MapFS as a stand-in for the build output, with an index.html, a hashed asset and a favicon. A real program embeds the directory instead. The table shows each class of request and what comes back:
package main
import (
"fmt"
"net/http/httptest"
"strings"
"testing/fstest"
)
func main() {
dist := fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><div id=app></div>")},
"favicon.ico": {Data: []byte("icon")},
"assets/app-3f9a1c.js": {Data: []byte("console.log('app')")},
"assets/app-3f9a1c.css": {Data: []byte("body{margin:0}")},
}
srv := newServer(spaHandler(dist))
for _, p := range []string{
"/", "/settings/profile", "/orders/42",
"/assets/app-3f9a1c.js", "/favicon.ico",
"/assets/old-build.js", "/missing.png",
"/api/hello", "/api/typo",
} {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
body := strings.TrimSpace(rec.Body.String())
if len(body) > 34 {
body = body[:34] + "..."
}
fmt.Printf("%-24s %d %-26s cache=%-34q %s\n", p, rec.Code, rec.Header().Get("Content-Type"), rec.Header().Get("Cache-Control"), body)
}
}/ 200 text/html; charset=utf-8 cache="no-cache" <!doctype html><div id=app></div>
/settings/profile 200 text/html; charset=utf-8 cache="no-cache" <!doctype html><div id=app></div>
/orders/42 200 text/html; charset=utf-8 cache="no-cache" <!doctype html><div id=app></div>
/assets/app-3f9a1c.js 200 text/javascript; charset=utf-8 cache="public, max-age=31536000, immutable" console.log('app')
/favicon.ico 200 image/x-icon cache="no-cache" icon
/assets/old-build.js 404 text/plain; charset=utf-8 cache="" 404 page not found
/missing.png 404 text/plain; charset=utf-8 cache="" 404 page not found
/api/hello 200 application/json cache="" {"message":"hello from the API"}
/api/typo 404 application/json cache="" {"error":"not found"}Read the rows by class. The root and the two client routes return the shell with 200 and no-cache. The hashed JavaScript returns itself with the immutable year-long policy. The favicon is served with revalidation. The missing JavaScript and PNG are 404s, which is what keeps the browser from parsing HTML as a script. The valid API call returns JSON and the mistyped one returns a JSON 404, not the shell. Those nine lines are the entire contract of the handler, and they make a good test table: keep them as assertions.
Embedding the build
In the real server, replace the map file system with the embedded directory your front-end build writes to (web/dist):
//go:embed all:web/dist
var distFS embed.FS
func main() {
dist, err := fs.Sub(distFS, "web/dist")
if err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", newServer(spaHandler(dist))))
}The build order matters: npm run build must run before go build, because go:embed fails at compile time if the directory is missing or empty. A Makefile target or a multi-stage Dockerfile (Node stage builds the front end, Go stage embeds it) is the normal arrangement (embedding assets covers the details and the dev-mode switch).
Development and production differences
In development, the front end runs on its own dev server (Vite, webpack) with hot reload, and the Go server handles only the API. Two setups work. The dev server proxies /api to Go, which is configured in the front-end tool and needs no Go changes. Or Go proxies everything else to the dev server with httputil.NewSingleHostReverseProxy, so the browser talks to one origin and cookies and CORS behave as they will in production. The second is more work and removes a class of "works in dev, breaks in prod" surprises. Same-origin deployment also means you can leave CORS off entirely, a security win (CORS).
Things that go wrong
Server-side rendering and SEO. A pure SPA sends an empty shell, and crawlers that do not run JavaScript see nothing. If you need indexable pages, render on the server (see a server-rendered site) or add pre-rendering. A Go backend with an SPA is for applications behind a login, not for content sites.
Security headers. Add a Content-Security-Policy that matches the build output, X-Content-Type-Options: nosniff and Referrer-Policy, through middleware (middleware). Inline scripts in index.html need a nonce or a hash in the policy.
Compression. Text assets compress well, and the embedded files are uncompressed. Pre-compress at build time or add gzip middleware (gzip middleware).
Trailing slashes and base paths. If the app is mounted under /app/, set the front end's base path and strip the prefix on the Go side with http.StripPrefix, or the asset URLs will not match.
The Go side of all this is small. The http.FileServerFS documentation and the io/fs package cover the pieces used here, and MDN's HTTP caching guide explains no-cache, immutable and revalidation in full.


