Routing Guide
This guide explains Tracks' routing architecture, which emphasizes HYPERMEDIA-first development with domain-based organization and type-safe route patterns.
HYPERMEDIA-First Philosophy
Tracks generates applications that serve HTML by default using templ templates. This is the opposite of most modern frameworks that default to JSON APIs:
Default (HYPERMEDIA):
- Routes serve HTML pages via templ templates
- Forms submit to endpoints that process and redirect
- Progressive enhancement with HTMX for interactivity
- Server-rendered, accessible by default
Exception (JSON APIs):
- Only for specific use cases (health checks, webhooks, metrics)
- Generated with
--apiflag when needed - Always prefixed with
/api/for clarity
This approach provides:
- Simpler development - No separate frontend/backend coordination
- Better performance - No SPA JavaScript bundle overhead
- Accessibility - HTML works everywhere, JavaScript enhances
- SEO-friendly - Content is server-rendered
Domain-Based Route Organization
Routes are organized by domain into separate files under internal/http/routes/:
internal/http/routes/
├── routes.go # Shared routes (sitemap, robots.txt, API prefix)
├── health.go # Health check domain (simple, API endpoint)
└── users.go # User domain (complex, HYPERMEDIA routes with helpers)
Each domain file defines its own route constants and helper functions.
Why Domain-Based Organization?
- Scalability - Each domain is self-contained
- Discoverability - Easy to find all routes for a domain
- Type safety - Domain-specific helper functions
- Testing - Domain routes tested independently
Simple Domains (API Endpoints)
Some domains have simple routes with no parameters. The health check is a good example:
internal/http/routes/health.go:
package routes
const (
APIHealth = APIPrefix + "/health"
)
Characteristics:
- No parameters (static route)
- JSON response (API endpoint)
- Prefixed with
/api/for clarity - No helper functions needed
Usage:
import "yourapp/internal/http/routes"
func (s *Server) routes() {
s.router.Get(routes.APIHealth, s.handleHealthCheck())
}
Complex Domains (HYPERMEDIA Routes)
Most domains serve HTML and have parameterized routes. The user domain demonstrates the full pattern.
Note: The examples below are taken directly from Tracks' production templates (users.go.tmpl, users_test.go.tmpl), ensuring they represent actual generated code.
internal/http/routes/users.go:
package routes
// UserSlugParam is exported so handlers can extract parameters without magic strings.
// usersPath remains unexported as it's an internal routing detail.
const (
usersPath = "users"
UserSlugParam = "username"
)
// HYPERMEDIA-First Pattern (Default for all generated resources):
// Routes serve HTML via templ and include form routes (/new, /edit) for RESTful HTML.
//
// API Alternative: Use --api flag to generate JSON routes with /api prefix and no forms.
//
// Type-Safe URL Generation:
// Use typed helpers (UserShowURL, UserEditURL, etc.) instead of manual string concatenation.
// Provides compile-time safety and automatic URL encoding to prevent injection attacks.
const (
UserIndex = "/" + usersPath
UserShow = "/" + usersPath + "/:" + UserSlugParam
UserNew = "/" + usersPath + "/new"
UserCreate = "/" + usersPath
UserEdit = "/" + usersPath + "/:" + UserSlugParam + "/edit"
UserUpdate = "/" + usersPath + "/:" + UserSlugParam
UserDelete = "/" + usersPath + "/:" + UserSlugParam
)
func UserIndexURL() string {
return UserIndex
}
func UserShowURL(username string) string {
return RouteURL(UserShow, UserSlugParam, username)
}
func UserNewURL() string {
return UserNew
}
func UserCreateURL() string {
return UserCreate
}
func UserEditURL(username string) string {
return RouteURL(UserEdit, UserSlugParam, username)
}
func UserUpdateURL(username string) string {
return RouteURL(UserUpdate, UserSlugParam, username)
}
func UserDeleteURL(username string) string {
return RouteURL(UserDelete, UserSlugParam, username)
}
Characteristics:
- Path and parameter constants - Separate constants for base path and parameter name (no magic strings)
- Route constants - RESTful HYPERMEDIA pattern
- Form routes -
/newand/editfor HTML forms - URL encoding - Automatic via
url.PathEscape - Type safety - Typed helper functions
- HYPERMEDIA routes - No
/api/prefix
Route Constants Pattern
Route constants provide compile-time safety and prevent typos:
Benefits:
- Compile-time errors - Typos caught by compiler
- Refactoring safety - IDE renames all usages
- Single source of truth - Route defined once
- Documentation - Constants self-document available routes
Pattern:
const (
UserIndex = "/users" // List all users
UserShow = "/users/:username" // Show specific user
UserNew = "/users/new" // New user form
UserCreate = "/users" // Create user (POST)
UserEdit = "/users/:username/edit" // Edit user form
UserUpdate = "/users/:username" // Update user (PUT/PATCH)
UserDelete = "/users/:username" // Delete user (DELETE)
)
Path and Parameter Constants
Separate constants for paths and parameters prevent magic strings and keep the code maintainable:
const (
usersPath = "users"
UserSlugParam = "username"
)
const (
UserShow = "/" + usersPath + "/:" + UserSlugParam // "/users/:username"
UserEdit = "/" + usersPath + "/:" + UserSlugParam + "/edit" // "/users/:username/edit"
)
Why separate constants?
- Path constant (
usersPath) - The base path segment in the URL (unexported, internal detail) - Parameter constant (
UserSlugParam) - The parameter name used in route patterns, RouteURL calls, and handlers (exported so handlers can usechi.URLParam(r, routes.UserSlugParam)) - No magic strings - Both are referenced by constant, not hardcoded strings
- Consistent naming - All resources follow
{Resource}SlugParampattern (e.g.,UserSlugParam,PostSlugParam)
Why not :id?
HYPERMEDIA routes use readable identifiers (slugs) in URLs:
/users/:username→/users/johndoe✓ (readable, SEO-friendly)/users/:id→/users/12345✗ (opaque, not SEO-friendly)
RouteURL Helper Pattern
The RouteURL helper function is defined in routes.go and shared across all domain route files:
internal/http/routes/routes.go:
import (
"net/url"
"strings"
)
func RouteURL(route string, params ...string) string {
if len(params) == 0 {
return route
}
result := route
for i := 0; i < len(params); i += 2 {
if i+1 >= len(params) {
break
}
key := params[i]
value := params[i+1]
placeholder := ":" + key
result = strings.Replace(result, placeholder, url.PathEscape(value), 1)
}
return result
}
Features:
- URL encoding - Automatic via
url.PathEscape - Multiple parameters - Supports any number of params
- Injection prevention - Spaces become
%20,@becomes%40, etc.
Usage:
// Simple case
url := RouteURL(UserIndex) // "/users"
// With parameters - use constant, not magic string
url := RouteURL(UserShow, routes.UserSlugParam, "johndoe") // "/users/johndoe"
// Special characters are encoded
url := RouteURL(UserShow, routes.UserSlugParam, "user@example.com") // "/users/user%40example.com"
Typed Helper Functions
Domain files provide type-safe helper functions:
func UserShowURL(username string) string {
return RouteURL(UserShow, UserSlugParam, username)
}
func UserEditURL(username string) string {
return RouteURL(UserEdit, UserSlugParam, username)
}
Benefits:
- Type safety - Compiler enforces correct parameter types
- IDE autocomplete - Discoverability of available routes
- Refactoring - Rename parameters safely
- Less error-prone - No manual parameter name typos
Usage in handlers:
func (h *UserHandler) HandleShow(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, routes.UserSlugParam)
user, err := h.service.GetByUsername(r.Context(), username)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
// Use helper to generate URLs in templates
editURL := routes.UserEditURL(user.Username)
deleteURL := routes.UserDeleteURL(user.Username)
// Render templ template with URLs
component := views.UserProfile(user, editURL, deleteURL)
component.Render(r.Context(), w)
}
Route Registration
Routes are registered in internal/http/routes.go:
package http
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"yourapp/internal/http/handlers"
"yourapp/internal/http/routes"
httpmiddleware "yourapp/internal/http/middleware"
)
func (s *Server) routes() {
s.router.Use(middleware.RequestID)
s.router.Use(middleware.RealIP)
s.router.Use(httpmiddleware.Logging(s.logger))
s.router.Use(middleware.Recoverer)
// Health check (no auth required)
s.router.Get(routes.APIHealth, s.handleHealthCheck())
// User routes (HYPERMEDIA)
userHandler := handlers.NewUserHandler(s.userService, s.logger)
s.router.Get(routes.UserIndex, userHandler.HandleIndex)
s.router.Get(routes.UserShow, userHandler.HandleShow)
s.router.Get(routes.UserNew, userHandler.HandleNew)
s.router.Post(routes.UserCreate, userHandler.HandleCreate)
s.router.Get(routes.UserEdit, userHandler.HandleEdit)
s.router.Post(routes.UserUpdate, userHandler.HandleUpdate)
s.router.Post(routes.UserDelete, userHandler.HandleDelete)
}
Pattern:
- Import the routes package (
"yourapp/internal/http/routes") - Use route constants from the package (e.g.,
routes.APIHealth,routes.UserIndex,routes.UserShow) - Register with appropriate HTTP methods
- HYPERMEDIA routes use GET for forms, POST for mutations
Note: All domain route files (health.go, users.go, etc.) are in the same routes package. They're organized into separate files for maintainability, but share the same package namespace.
Testing Routes
Test route constants and helpers to ensure correctness:
internal/http/routes/users_test.go:
package routes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestUserRoutes(t *testing.T) {
t.Run("route constants have correct values", func(t *testing.T) {
assert.Equal(t, "/users", UserIndex)
assert.Equal(t, "/users/:username", UserShow)
assert.Equal(t, "/users/new", UserNew)
assert.Equal(t, "/users", UserCreate)
assert.Equal(t, "/users/:username/edit", UserEdit)
assert.Equal(t, "/users/:username", UserUpdate)
assert.Equal(t, "/users/:username", UserDelete)
})
t.Run("routes follow HYPERMEDIA pattern", func(t *testing.T) {
// HYPERMEDIA routes use readable slugs like :username (not :id)
assert.Contains(t, UserShow, ":username")
assert.Contains(t, UserEdit, ":username")
assert.Contains(t, UserUpdate, ":username")
assert.Contains(t, UserDelete, ":username")
})
t.Run("routes do not contain /api/ prefix", func(t *testing.T) {
// HYPERMEDIA routes serve HTML, not JSON APIs
assert.NotContains(t, UserIndex, "/api/")
assert.NotContains(t, UserShow, "/api/")
assert.NotContains(t, UserNew, "/api/")
assert.NotContains(t, UserCreate, "/api/")
assert.NotContains(t, UserEdit, "/api/")
assert.NotContains(t, UserUpdate, "/api/")
assert.NotContains(t, UserDelete, "/api/")
})
t.Run("routes include form routes for HYPERMEDIA", func(t *testing.T) {
// HYPERMEDIA patterns include /new and /edit for HTML forms
assert.Contains(t, UserNew, "/new")
assert.Contains(t, UserEdit, "/edit")
})
t.Run("parameter constants have correct values", func(t *testing.T) {
assert.Equal(t, "users", usersPath)
assert.Equal(t, "username", UserSlugParam)
})
}
func TestRouteURL(t *testing.T) {
tests := []struct {
name string
route string
params []string
expected string
}{
{
name: "no parameters",
route: "/users",
params: nil,
expected: "/users",
},
{
name: "single parameter",
route: "/users/:username",
params: []string{"username", "john"},
expected: "/users/john",
},
{
name: "multiple parameters",
route: "/users/:username/posts/:posts",
params: []string{"username", "john", "posts", "hello-world"},
expected: "/users/john/posts/hello-world",
},
{
name: "URL encodes spaces",
route: "/users/:username",
params: []string{"username", "john doe"},
expected: "/users/john%20doe",
},
{
name: "URL encodes special characters",
route: "/users/:username",
params: []string{"username", "user@example.com"},
expected: "/users/user%40example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := RouteURL(tt.route, tt.params...)
assert.Equal(t, tt.expected, result)
})
}
}
func TestUserHelpers(t *testing.T) {
t.Run("UserIndexURL returns correct path", func(t *testing.T) {
assert.Equal(t, "/users", UserIndexURL())
})
t.Run("UserShowURL with normal username", func(t *testing.T) {
assert.Equal(t, "/users/john", UserShowURL("john"))
})
t.Run("UserShowURL with special characters", func(t *testing.T) {
assert.Equal(t, "/users/user%40example.com", UserShowURL("user@example.com"))
})
t.Run("UserEditURL with normal username", func(t *testing.T) {
assert.Equal(t, "/users/john/edit", UserEditURL("john"))
})
}
API Routes (Rare Exceptions)
JSON APIs are the exception in Tracks applications. Use the --api flag when generating resources that need JSON endpoints:
tracks generate resource webhooks --api
Generated API routes:
const (
APIWebhooks = "/api/webhooks"
APIWebhookShow = "/api/webhooks/:id"
APIWebhookCreate = "/api/webhooks"
APIWebhookUpdate = "/api/webhooks/:id"
APIWebhookDelete = "/api/webhooks/:id"
)
Characteristics:
- Prefixed with
/api/for clarity - Use
:idinstead of readable slugs (IDs are fine for APIs) - No form routes (
/new,/editnot needed for JSON) - Return JSON responses
- Typically used for:
- Health/metrics endpoints
- Webhook receivers
- Third-party integrations
- Mobile app backends
Best Practices
1. Default to HYPERMEDIA
Always start with HTML-serving routes. Only use --api if you truly need JSON responses.
2. Use Domain-Based Files
Create separate route files for each domain:
routes/
├── users.go # User domain
├── posts.go # Post domain
├── comments.go # Comment domain
└── health.go # Health checks
3. Use Path and Parameter Constants
Keep paths and parameter names consistent with separate constants:
const (
postsPath = "posts"
postSlugParam = "post_slug"
)
const (
PostShow = "/" + postsPath + "/:" + postSlugParam
)
4. Always Use Typed Helpers
Never manually construct URLs:
// Good
url := users.UserShowURL(username)
// Bad
url := "/users/" + username // No URL encoding! Injection risk!
5. Test Your Routes
Write tests for route constants and helpers to catch typos and ensure URL encoding works.
6. Include Form Routes
HYPERMEDIA applications need form routes:
const (
UserNew = "/users/new" // GET: Show form
UserCreate = "/users" // POST: Process form
UserEdit = "/users/:username/edit" // GET: Show edit form
UserUpdate = "/users/:username" // POST: Process update
)
7. Use Readable Slugs
HYPERMEDIA routes benefit from readable URLs:
/users/johndoe✓/posts/getting-started-with-go✓/users/123✗ (opaque, not SEO-friendly)
Next Steps
- Architecture Overview - High-level system design
- Layer Guide - Detailed explanation of each layer
- Patterns - Common implementation patterns
- Testing Guide - Testing strategies