Just build your react frontend into your Go Backend !
Single-Binary Full-Stack: Embedding React SPAs in Go for Frictionless Deployment
Current Situation Analysis
Modern web development defaults to a decoupled architecture: a Node-based frontend build pipeline served by Nginx or a CDN, communicating via HTTP with a separate backend service. While this pattern scales well for high-traffic public SaaS platforms, it introduces significant operational overhead for internal tools, self-hosted dashboards, edge deployments, and lightweight microservices.
The friction manifests in three areas:
- Deployment Complexity: Shipping a tool requires orchestrating multiple containers, configuring reverse proxies, managing CORS policies, and ensuring the Node runtime is available in the production environment.
- Resource Inefficiency: A minimal React app wrapped in a Node server or Nginx container often consumes 150MBβ300MB of disk space and hundreds of megabytes of RAM, which is prohibitive for resource-constrained environments like Raspberry Pis or low-cost VPS instances.
- Network Latency & Configuration: Decoupled stacks require explicit CORS configuration and introduce an extra network hop between the static asset server and the API backend.
Go's embed package provides a native mechanism to bypass these issues entirely. By compiling static assets directly into the Go binary, developers can achieve a zero-dependency deployment model. The resulting artifact is a single executable that serves both the UI and the API from the same process, eliminating the need for external web servers or runtime dependencies in production.
WOW Moment: Key Findings
Embedding the frontend transforms the deployment profile from a multi-service orchestration problem to a single-file distribution model. The impact on operational metrics is substantial.
| Metric | Decoupled Stack (Nginx + Node + Go) | Embedded Go Binary |
|---|---|---|
| Production Artifact | Docker Compose / Multi-stage Image | Single Executable (~25β30 MB) |
| Runtime Dependencies | Node.js, Nginx, OS Libraries | None (Statically Linked) |
| CORS Configuration | Required (Headers, Preflight) | None (Same Origin) |
| Deployment Steps | Build Frontend β Build Backend β Orchestrate β Configure Proxy | ./app |
| Memory Footprint | ~150β300 MB | ~10β20 MB |
| Latency (UI β API) | Network hop via reverse proxy | In-process (Zero latency) |
This approach enables "download and run" distribution for internal tools and drastically reduces the attack surface and maintenance burden of production containers.
Core Solution
The implementation relies on Go's embed directive to bake the Vite build output into the binary, a custom HTTP handler to manage SPA routing fallbacks, and a Vite proxy configuration to preserve hot-reloading during development.
1. Project Structure
Organize the repository to isolate the frontend build output within the backend package. This ensures the Go compiler can locate the assets relative to the source files.
.
βββ cmd/
β βββ server/
β βββ main.go # Application entry point
βββ internal/
β βββ api/ # Backend handlers
β βββ ui/
β βββ ui_bundle.go # Embedding logic and asset serving
βββ web/ # React/Vite frontend source
β βββ package.json
β βββ vite.config.ts
β βββ dist/ # Vite production output (generated)
βββ go.mod
βββ Makefile # Build orchestration
2. The Embedding Layer
Create a dedicated file to manage the embedded filesystem. This abstraction isolates the embed logic and provides a clean http.FileSystem interface to the router.
File: internal/ui/ui_bundle.go
package ui
import (
"embed"
"io/fs"
"net/http"
)
//go:embed all:web/dist
var embeddedWebAssets embed.FS
// GetFileSystem returns the embedded filesystem with the build directory stripped.
// This ensures requests to /index.html map to the root of the embedded assets.
func GetFileSystem() http.FileSystem {
strippedFS, err := fs.Sub(embeddedWebAssets, "web/dist")
if err != nil {
// In a real application, handle this gracefully or panic during init.
panic("failed to initialize embedded UI filesystem: " + err.Error())
}
return http.FS(strippedFS)
}
Rationale: The //go:embed all:web/dist directive captures the entire directory tree. Using fs.Sub is critical; without it, the embedded paths retain the web/dist prefix, causing the file server to fail when resolving requests like /assets/main.js.
3. SPA Routing and Fallback Logic
Single-page applications use client-side routing. When a user navigates to /settings and refreshes, the browser requests that path from the server. Since no physical file exists at /settings, the server must fall back to serving index.html, allowing the React router to take over.
File: internal/ui/handler.go
package ui
import (
"errors"
"net/http"
"os"
)
// SPAHandler serves embedded assets with a fallback to index.html for client-side routing.
func SPAHandler() http.Handler {
fileSystem := GetFileSystem()
fileServer := http.FileServer(fileSystem)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Attempt to open the requested file in the embedded filesystem.
file, err := fileSystem.Open(r.URL.Path)
if err == nil {
file.Close()
// File exists; serve it directly.
fileServer.ServeHTTP(w, r)
return
}
// If the error is "file not found", this is likely a client-side route.
// Rewrite the request to "/" to serve index.html.
if errors.Is(err, os.ErrNotExist) {
r.URL.Path = "/"
fileServer.ServeHTTP(w, r)
return
}
// For other errors (permissions, etc.), return 500.
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
})
}
Rationale: This handler intercepts all requests. It checks for file existence before delegating to http.FileServer. This prevents the default file server from returning a 404 for valid SPA routes. The rewrite to / ensures index.html is served, which contains the script tags to bootstrap the React application.
4. Router Assembly
Wire the UI handler and API endpoints into the main mux. API routes should be registered first to ensure they take precedence over the catch-all UI handler.
File: cmd/server/main.go
package main
import (
"log"
"net/http"
"myapp/internal/api"
"myapp/internal/ui"
)
func main() {
mux := http.NewServeMux()
// Register API endpoints.
mux.HandleFunc("/api/v1/status", api.HealthCheck)
mux.HandleFunc("/api/v1/data", api.GetData)
// Register the SPA handler as the catch-all.
mux.Handle("/", ui.SPAHandler())
log.Println("Server starting on :3000")
if err := http.ListenAndServe(":3000", mux); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
5. Development Workflow with Hot-Reloading
Embedding assets is ideal for production but hinders development speed. Rebuilding the Go binary for every CSS change is inefficient. The solution is a decoupled development mode using Vite's proxy feature.
File: web/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
// Proxy API requests to the Go backend.
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
// Proxy WebSocket connections if applicable.
'/ws': {
target: 'ws://localhost:3000',
ws: true,
},
},
},
});
Workflow:
- Run
npm run devin theweb/directory. Vite serves the UI on:5173with HMR. - Run
go run ./cmd/serverin the root. Go serves the API on:3000. - Vite intercepts requests to
/apiand/wsand forwards them to Go. This mimics the production behavior (same origin) without CORS issues, while preserving instant frontend updates.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|---|---|
The fs.Sub Omission |
Developers often forget to strip the directory prefix from the embedded filesystem. This results in 404 errors because the server looks for /web/dist/index.html when the browser requests /index.html. |
Always use fs.Sub(embeddedFS, "path/to/dist") before creating the http.FileSystem. |
| SPA Refresh 404s | Without a fallback handler, refreshing a page on a client-side route (e.g., /dashboard) causes the Go server to search for a directory named dashboard, which doesn't exist, returning a 404. |
Implement the os.ErrNotExist check in the handler to rewrite unknown paths to / and serve index.html. |
| Build Race Conditions | Running go build before the frontend build completes results in an empty or stale asset directory embedded in the binary. |
Use a Makefile or build script that runs npm run build (or vite build) before invoking go build. |
| WebSocket Proxy Gaps | Vite's proxy configuration requires explicit settings for WebSockets. Omitting ws: true causes WebSocket connections to fail during development. |
Add a proxy entry for /ws with ws: true in vite.config.ts. |
| Missing Cache Headers | Go's default http.FileServer does not set aggressive caching headers for static assets, leading to unnecessary bandwidth usage and slower load times. |
Wrap the file server in middleware that sets Cache-Control headers for asset paths (e.g., /assets/*). |
| Binary Bloat | Embedding the entire web/ directory, including node_modules or source maps, drastically increases binary size. |
Ensure the //go:embed directive targets only the production build output (e.g., web/dist), not the source directory. |
| CORS in Dev vs Prod | API calls may work in dev due to the proxy but fail in production if the frontend uses absolute URLs or incorrect relative paths. | Use relative paths for API calls (e.g., /api/v1/data) so they resolve correctly in both proxied dev mode and embedded prod mode. |
Production Bundle
Action Checklist
- Verify Build Output: Ensure
npm run buildgenerates files inweb/dist/and thatweb/dist/index.htmlexists. - Configure Embed Path: Confirm
//go:embedpoints to the correct directory relative to the Go source file. - Test SPA Fallback: Run the binary and navigate to a deep link (e.g.,
/settings); verifyindex.htmlis served. - Validate Proxy: Run
npm run devandgo runsimultaneously; verify API calls succeed without CORS errors. - Optimize Docker: Use a
scratchoralpinebase image; copy only the compiled binary. - Check Binary Size: Run
ls -lhon the binary; ensure it is within expected limits (typically <30MB for a React SPA). - Security Scan: Verify no sensitive files (
.env, source maps) are included in the embed directory.
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Internal Tool / Dashboard | Embedded Binary | Zero deployment friction; single artifact; no Nginx/Node overhead. | Low (Minimal infra) |
| Self-Hosted SaaS | Embedded Binary | Simplifies installation for end-users; reduces support burden. | Low (Single binary distribution) |
| High-Traffic Public App | Decoupled + CDN | CDNs provide global edge caching, DDoS protection, and offload static traffic from the backend. | High (CDN costs, complex infra) |
| Edge / IoT Device | Embedded Binary | Low memory footprint; no runtime dependencies; runs on minimal hardware. | Low (Efficient resource usage) |
| Micro-Frontends | Decoupled | Independent deployment cycles for different UI modules require separate hosting. | High (Orchestration complexity) |
Configuration Template
Makefile
.PHONY: build dev clean
build:
@echo "Building frontend..."
cd web && npm install && npm run build
@echo "Building Go binary..."
go build -o bin/server ./cmd/server
dev:
@echo "Starting development environment..."
@# Run frontend and backend concurrently
(cd web && npm run dev) &
(cd cmd/server && air)
clean:
rm -rf web/dist bin/server
Dockerfile
# Build stage for frontend
FROM node:20-alpine AS frontend-builder
WORKDIR /app/web
COPY web/package*.json ./
RUN npm ci
COPY web/ .
RUN npm run build
# Build stage for Go binary
FROM golang:1.22-alpine AS go-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Copy frontend build output into the location expected by embed
COPY --from=frontend-builder /app/web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
# Production stage
FROM scratch
COPY --from=go-builder /server /server
ENTRYPOINT ["/server"]
Quick Start Guide
- Initialize: Create a Go module and a Vite React app in the
web/subdirectory. - Add Embed Code: Create
internal/ui/ui_bundle.gowith the//go:embeddirective andfs.Sublogic. - Wire Router: Implement the
SPAHandlerwith fallback logic and register it inmain.goafter API routes. - Configure Proxy: Update
web/vite.config.tsto proxy/apitohttp://localhost:3000. - Build & Run: Execute
make buildto generate the binary, then run./bin/serverto serve the full-stack application on port 3000.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
