Shipping a React + Go App as a Single Binary

Published on

Most tutorials teach you to build a React frontend and a Go backend as two separate things - two repos, two deployments, two Docker containers. But you don’t have to. Go has a feature called //go:embed that bakes your entire compiled frontend into the Go binary at build time. The result is a single executable file that serves both your API and your React app.

What you will learn:

  • How to structure a monorepo with a React frontend and Go backend
  • How to compile React and embed it inside a Go binary using //go:embed
  • How to handle SPA routing from Go so React Router works correctly
  • How to write a GitHub Actions pipeline that builds and releases the binary
  • How to package everything in a minimal Docker image

Prerequisites: Basic familiarity with Go and React. You don’t need to be an expert in either.

Table of Contents

  1. The Core Idea
  2. Repository Structure
  3. The React Frontend
  4. The Go Backend
  5. Embedding the Frontend in Go
  6. The Build Pipeline
  7. GitHub Actions: CI and Release
  8. Docker: A Minimal Final Image
  9. Key Takeaways

1. The Core Idea

Go 1.16 introduced the //go:embed directive. It tells the Go compiler to read files from your disk at compile time and bundle them into the binary. At runtime, your program can read those files from memory - no disk access required.

This means you can:

  1. Build your React app: pnpm build —> produces a dist/ folder
  2. Copy dist/ into your Go source tree
  3. Run go build - the compiler embeds the entire dist/ folder into the binary

The resulting binary contains your API server and every HTML, JS, and CSS file your frontend needs. Deploy it to any machine, run it, and it just works.

2. Repository Structure

A clean monorepo layout keeps the two sides separate. They share a repository but do not share code - they only meet at build time.

myapp/
├── .github/
│   └── workflows/
│       ├── ci.yml          # Run checks on every push and PR
│       └── release.yml     # Build and publish binaries on tags
├── backend/                # Go source code
│   ├── main.go             # Entry point, routing, frontend serving
│   ├── go.mod
│   ├── go.sum
│   ├── handlers/           # HTTP route handlers
│   └── ui/
│       └── dist/           # Compiled React app lives here (embedded by Go)
├── ui/                     # React source code
│   ├── package.json
│   ├── vite.config.ts
│   ├── src/
│   └── dist/               # Build output (copied to backend/ui/dist)
├── Makefile
└── Dockerfile

The key insight: ui/ and backend/ are completely decoupled during development. When you build for production, you copy ui/dist/ into backend/ui/dist/ before running go build. The Go compiler embeds that folder at that moment.

3. The React Frontend

The frontend is a standard React + TypeScript app built with Vite.

Vite configuration

// ui/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
	plugins: [react()]
});

Running pnpm build produces a ui/dist/ folder with:

  • index.html - the app shell
  • assets/ - hashed JS and CSS bundles

This is a Single Page Application (SPA).

Calling the API

The frontend talks to the Go backend over HTTP. During development, you point it at localhost:8080. In production, the Go server serves the frontend itself, so all requests go to the same origin and you use relative URLs:

// ui/src/lib/api.ts
async function request<T>(path: string, init?: RequestInit): Promise<T> {
	const res = await fetch(`/api${path}`, init);
	if (!res.ok) throw new Error(await res.text());
	return res.json() as Promise<T>;
}

export const items = {
	list: () => request<Item[]>('/items'),

	create: (data: { name: string }) =>
		request<Item>('/items', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify(data)
		}),

	delete: (id: string) => request(`/items/${id}`, { method: 'DELETE' })
};

4. The Go Backend

The backend is a standard Go HTTP server. It has two jobs:

  1. Handle API requests under /api/...
  2. Serve the React app for everything else

5. Embedding the Frontend in Go

This is the heart of the tutorial. Here is the complete main.go:

package main

import (
  "embed"
  "io/fs"
  "net/http"
  "strings"

  "github.com/labstack/echo/v4"
  "github.com/labstack/echo/v4/middleware"
)

//go:embed all:ui/dist
var embeddedFiles embed.FS

func main() {
  e := echo.New()
  e.Use(middleware.Logger())
  e.Use(middleware.Recover())

  // API routes
  h := &Handler{DB: openDB()}
  e.GET("/api/items", h.ListItems)
  e.POST("/api/items", h.CreateItem)
  e.DELETE("/api/items/:id", h.DeleteItem)

  // Serve the embedded React app for everything else
  serveFrontend(e)

  e.Logger.Fatal(e.Start(":8080"))
}

func serveFrontend(e *echo.Echo) {
	// Serve embedded frontend; unknown paths fall back to index.html for client-side routing.
	distFS, err := fs.Sub(uiFiles, "ui/dist")
	if err != nil {
		log.Fatalf("sub ui/dist: %v", err)
	}
	e.GET("/*", func(c echo.Context) error {
		path := strings.TrimPrefix(c.Request().URL.Path, "/")
		if _, statErr := fs.Stat(distFS, path); path == "" || statErr != nil {
			http.ServeFileFS(c.Response(), c.Request(), distFS, "index.html")
			return nil
		}
		http.ServeFileFS(c.Response(), c.Request(), distFS, path)
		return nil
	})
}

Breaking it down

//go:embed all:ui/dist

This single line is the magic. At compile time the Go toolchain reads every file inside ui/dist/ and stores them in embeddedFiles. The all: prefix includes hidden files (dotfiles). At runtime, embeddedFiles behaves like a read-only filesystem backed by memory.

fs.Sub(embeddedFiles, "ui/dist")

Strips the ui/dist prefix so you refer to files as index.html instead of ui/dist/index.html.

The SPA fallback

This part is easy to miss but critical. A React SPA has routes like /dashboard or /settings, but those paths don’t correspond to real files. When a user refreshes /dashboard, Go looks for that file in the embedded FS, doesn’t find it, and falls back to serving index.html. React boots, reads the URL from the browser, and renders the right component.

Without this fallback, refreshing any route except / returns a 404.

6. The Build Pipeline

Development workflow

Configure a proxy in vite.config.ts to forward /api requests to Go:

// ui/vite.config.ts
export default defineConfig({
	plugins: [react()],
	server: {
		proxy: {
			'/api': 'http://localhost:8080'
		}
	}
});

Now the frontend at localhost:5173 talks to the Go API at localhost:8080. You get hot reload for React and a fast feedback loop for Go without any embedding step.

Production workflow

One binary, one command.

7. GitHub Actions: CI and Release

CI: validate every push

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  ui:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
        working-directory: ui
      - run: npm run typecheck
        working-directory: ui
      - run: npm run build
        working-directory: ui

  backend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version-file: backend/go.mod }
      - run: go vet ./...
        working-directory: backend
      - run: go test ./...
        working-directory: backend

The two jobs run in parallel. The UI job type-checks TypeScript and verifies the React build succeeds. The backend job vets and tests Go. Neither depends on the other.

Release: build binaries on tags

When you push a tag like v1.0.0, the release workflow runs the full build sequence and attaches the binary to a GitHub Release:

# .github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - goos: linux
            goarch: amd64
          - goos: windows
            goarch: amd64
          - goos: darwin
            goarch: arm64

    steps:
      - uses: actions/checkout@v4

      # Step 1: build the React app
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci && npm run build
        working-directory: ui

      # Step 2: copy the output into the Go source tree
      - run: cp -r ui/dist backend/ui/dist

      # Step 3: compile the Go binary (cross-compile, fully static)
      - uses: actions/setup-go@v5
        with: { go-version-file: backend/go.mod }
      - name: Build
        env:
          GOOS: ${{ matrix.goos }}
          GOARCH: ${{ matrix.goarch }}
          CGO_ENABLED: '0'
        run: |
          go build \
            -ldflags="-s -w -X main.version=${{ github.ref_name }}" \
            -o ../myapp-${{ matrix.goos }}-${{ matrix.goarch }} \
            .
        working-directory: backend

      - uses: actions/upload-artifact@v4
        with:
          name: myapp-${{ matrix.goos }}-${{ matrix.goarch }}
          path: myapp-${{ matrix.goos }}-${{ matrix.goarch }}

  release:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
      - uses: softprops/action-gh-release@v2
        with:
          files: '**/*'

Two flags worth understanding:

CGO_ENABLED=0 disables C interop entirely. Combined with a pure-Go SQLite driver, the binary has zero dynamic library dependencies. You can copy it to any Linux machine - regardless of what shared libraries are installed - and it runs.

-ldflags="-s -w" strips the debug symbol table (-s) and DWARF debugging info (-w). This typically cuts binary size by 30–50%.

After the workflow runs, anyone can go to your GitHub Releases page, download one file for their platform, and run it.

8. Docker: A Minimal Final Image

Even though a single binary is easy to deploy on its own, Docker is useful for orchestrated environments. Use a three-stage build:

# Stage 1: Build the React app
FROM node:22-alpine AS ui-builder
WORKDIR /app/ui
COPY ui/package.json ui/package-lock.json ./
RUN npm ci
COPY ui/ ./
RUN npm run build

# Stage 2: Build the Go binary
FROM golang:1.23-alpine AS go-builder
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
COPY --from=ui-builder /app/ui/dist ./ui/dist
RUN CGO_ENABLED=0 GOOS=linux \
    go build -ldflags="-s -w" -o /myapp .

# Stage 3: Minimal runtime image
FROM gcr.io/distroless/static-debian12
COPY --from=go-builder /myapp /myapp
EXPOSE 8080
ENTRYPOINT ["/myapp"]

Each stage discards everything that was needed only to build. The final image contains only the distroless base and your binary.

Why distroless?

gcr.io/distroless/static-debian12 is a base image with no shell, no package manager, and no utilities - only the bare minimum needed to run a statically linked binary. The image is around 2 MB. There is nothing to exploit because there is nothing there.

The final image size is roughly: 2 MB base + size of your binary. Compare that to ~200 MB for a standard Debian image.

# Build
docker build -t myapp:latest .

# Run
docker run -p 8080:8080 myapp:latest

9. Key Takeaways

//go:embed is the whole trick. One directive bundles a folder into your binary at compile time. Go handles serving, MIME types, and caching headers automatically via http.FileServer.

The SPA fallback is non-negotiable. React Router (or Tanstack router) handles navigation in the browser, but Go responds to HTTP requests. Without the index.html fallback, refreshing any route other than / returns a 404. Implement it and forget about it.

CGO_ENABLED=0 unlocks cross-compilation. Choose a pure-Go database driver and disable CGO. Now you can build a Linux binary from any machine in CI without installing a cross-compilation toolchain.

The monorepo layout keeps concerns separate. ui/ and backend/ never import each other. They meet only at build time when the compiled frontend is copied into the Go source tree. During development, each runs independently with its own tooling.

GitHub Actions makes this repeatable. Tag a commit, wait a few minutes, and your users can download a self-contained binary for their platform. No runtime installation required.

Distroless minimizes your attack surface. A statically linked Go binary plus a 2 MB base image is the smallest footprint you can have in a container. No shell means no shell exploits.

Further Reading