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!