One Binary, Two Worlds
One deployable
Lesson 5 of 5
What you'll learn
- Embed the Vite production build into the Go binary with
//go:embed - Serve static assets and an
index.htmlSPA fallback — without swallowing API 404s - Ship the whole app as one
CGO_ENABLED=0binary
Dev ran two servers; production runs one. npm run build in web/ compiles the SPA to web/dist/ — an index.html plus hashed assets. You already know the trick for baking files into a binary from the last course: //go:embed. The directive can't reach into parent directories, so the embed lives in the web package, next to what it embeds:
// web/embed.go
package web
import "embed"
//go:embed all:dist
var Dist embed.FS
The all: prefix includes dotfiles and _-prefixed files that plain embed skips — build outputs sometimes contain them, and a silently missing asset is a miserable bug. Strip the dist/ prefix with fs.Sub, then serve it with the same http.FileServerFS you used for static assets before:
dist, _ := fs.Sub(web.Dist, "dist")
mux.Handle("GET /assets/", http.FileServerFS(dist))
The SPA fallback
A SPA has client-side routes like /notes/42 that exist in React Router, not on the server. A hard refresh there asks Go for /notes/42 — with no fallback, that's a 404 and a blank page. The rule: unknown non-API GET paths get index.html, so the SPA boots and its router takes over:
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
if f, err := dist.Open(strings.TrimPrefix(r.URL.Path, "/")); err == nil {
f.Close()
http.FileServerFS(dist).ServeHTTP(w, r) // a real built file
return
}
http.ServeFileFS(w, r, dist, "index.html") // anything else: boot the SPA
})
ServeMux precedence makes this safe: GET / is the least specific pattern, so every /api/... registration wins first — and an unknown /api/ path never reaches the fallback; it gets the mux's own 404. That matters: a typo'd API call should fail loudly as JSON-shaped 404, not "succeed" with HTML.
Build the SPA before the binary
go build compiles whatever is in dist/ at that moment — embedding is a compile-time copy. Make the release script unconditional: npm --prefix web run build && CGO_ENABLED=0 GOOS=linux go build .... Stale dist/ is the classic "my fix didn't deploy" of this architecture.
Deployment is then exactly the story from the last course — one static file, scp it up, systemd owns the process, Caddy terminates TLS. Nothing new to operate: the frontend is just bytes inside the binary now, and CORS never enters the picture because there is only one origin.
The challenge models the production router: API table first, then real file, then index.html — with the API 404 carve-out.
Run it. The resolver checks the API table, then the embedded file set, then falls back to index.html — except under /api/, where unknown paths must 404 as JSON instead of booting the SPA.
A user hard-refreshes on /notes/42, a route that only exists in React Router. What should the Go server return?
That closes the loop: two worlds in one repo, one contract at /api/, and — from vite build through //go:embed to CGO_ENABLED=0 — still just one file to ship.
Saved on this device. Sign in to sync your progress everywhere.