4
Go + React SPA
my setup for go + react projects
LLucas Oliveira
·Jan 8, 2026·28 views# Go + React SPA
htmx is great until you need a dashboard with 15 components updating at different intervals. Then you need this.
## When to Use This Instead of htmx
- Real-time dashboards with complex state
- Heavy client-side filtering/sorting
- Drag and drop interfaces
- Offline-first requirements
- Mobile app sharing code with web
If your app is mostly forms and tables, use htmx. If it's a control panel with live charts, use this.
## Tech Stack
- **Backend**: Go 1.22+ with chi router
- **Frontend**: React 18 + TypeScript + Vite
- **State**: TanStack Query (server state) + Zustand (client state)
- **Styling**: Tailwind CSS
- **API**: REST with OpenAPI spec
## Project Structure
```
.
├── api/
│ ├── cmd/server/main.go
│ ├── internal/
│ │ ├── handler/
│ │ ├── service/
│ │ └── database/
│ ├── openapi.yaml
│ └── go.mod
├── web/
│ ├── src/
│ │ ├── api/ # Generated from OpenAPI
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── pages/
│ │ └── stores/
│ ├── package.json
│ └── vite.config.ts
├── docker-compose.yml
└── Makefile
```
## Commands
```bash
# Development
make dev # Start both API and frontend
make api # API only (port 8080)
make web # Frontend only (port 5173)
# Code generation
make generate # Generate API client from OpenAPI
# Testing
make test # All tests
make test-api # Backend tests
make test-web # Frontend tests
# Production
make build # Build both
make docker # Docker image
```
## API Design
OpenAPI spec is the contract. Generate both server stubs and client.
```yaml
# openapi.yaml
openapi: 3.0.3
info:
title: Dashboard API
version: 1.0.0
paths:
/api/metrics:
get:
operationId: getMetrics
parameters:
- name: from
in: query
schema:
type: string
format: date-time
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/MetricsResponse'
```
Generate client:
```bash
npx openapi-typescript openapi.yaml -o web/src/api/schema.d.ts
```
## Backend Patterns
Keep handlers thin. Business logic in services.
```go
func (h *Handler) GetMetrics(w http.ResponseWriter, r *http.Request) {
from := r.URL.Query().Get("from")
metrics, err := h.metricsService.Get(r.Context(), from)
if err != nil {
h.error(w, err)
return
}
h.json(w, metrics)
}
```
## Frontend Patterns
### Data Fetching with TanStack Query
```typescript
export function useMetrics(from: Date) {
return useQuery({
queryKey: ['metrics', from],
queryFn: () => api.getMetrics({ from: from.toISOString() }),
refetchInterval: 30_000, // Poll every 30s
});
}
```
### Component Structure
```typescript
function MetricsPanel() {
const { data, isLoading, error } = useMetrics(startOfDay(new Date()));
if (isLoading) return <Skeleton />;
if (error) return <ErrorState error={error} />;
return (
<div className="grid grid-cols-3 gap-4">
{data.metrics.map(m => (
<MetricCard key={m.id} metric={m} />
))}
</div>
);
}
```
### Client State with Zustand
For UI state that doesn't come from server:
```typescript
interface DashboardStore {
selectedTimeRange: TimeRange;
setTimeRange: (range: TimeRange) => void;
sidebarOpen: boolean;
toggleSidebar: () => void;
}
export const useDashboardStore = create<DashboardStore>((set) => ({
selectedTimeRange: '24h',
setTimeRange: (range) => set({ selectedTimeRange: range }),
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));
```
## WebSocket for Real-time
When polling is not fast enough:
```go
// Backend
func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for metric := range h.metricsStream {
conn.WriteJSON(metric)
}
}
```
```typescript
// Frontend
function useRealtimeMetrics() {
const queryClient = useQueryClient();
useEffect(() => {
const ws = new WebSocket('ws://localhost:8080/ws/metrics');
ws.onmessage = (event) => {
const metric = JSON.parse(event.data);
queryClient.setQueryData(['metrics'], (old) => ({
...old,
metrics: [...old.metrics, metric],
}));
};
return () => ws.close();
}, []);
}
```
## Deployment
Single binary serves both API and static files:
```go
//go:embed all:dist
var staticFiles embed.FS
func main() {
r := chi.NewRouter()
// API routes
r.Route("/api", func(r chi.Router) {
r.Get("/metrics", handler.GetMetrics)
})
// Static files
r.Handle("/*", http.FileServer(http.FS(staticFiles)))
http.ListenAndServe(":8080", r)
}
```
Build produces one Docker image, one binary. Easy to deploy.
## When to Switch Back to htmx
If you find yourself with:
- Mostly server-rendered pages
- Simple forms
- Minimal client state
Consider htmx. Simpler stack, less JavaScript, faster initial load.
This setup is for when you actually need the complexity.
Comments
No comments yet
Be the first to share your thoughts!