Project Structure

How to organize a GO(A)HT project around Go handlers, templates, and shared libs.

Start with Go’s normal package boundaries, then organize around response surfaces. Pages, HTML fragments, JSON islands, and SSE streams can all live in one app without splitting into a frontend and backend.

Single App Layout

Use the same starter layout introduced in Getting Started, then add response-surface conventions inside it.

myapp/
├── cmd/
│   └── server/
│       └── main.go          # Entry point
├── internal/
│   ├── handler/              # HTTP handlers
│   │   ├── game_page.go      # Full-page HTML handlers
│   │   ├── game_partial.go   # Fragment handlers for HTMX
│   │   ├── game_api.go       # JSON for Alpine islands
│   │   └── game_sse.go       # SSE stream handlers
│   ├── service/              # Business logic
│   ├── middleware/           # Auth, logging
│   └── model/                # sqlc generated code
├── templates/
│   ├── layouts/
│   │   └── app.html          # Base layout
│   ├── pages/
│   │   ├── lobby.html
│   │   └── game.html
│   └── partials/
│       ├── lobby_status.html
│       ├── scoreboard.html
│       └── team_row.html
├── styles/
│   └── main.css             # Tailwind entry point
├── static/
│   ├── app.css              # Built CSS
│   └── vendor/
│       ├── htmx.min.js
│       ├── htmx-sse.js      # Required for `hx-ext="sse"`
│       └── alpine.min.js
├── model/
│   ├── migrations/          # SQL migrations
│   ├── queries.sql          # sqlc queries
│   └── sqlc.yaml            # sqlc config
└── go.mod

What Goes Where

  • cmd/server wires dependencies and starts the app. Keep it thin.
  • internal/handler owns the transport layer. Split files by response surface when that keeps intent clear.
  • internal/service owns state transitions, validation, and coordination with storage.
  • internal/model holds generated database access code.
  • templates/pages render full documents.
  • templates/partials render HTML fragments. HTML fragments are the primary UI API.
  • static/vendor/htmx-sse.js is needed if you use HTMX’s hx-ext="sse" pattern from the SSE docs.

This keeps the main flow obvious:

  1. A handler loads or mutates state through a service.
  2. The handler returns one response surface: page HTML, partial HTML, JSON, or SSE.
  3. Templates stay close to the HTML the browser actually swaps.

Route and File Naming

Prefer names that tell you which surface a handler returns:

  • games_page.go for GET /games/{id}
  • games_partial.go for GET /games/{id}/scoreboard
  • games_api.go for GET /api/games/{id}/state
  • games_sse.go for GET /games/{id}/events

That naming mirrors BagTrax-style apps: most user-facing work stays in page and partial handlers, while JSON and SSE stay narrowly scoped.

Monorepo and Shared Libs

The same structure scales to a monorepo. Keep each app self-contained, then extract only real shared code into libs.

repo/
├── apps/
│   ├── bagtrax/
│   │   ├── cmd/server/main.go
│   │   ├── internal/
│   │   └── templates/
│   └── marketing/
│       ├── cmd/server/main.go
│       ├── internal/
│       └── templates/
├── libs/
│   ├── authkit/                # Auth/session helpers
│   ├── ssehub/                 # Reusable SSE fanout/publish code
│   ├── web/
│   │   ├── render.go           # Shared template/render helpers
│   │   └── json.go             # Shared JSON response helpers
│   └── postgres/
│       └── tx.go
└── go.work

Rules that keep shared libs useful:

  • Share infrastructure, not product-specific templates.
  • Keep app templates inside each app, even in a monorepo.
  • Extract shared response helpers only after at least two apps need them.
  • Let each app choose how much Alpine it needs; do not force JSON endpoints into every app.

Practical Split Points

Start small. One app can begin with:

  • one routes.go
  • one internal/handler package
  • one service/ package
  • templates/layouts, templates/pages, and templates/partials

Split further when one of these becomes true:

  • page handlers and fragment handlers are hard to scan in one file
  • Alpine islands need a small JSON surface of their own
  • SSE publishing and subscription logic is reused across domains
  • a second app needs the same auth, rendering, or SSE plumbing

The goal is not maximum abstraction. The goal is a Go-first, HTML-first layout that makes each response surface easy to find.