The typical approach to full-stack web development involves two independent pieces: a frontend development server (or an Nginx instance in production) delivering your UI assets, and a separate backend server handling API requests.
That works fine for massive corporate systems, but it adds unnecessary deployment friction for lightweight applications, internal tools, or self-hosted dashboards.
While building my open-source project, MQTT Dashboard, I wanted a zero-dependency deployment flow. I wanted users to download a single file, execute it, and immediately have both the UI and backend running on port 8080.
Here is the exact setup I used to pack a Vite + React single-page application (SPA) straight into a statically linked Go binaryβwithout sacrificing hot-reloading during local development.
1. Why would I want that?
Shipping a Single Binary
By embedding your static assets directly into your Go backend, you eliminate the need for an external web server like Nginx or a Node runtime in production. The Go binary serves the compiled JavaScript, CSS, and HTML natively out of memory. If a user wants to run your tool, they don't need to configure reverse proxies or manage frontend paths; they just run the executable.
Making the Dockerfile Simpler
This architectural choice heavily simplifies your production container deployment. Instead of orchestrating multiple containers or configuring complex multi-stage setups that copy code into separate runtime environments, your production Dockerfile drops down to a featherweight, secure layer.
You build the React app, pass the static dist/ directory to Go, compile a statically linked binary, and toss it into a raw, non-root Alpine image. The final production container drops to around 25-30MB, consumes minimal RAM, and runs effortlessly on low-power hardware like a home server or a Raspberry Pi.
π‘ Bonus Advantage: Because the Go binary serves both the UI and your backend endpoints from the exact same port (
:8080), you completely bypass CORS configuration headaches and WebSocket handshake path friction.
2. How to use the embed directive
Go lets you read files and folders at compilation time and bake them straight into your application binary using the standard library's native embed package.
Our directory layout looks like this:
.
βββ backend/
β βββ dist/ # Where the React production build will land
β βββ main.go
β βββ go.mod
βββ frontend/
βββ package.json
βββ dist/ # Vite's production output
The Go Asset Layer
Inside the backend/ directory, create a file called assets.go. This acts as the bridge that reads your compiled frontend assets and exposes them to your router:
package main
import (
"embed"
"io/fs"
"net/http"
)
//go:embed dist/*
var frontendAssets embed.FS
func getFrontendFileSystem() http.FileSystem {
// Strip the "dist" prefix so files are served relative to the build root
strippedFS, err := fs.Sub(frontendAssets, "dist")
if err != nil {
panic("Failed to initialize embedded filesystem: " + err.Error())
}
return http.FS(strippedFS)
}
Note on
fs.Sub: By default, the embedded filesystem keeps thedist/prefix in its pathing. Stripping it ensures that when a browser requests/index.html, Go maps it straight to the root instead of breaking your asset resolution.
Handling SPA Routing
If you serve static assets traditionally, hitting the refresh button on a React Router path like /dashboards/edit throws a 404 Not Found because the Go server looks for a physical directory named /dashboards/edit on disk.
To make an embedded SPA function properly, your HTTP router needs a fallback mechanism: If a requested file doesn't exist on the embedded filesystem, automatically serve index.html and let React Router handle the path rendering client-side.
package main
import (
"errors"
"net/http"
"os"
)
func setupRoutes() *http.ServeMux {
mux := http.NewServeMux()
// 1. Register API/WebSocket endpoints first
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status":"healthy"}`))
})
// 2. Setup the SPA Embedded File Server fallback
fileServer := http.FileServer(getFrontendFileSystem())
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fsys := getFrontendFileSystem()
// If the file physically exists in the embedded assets, serve it
file, err := fsys.Open(r.URL.Path)
if err == nil {
file.Close()
fileServer.ServeHTTP(w, r)
return
}
// Fallback: If file doesn't exist, serve index.html for React Router
if errors.Is(err, os.ErrNotExist) {
r.URL.Path = "/"
fileServer.ServeHTTP(w, r)
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
})
return mux
}
3. How to still dev correctly with hot-reload
Baking your assets into a binary is incredible for production, but rebuilding your entire Go binary every single time you change a pixel or a CSS class in React is a developer experience nightmare.
To maintain an instant Hot Module Replacement (HMR) workflow during development, you run your tools in a decoupled dev mode, utilizing Vite's dev server for the frontend and Air for backend live-reloading.
The Development Architecture
-
Frontend: Run
npm run devin your/frontenddirectory. Vite fires up onhttp://localhost:5173. -
Backend: Run
airin your/backenddirectory. Air watches your.gofiles and live-reloads your API onhttp://localhost:8080.
Pointing Vite to the Go Backend
To bypass CORS issues locally and make sure your frontend API fetches seamlessly hit your Go server, configure Viteβs built-in proxy.
Update your frontend/vite.config.ts (or .js) file to automatically proxy API and WebSocket requests back to your running Go port:
import { defineConfig } from 'vite'
import react from '@vitejs/react-swc'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// Proxy standard API HTTP requests
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
// Proxy live WebSocket traffic
'/ws': {
target: 'ws://localhost:8080',
ws: true,
}
}
}
})
The Result
When working locally, you open your browser to http://localhost:5173 (Vite). Any changes you make to your React components reflect instantly in the browser via HMR. When your React components make a fetch request to /api/v1/health, Vite transparently routes it back to your Go server running on :8080.
When you are ready to ship to production, you run your production build script: Vite compiles down to static optimized files inside backend/dist/, and your go build pipeline locks those files permanently inside your deployable executable.
If you want to see this layout working in a live codebase, check out the full implementation over at MQTT Dashboard on GitHub.
Drop a comment below if you run into any issues mapping your embedded file handlers or configuring Air for your hot-reload stack!
Top comments (0)