Step-by-step guide to scaffold a React/RTK Query web app with Vite and build it using a Dagger-powered go:generate hook, served by a Go binary.
This guide shows how to create a small, production-friendly web frontend (React + RTK Query + Vite) and bundle it with a Go backend using a self-contained Dagger build. The Go backend exposes a go:generate hook to produce the static dist/ assets without requiring Node to be installed locally. You will finish with a single Go command that serves the built SPA and a few API endpoints.
For writing style and structure, see:
glaze help how-to-write-good-documentation-pages
The approach assumes:
go get dagger.io/dagger@latest)Basic familiarity with React and Go web servers is helpful.
The following layout keeps frontend, builder, and server cohesive:
your-repo/
cmd/
app/
main.go # Go entrypoint (serves API + static)
gen.go # go:generate hook → runs ../build-web
dist/ # output of the Vite build (generated)
build-web/
main.go # Dagger builder for web/
web/
index.html
vite.config.ts
package.json
tsconfig.json
src/
main.tsx
store.ts
api.ts # RTK Query base slice
views/
Home.tsx
Health.tsx
You can adapt names (e.g., cmd/app) to your project.
Create a minimal Vite React app in web/.
web/package.json:
{
"name": "my-web",
"private": true,
"version": "0.0.1",
"type": "module",
"packageManager": "pnpm@10.15.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5173"
},
"dependencies": {
"@reduxjs/toolkit": "^2.2.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-redux": "^9.0.0",
"react-router-dom": "^6.22.3"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.4.0"
}
}
web/vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: { outDir: 'dist', sourcemap: false },
server: { port: 5173 }
})
web/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
web/src/main.tsx:
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './store'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Home } from './views/Home'
import { Health } from './views/Health'
const root = createRoot(document.getElementById('root')!)
root.render(
<React.StrictMode>
<Provider store={store}>
<BrowserRouter>
<div style={{ padding: 16 }}>
<div style={{ float: 'right' }}><Health /></div>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</div>
</BrowserRouter>
</Provider>
</React.StrictMode>
)
web/src/store.ts:
import { configureStore } from '@reduxjs/toolkit'
import { api } from './api'
export const store = configureStore({
reducer: { [api.reducerPath]: api.reducer },
middleware: (gDM) => gDM().concat(api.middleware)
})
web/src/api.ts:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (b) => ({
health: b.query<{ ok: boolean }, void>({ query: () => '/health' })
})
})
export const { useHealthQuery } = api
web/src/views/Health.tsx:
import React from 'react'
import { useHealthQuery } from '../api'
export const Health: React.FC = () => {
const { data, isLoading, isError } = useHealthQuery()
if (isLoading) return <span>…</span>
if (isError) return <span style={{ color: 'red' }}>Server DOWN</span>
return <span style={{ color: data?.ok ? 'green' : 'red' }}>{data?.ok ? 'Server OK' : 'Server DOWN'}</span>
}
web/src/views/Home.tsx:
import React from 'react'
export const Home: React.FC = () => (
<main>
<h1>My App</h1>
<p>Starter UI using React + RTK Query + Vite.</p>
</main>
)
Create a Go program to build web/ inside a container and export the dist/ output to your server directory. Place it at cmd/build-web/main.go.
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"dagger.io/dagger"
)
func main() {
pnpmVersion := os.Getenv("WEB_PNPM_VERSION")
if pnpmVersion == "" { pnpmVersion = "10.15.0" }
ctx := context.Background()
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout))
if err != nil { log.Fatalf("connect dagger: %v", err) }
defer client.Close()
// repo root assumed two levels up from here: cmd/build-web → repo/
wd, _ := os.Getwd()
repoRoot := filepath.Dir(filepath.Dir(wd))
webPath := filepath.Join(repoRoot, "web")
outPath := filepath.Join(filepath.Dir(wd), "app", "dist") // cmd/app/dist
base := client.Container().From("node:22")
if bi := os.Getenv("WEB_BUILDER_IMAGE"); bi != "" { base = client.Container().From(bi) }
webDir := client.Host().Directory(webPath)
ctr := base.
WithWorkdir("/src").
WithMountedDirectory("/src", webDir).
WithEnvVariable("PNPM_HOME", "/pnpm")
// Use Corepack to pin pnpm
if os.Getenv("WEB_BUILDER_IMAGE") == "" || !strings.Contains(os.Getenv("WEB_BUILDER_IMAGE"), ":") {
ctr = ctr.WithExec([]string{"sh", "-lc", fmt.Sprintf("corepack enable && corepack prepare pnpm@%s --activate", pnpmVersion)})
}
ctr = ctr.
WithExec([]string{"sh", "-lc", "pnpm --version"}).
WithExec([]string{"sh", "-lc", "pnpm install --reporter=append-only"}).
WithExec([]string{"sh", "-lc", "pnpm build"})
dist := ctr.Directory("/src/dist")
if _, err := dist.Export(ctx, outPath); err != nil {
log.Fatalf("export dist: %v", err)
}
log.Printf("exported web dist to %s", outPath)
}
Environment variables supported:
WEB_PNPM_VERSION (default 10.15.0)WEB_BUILDER_IMAGE (e.g., node:22 or a pinned digest)PNPM_CACHE_DIR (optional: mount a host dir as pnpm store)REGISTRY_USER/REGISTRY_TOKEN (optional for authenticated registries)Add cmd/app/gen.go to integrate the builder into your Go build flow.
//go:generate go run ../build-web
package main
Running go generate ./cmd/app will execute the Dagger builder and write to cmd/app/dist/.
Add a minimal Go HTTP server in cmd/app/main.go. It serves the built SPA and exposes a health endpoint. You can integrate with any CLI framework (or Glazed/Cobra if you already use it).
package main
import (
"flag"
"log"
"net/http"
"os"
"path/filepath"
)
func main() {
root := flag.String("root", "./dist", "path to built web assets")
addr := flag.String("addr", ":8088", "listen address")
flag.Parse()
mux := http.NewServeMux()
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
})
abs, err := filepath.Abs(*root)
if err != nil { log.Fatalf("resolve root: %v", err) }
if _, err := os.Stat(abs); err != nil {
log.Printf("warning: web dist not found at %s", abs)
}
mux.Handle("/", http.FileServer(http.Dir(abs)))
log.Printf("serving on %s (web from %s)", *addr, abs)
log.Fatal(http.ListenAndServe(*addr, mux))
}
cd cmd/app
go generate
go run . serve --addr :8088 --root ./dist
# or if using the simple flag-based server above:
go run . --addr :8088 --root ./dist
curl -s localhost:8088/api/health | jq
open http://localhost:8088/
Using tmux lets you keep the server running while you iterate:
tmux kill-session -t web || true
tmux new-session -d -s web 'cd cmd/app && go run . --addr :8088 --root ./dist'
tmux attach -t web # Ctrl-b d to detach
tmux kill-session -t web
go generate ./cmd/app in CI to produce the dist/ artifacts before building your binary.PNPM_CACHE_DIR to a CI cache path).dist/ directory in release artifacts or bake it into your container image.WEB_PNPM_VERSION or pre-bake pnpm in a custom WEB_BUILDER_IMAGE.cmd/app/dist/ exists and contains index.html and an assets/ folder.--root ./dist to the server.web/src/api.ts.store.ts for UI features.