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/serverwires dependencies and starts the app. Keep it thin.internal/handlerowns the transport layer. Split files by response surface when that keeps intent clear.internal/serviceowns state transitions, validation, and coordination with storage.internal/modelholds generated database access code.templates/pagesrender full documents.templates/partialsrender HTML fragments. HTML fragments are the primary UI API.static/vendor/htmx-sse.jsis needed if you use HTMX’shx-ext="sse"pattern from the SSE docs.
This keeps the main flow obvious:
- A handler loads or mutates state through a service.
- The handler returns one response surface: page HTML, partial HTML, JSON, or SSE.
- 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.goforGET /games/{id}games_partial.goforGET /games/{id}/scoreboardgames_api.goforGET /api/games/{id}/stategames_sse.goforGET /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/handlerpackage - one
service/package templates/layouts,templates/pages, andtemplates/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.