How to Serve an SPA (React/Vue) from a Go Backend

Web
Serve a React or Vue SPA from Go by mounting a static file server and adding a fallback handler to return index.html for all non-file routes.

The refresh problem

You finish building a React dashboard. It runs perfectly on localhost:3000. You bundle it, drop the dist folder next to your Go backend, and start the server. You navigate to /dashboard. The browser shows a 404. You refresh /users/42. Another 404. The frontend router expects to catch every path and render the right component. Go's default file server only knows about physical files on disk. The mismatch breaks the app.

Single page applications work by loading a single index.html file and letting JavaScript handle navigation. When a user clicks a link, the browser updates the URL bar without asking the server for a new page. The JavaScript router reads the URL and swaps components. This means every route in the app should resolve to the same HTML file. Go's net/http package provides http.FileServer to serve static assets. It is strict by design. It looks for a file or directory that exactly matches the request path. If the path does not exist, it returns a 404. To make an SPA work, you need a fallback mechanism that distinguishes between requests for static assets and requests for client-side routes. Assets have file extensions. Routes usually do not. The handler checks the extension. If it finds one, it delegates to the file server. If it finds none, it serves index.html.

How client-side routing actually works

Think of the browser as a theater. The server hands the audience one script (index.html) and a stack of props (JavaScript, CSS, images). Once the curtain rises, the actors (your frontend framework) decide which scenes to perform based on the program in the audience's hands (the URL). The server never sees the scene changes. It only cares about delivering the initial script and the props.

When a user clicks a link inside the app, the JavaScript intercepts the click, updates the browser history API, and renders the new view. No network request happens. The server is completely unaware of the navigation. This is fast and efficient. It also creates the refresh trap. When a user hits F5 or types /dashboard directly into the address bar, the browser bypasses the JavaScript router and asks the server for that exact path. The server looks for a file named dashboard in the dist folder. It does not exist. The server returns 404. The app breaks.

The solution is a middleware pattern that sits between the browser and the file system. It inspects the incoming request. If the path looks like a static asset, it passes the request to the file server. If the path looks like a client route, it serves the entry HTML file. The browser receives the HTML, loads the JavaScript, and the router takes over. The user sees the dashboard without the server ever knowing what a dashboard is.

The minimal fallback handler

Here is the simplest working pattern. It mounts a custom handler that inspects the URL path and routes the request accordingly.

package main

import (
	"net/http"
	"path/filepath"
)

func main() {
	// Serve static files from the ./dist directory
	fs := http.FileServer(http.Dir("./dist"))

	// Create a new mux to handle routing
	mux := http.NewServeMux()

	// Intercept all requests to apply the SPA fallback logic
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// Check if the requested path has a file extension
		if filepath.Ext(r.URL.Path) != "" {
			// Delegate to the static file server for assets
			fs.ServeHTTP(w, r)
			return
		}
		// No extension means it is a client-side route. Serve the SPA entry point.
		http.ServeFile(w, r, "./dist/index.html")
	})

	// Start the HTTP server on port 8080
	http.ListenAndServe(":8080", mux)
}

When a request arrives, the custom handler runs first. It extracts the file extension using filepath.Ext. If the path is /static/js/main.js, the extension is .js. The condition passes, and fs.ServeHTTP handles the request. The file server reads the bundle from disk and streams it back with the correct content type.

If the path is /dashboard, filepath.Ext returns an empty string. The condition fails. The handler calls http.ServeFile to return ./dist/index.html. The browser receives the HTML, parses it, loads the JavaScript bundle, and the frontend router takes over. The user sees the dashboard without the server ever knowing what a dashboard is.

This pattern works because client-side routing happens entirely in the browser. The server only cares about delivering the initial HTML shell and the static assets that power it. Everything else is handled by the JavaScript runtime. The standard library handles the heavy lifting. You do not need third-party routing packages to solve this.

What happens under the hood

Go's http.FileServer wraps a directory in an http.Handler. When ServeHTTP is called, it uses os.Stat to check if the requested path exists on disk. It reads the file, sets the Content-Type header based on the file extension, and writes the bytes to the response. If the file does not exist, it writes a 404 status and a default error page.

The fallback handler intercepts this flow. By checking filepath.Ext, you create a pragmatic heuristic. Most static assets end in .js, .css, .png, .woff2, or .ico. Most client-side routes do not. This heuristic is not perfect. A route like /api/v1 has no extension and will correctly fall back to index.html. A route like /blog/post.md has an extension and will incorrectly try to serve a markdown file. You can adjust the check to match your build output. The key is that the server never needs to understand your frontend routing logic. It only needs to know what looks like a file.

Path cleaning is another hidden detail. Browsers sometimes send paths with trailing slashes or redundant separators. http.FileServer expects clean paths. If you pass /dist//assets/./image.png, the file server may fail to match the file. The standard library does not automatically clean paths for you. You need to normalize them before delegating. This prevents subtle 404 bugs that only appear in production.

Production-ready pattern

The minimal example works for development, but production deployments need a few adjustments. Real applications handle trailing slashes, path cleaning, and caching headers. The standard library provides tools to keep paths safe and predictable.

Here is a more robust handler that cleans the URL path, respects trailing slashes, and sets cache control headers for static assets.

package main

import (
	"net/http"
	"path/filepath"
	"strings"
)

// spaHandler serves static files or falls back to index.html for client routes.
func spaHandler(fs http.Handler) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// Clean the path to prevent directory traversal and normalize slashes
		cleanPath := filepath.Clean(r.URL.Path)
		r.URL.Path = cleanPath

		// Check if the path points to a static asset by looking for an extension
		if strings.Contains(cleanPath, ".") {
			// Serve the asset through the file server
			fs.ServeHTTP(w, r)
			return
		}

		// Fallback to the SPA entry point for all other routes
		http.ServeFile(w, r, "./dist/index.html")
	}
}

func main() {
	// Wrap the directory in a file server handler
	fs := http.FileServer(http.Dir("./dist"))

	// Create the mux and wrap the file server with the SPA handler
	mux := http.NewServeMux()
	mux.Handle("/", spaHandler(fs))

	// Listen on port 8080
	http.ListenAndServe(":8080", mux)
}

The filepath.Clean call strips redundant separators and resolves .. sequences. This prevents a request like /static/../config.json from escaping the dist directory. The strings.Contains check is slightly broader than filepath.Ext because it catches paths like /favicon.ico or /assets/image.png without relying on strict extension parsing. Wrapping the logic in a spaHandler function keeps the main function clean and follows the Go convention of keeping handler logic modular.

Go developers follow a few conventions that make this pattern idiomatic. The gofmt tool formats the code automatically. Most editors run it on save. Do not argue about indentation or brace placement. Let the tool decide. The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. If you wrap http.ServeFile in a custom function that returns an error, handle it explicitly. Discarding an error with _ signals that you considered the return value and chose to drop it. Use it sparingly. Public names start with a capital letter. Private names start lowercase. The handler function is lowercase because it is only used within this package. The http.Handler interface is public because it is part of the standard library. Accept interfaces, return structs. This handler returns an http.HandlerFunc, which implements http.Handler. The type system does the heavy lifting.

Common traps and compiler feedback

Serving SPAs introduces a few common traps. The most frequent issue is the browser refresh. When a user refreshes /dashboard, the browser sends a direct GET request to the server. If the fallback handler is missing, the server returns a 404. The pattern above fixes this by catching extensionless paths.

Another trap is caching. Browsers aggressively cache static assets. If you deploy a new JavaScript bundle but keep the same filename, users might load stale code. Production builds usually append a content hash to filenames, like main.a1b2c3.js. The file server handles these correctly, but you need to ensure your build tool generates unique names for each deployment. You can also set Cache-Control headers on the response writer to control how long the browser keeps assets.

Path handling can also trigger compiler or runtime warnings. If you forget to clean the URL path, the file server may reject requests with http: superfluous 200 response body or fail to match files due to trailing slashes. The http.Dir wrapper expects clean paths. Passing a malformed path often results in a 404 that looks like a missing file rather than a routing bug.

Error handling in the fallback route is another consideration. http.ServeFile writes a 404 automatically if index.html is missing. If you want custom error pages, you need to check the error return value and write your own response. The compiler will complain with declared and not used if you capture the error but ignore it, so handle it explicitly or discard it with _ if you trust the build process. The compiler rejects the program with loop variable i captured by func literal if you accidentally capture a loop variable in a closure, though this pattern does not use loops. It is a common Go 1.22+ change that catches subtle concurrency bugs.

Context management is often overlooked in static serving. The http.Request carries a context.Context that handles cancellation and deadlines. If you spawn background goroutines to process requests, pass the context through. Functions that take a context should respect cancellation. For simple file serving, the standard library handles this automatically. You do not need to manually manage contexts unless you add custom middleware or database calls.

When to pick this approach

Use a custom fallback handler when you need a simple, zero-dependency way to serve a React or Vue app alongside a Go backend. Use a reverse proxy like Nginx or Caddy when you need advanced caching, compression, or SSL termination in front of your Go server. Use an embedded binary when you want a single deployable artifact without external directories. Use server-side rendering when your application requires fast initial page loads or search engine optimization that client-side routing cannot provide.

Keep the routing logic on the client. Let Go serve files and fall back to HTML.

Where to go next