Tracks is a full-featured, batteries-included web framework for Go (Go 1.26+) built on Go idioms, explicit typing, and zero-magic design. It combines the developer ergonomics of modern web frameworks (convention-over-configuration, layouts, partials, scaffolding) with Go's high performance, explicit error handling, and cloud-native observability.
- Standard Library Foundation: Built directly on Go's
net/http.ServeMuxwith pattern matching (/posts/{id}). - Rich Templating Engine: Layouts, auto-discovered nested partials (
folder#partial), localized translations (t), view variables (v), Phosphor icons (icon), CSRF injection, and domain-specific template overrides. - Explicit Database & Generic Repositories: Reflection-free model mapping (
database.Repository[S, T]), query builder, lifecycle hooks (BeforeCreate,AfterUpdate, etc.), transactions (database.WithTransaction), atomic field updates (AtomicUpdate), domain-scoped models, and Goose migrations. - Deep OpenTelemetry Instrumentation: Distributed tracing, metrics (including SQLite connection pool stats), and structured slog integration pre-wired out of the box via OTLP.
- Built-in Modular Ecosystem:
authentication: Session management, bcrypt hashing, system roles (RequireSystemRole), user hooks.multitenancy: Subdomain isolation (splitter), central vs. tenant SQLite databases, and tenant migrations.featureflags: Database-backed, in-memory cached, rule-targeted feature flags with CLI management.mail: Pluggable delivery drivers (smtp,log,testing) with HTML template rendering.storage: Multi-provider blob storage (disk,s3,gcs).
- Core Platform Capabilities:
- CSRF protection (cookie, header, form field).
- Sliding-window rate limiting.
- In-memory tag-invalidated cache.
- Asynchronous background job queue with retry policies.
- Native WebSockets with rooms/hub support.
- File-based Blog/CMS with frontmatter parsing.
- API versioning (path, header, query parameter).
- Request parsing & struct validation (
validatetags). - Automated content negotiation (HTML, JSON, XML, text).
- Static file asset pipeline with content-based MD5 hashing.
- Scaffolding CLI (
tracks init,tracks generate,tracks db,tracks assets).
my-app/
├── config/
│ └── config.json # App configuration (ports, database, sessions, modules)
├── controllers/
│ ├── default.go # Standard action controllers
│ └── posts.go # Resource controllers
├── models/
│ └── post.go # Database models implementing database.Model[S, T]
├── views/
│ ├── layouts/
│ │ └── application.gohtml # Master layout template (contains {{template "yield" .Content}})
│ ├── default/
│ │ └── home.gohtml # Action-specific template
│ └── posts/
│ ├── _card.gohtml # Reusable partial
│ └── index.gohtml
├── public/
│ ├── css/
│ ├── js/
│ └── images/
├── migrations/
│ └── central/ # Goose SQL migrations
├── main.go # Application bootstrapper
└── go.mod
- Go 1.26 or higher
- SQLite3 (CGO enabled for
github.com/mattn/go-sqlite3)
Create a new Tracks application:
# Using the Tracks CLI (built from ./cli)
tracks init github.com/example/myapp
cd myappOr configure manually in a few lines of Go:
// main.go
package main
import (
"context"
"log"
"net/http"
"github.com/tmeire/tracks"
)
func main() {
ctx := context.Background()
router := tracks.New(ctx)
// Register a simple route
router.GetFunc("/", "default", "home", func(r *http.Request) (any, error) {
return map[string]string{
"message": "Welcome to Tracks!",
}, nil
})
if err := router.Run(ctx); err != nil {
log.Fatalf("Server stopped: %v", err)
}
}Tracks automatically reads ./config/config.json (or the path defined in TRACKS_CONFIG_FILE):
{
"name": "myapp",
"version": "1.0.0",
"port": 8080,
"development": true,
"secure": false,
"base_domain": "localhost:8080",
"sessions": {
"store": {
"type": "db",
"config": {
"type": "sqlite",
"config": {
"path": "./data/sessions.sqlite"
}
}
}
},
"database": {
"type": "sqlite",
"config": {
"path": "./data/app.sqlite"
}
}
}Tracks provides both functional handlers (ActionFunc) and resourceful controllers (Resource).
router.GetFunc("/articles", "articles", "index", articlesHandler)
router.PostFunc("/articles", "articles", "create", createArticleHandler)
router.PutFunc("/articles/{id}", "articles", "update", updateArticleHandler)
router.DeleteFunc("/articles/{id}", "articles", "destroy", deleteArticleHandler)An ActionFunc signature receives *http.Request and returns (any, error):
func articlesHandler(r *http.Request) (any, error) {
// Returning an object renders HTML (if views/articles/index.gohtml exists)
// or JSON if the client requests application/json.
return []Article{...}, nil
}Redirects and custom responses use tracks.Response:
func createArticleHandler(r *http.Request) (any, error) {
// Process form ...
return &tracks.Response{
StatusCode: http.StatusSeeOther,
Location: "/articles",
}, nil
}Standard error helpers:
tracks.BadRequest(err)tracks.NotFound("item not found")tracks.Forbidden("access denied")tracks.Unauthorized("login required")tracks.Conflict("already exists")tracks.UnprocessableEntity("validation failed", validationErrors)
Implement tracks.Resource to define standard REST endpoints:
type Resource interface {
Index(r *http.Request) (any, error)
New(r *http.Request) (any, error)
Create(r *http.Request) (any, error)
Show(r *http.Request) (any, error)
Edit(r *http.Request) (any, error)
Update(r *http.Request) (any, error)
Destroy(r *http.Request) (any, error)
}Register on the router:
router.Resource(&controllers.ArticlesResource{})
// Or mount at a custom sub-path:
router.ResourceAtPath("/admin", &controllers.ArticlesResource{})This registers:
GET /articles/->articles#indexGET /articles/new->articles#newPOST /articles/->articles#createGET /articles/{articles_id}->articles#showGET /articles/{articles_id}/edit->articles#editPUT /articles/{articles_id}->articles#updateDELETE /articles/{articles_id}->articles#destroy
Tracks uses Go's standard html/template with automatic layout inheritance and partial discovery.
Every page template renders inside a layout:
<!DOCTYPE html>
<html>
<head>
<title>{{v "title"}}</title>
<link rel="stylesheet" href="/assets/css/application.css">
{{csrf_token}}
</head>
<body>
<header>
<!-- Include partial from views/layouts/_nav.gohtml -->
{{template "nav" .}}
</header>
<main>
{{template "yield" .Content}}
</main>
<script src="/assets/js/application.js"></script>
</body>
</html><h1>Articles</h1>
<form method="POST" action="/articles">
{{csrf_field}}
<input type="text" name="title" />
<button type="submit">Create</button>
</form>
{{range .}}
<!-- Call nested partial from views/articles/_card.gohtml -->
{{template "articles#card" .}}
{{end}}| Helper | Description | Example |
|---|---|---|
{{t "key"}} |
Localized translation from translations/{lang}.json |
{{t "welcome_message"}} |
{{localize "/path"}} |
Path in the current language (/nl/path on a Dutch page) |
<a href="{{localize "/cart"}}"> |
{{alternate_links}} |
hreflang alternate <link> tags incl. x-default |
{{alternate_links}} in <head> |
{{alternates}} |
Language versions of the page (.Locale, .Path, .URL, .Current) |
{{range alternates}}…{{end}} |
{{canonical_url}} |
Absolute URL of the page in the current language | <link rel="canonical" href="{{canonical_url}}"> |
{{v "key"}} |
View variable set in handler via tracks.AddViewVar |
{{v "page_title"}} |
{{csrf_field}} |
Generates <input type="hidden" name="csrf_token" ...> |
{{csrf_field}} |
{{csrf_token}} |
Raw CSRF token string | {{csrf_token}} |
{{icon "name"}} |
Inlines SVG Phosphor icon from assets/icons/ |
{{icon "magnifying-glass-bold" "w-5 h-5"}} |
{{date "format" .Time}} |
Formats a time.Time value |
{{date "Jan 02, 2006" .CreatedAt}} |
{{now}}, {{today}}, {{year}} |
Date helpers | {{year}} |
{{dict "k1" v1 "k2" v2}} |
Constructs a dictionary for partial passing | {{template "card" (dict "Item" . "Class" "highlight")}} |
{{cents 1050}} |
Formats cents to dollars/euros (10.50) |
{{cents .PriceInCents}} |
{{safe .HTML}} |
Marks string as trusted HTML (template.HTML) |
{{safe .RichContent}} |
Translations live in translations/{locale}.json (nested keys, accessed with dots: {{t "nav.cart"}}). Missing keys fall back to the default language and are logged once per locale and key.
Add an i18n block to config.json to serve every language on its own URL, which is what search engines expect:
"i18n": { "default": "en", "locales": ["en", "nl", "fr"], "strategy": "path" }/nl/aboutis routed as/aboutwith languagenl, so you register every route once. Unprefixed URLs use the default language and/en/...redirects (301) to the unprefixed URL.- Cookies and
Accept-Languagenever change the language of a URL. When the visitor prefers another configured language, thesuggested_localeview var holds it, so you can offer a switch. - Root-relative redirects stay in the current language (
tracks.Redirect("/cart")sends Dutch visitors to/nl/cart). To switch language, redirect toi18n.URLFor(ctx, "en", "/cart"). - Pages whose paths differ per language (translated slugs), or that don't exist in every language, declare their versions; locales left out get no alternate link:
tracks.SetAlternates(r, map[string]string{
"en": "/coloring-pages/cow",
"nl": "/kleurplaten/koe", // served as /nl/kleurplaten/koe
})A minimal localized layout:
<html lang="{{v "locale"}}">
<head>
<link rel="canonical" href="{{canonical_url}}">
{{alternate_links}}
</head>
<body>
<a href="{{localize "/"}}">{{t "nav.home"}}</a>
{{range alternates}}{{if not .Current}}<a href="{{.Path}}" hreflang="{{.Locale}}">{{.Locale}}</a>{{end}}{{end}}
</body>In Go code, use tracks.T(r, "key"), tracks.LocalizePath(r, "/path") and i18n.CanonicalURL(ctx). Absolute URLs use base_url from the i18n block, or base_domain and secure when it's omitted.
Without an i18n block, the language is detected per request (?locale=, locale cookie, session, Accept-Language) and URLs are shared by all languages, as before. Set "strategy": "detect" to keep that behaviour while limiting it to the configured locales.
Placing a template in views/domains/{domain}/{controller}/{action}.gohtml automatically overrides the base template when accessed from that domain.
Tracks avoids reflection during database execution by asking models to declare their fields and scanning functions explicitly.
package models
import (
"context"
"time"
"github.com/tmeire/tracks/database"
)
type Article struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
func (a *Article) TableName() string {
return "articles"
}
func (a *Article) Fields() []string {
return []string{"id", "title", "content", "created_at"}
}
func (a *Article) Values() []any {
return []any{a.ID, a.Title, a.Content, a.CreatedAt}
}
func (a *Article) Scan(ctx context.Context, schema any, row database.Scanner) (*Article, error) {
var article Article
err := row.Scan(&article.ID, &article.Title, &article.Content, &article.CreatedAt)
if err != nil {
return nil, err
}
return &article, nil
}
func (a *Article) HasAutoIncrementID() bool {
return true
}
func (a *Article) GetID() any {
return a.ID
}Models can optionally implement:
BeforeCreate(ctx context.Context) errorAfterCreate(ctx context.Context) errorBeforeUpdate(ctx context.Context) errorAfterUpdate(ctx context.Context) errorBeforeDelete(ctx context.Context) errorAfterDelete(ctx context.Context) error
repo := database.NewRepository[any, *models.Article](nil)
// Insert
article := &models.Article{Title: "Hello Tracks", Content: "Body text", CreatedAt: time.Now()}
saved, err := repo.Create(ctx, article)
// Query by ID
found, err := repo.FindByID(ctx, saved.ID)
// Query Builder
list, err := repo.Select().
Where("title LIKE ?", "%Tracks%").
OrderBy("created_at", database.DESC).
Limit(10).
Exec(ctx)
// Atomic Updates
err = repo.AtomicUpdate(ctx, saved.ID, database.AtomicOp{
Field: "views",
Delta: 1,
})
// Transactions
err = database.WithTransaction(ctx, func(txCtx context.Context) error {
// queries in txCtx participate in the transaction
return repo.Update(txCtx, saved)
})type CreatePostRequest struct {
Title string `form:"title" json:"title" validate:"required,min=5"`
Content string `form:"content" json:"content" validate:"required"`
Email string `form:"email" json:"email" validate:"email"`
}
func CreatePost(r *http.Request) (any, error) {
var req CreatePostRequest
if err := tracks.ParseAndValidate(r, &req); err != nil {
if vErrors, ok := err.(tracks.ValidationErrors); ok {
return tracks.UnprocessableEntity("Validation failed", vErrors.FieldErrors()), nil
}
return tracks.BadRequest(err), nil
}
// Validated payload ready to use
return &tracks.Response{StatusCode: http.StatusCreated, Data: req}, nil
}Tracks includes plug-and-play modules registered with router.Module(...):
import "github.com/tmeire/tracks/modules/authentication"
// Protect routes by role
router.GetFunc("/admin", "admin", "dashboard", adminHandler,
authentication.RequireSystemRole("admin"))Routes requests dynamically by subdomain to dedicated SQLite tenant databases while maintaining a central database for users and tenants:
import "github.com/tmeire/tracks/modules/multitenancy"
router.Module(multitenancy.Register)Evaluate feature flags tied to database models with in-memory caching:
import "github.com/tmeire/tracks/modules/featureflags"
router.Module(featureflags.Register)import "github.com/tmeire/tracks/modules/mail"
mail.Send(ctx, mail.Message{
To: []string{"user@example.com"},
Subject: "Welcome",
Body: "Hello from Tracks!",
})import "github.com/tmeire/tracks/modules/storage"
service := storage.FromContext(r.Context())
err := service.Save(ctx, "avatars/user1.png", fileReader)cache := router.Cache()
cache.SetWithTags("user:1", userData, []string{"users"}, 10*time.Minute)
// Invalidate all items tagged "users"
cache.InvalidateTag("users")type SendWelcomeEmailJob struct {
UserID int64
}
func (j SendWelcomeEmailJob) Handle(ctx context.Context) error {
// Send email asynchronously
return nil
}
// Enqueue
router.Queue().Enqueue(ctx, SendWelcomeEmailJob{UserID: 42})Build the CLI binary:
go build -o tracks ./cli/main.goAvailable commands:
# Initialize a new application
./tracks init [module_name]
# Generate a controller action and view
./tracks generate controller [method] [path]
# Example: ./tracks generate controller GET /about
# Generate a resourceful scaffold (model, controller, views)
./tracks generate resource [name]
# Example: ./tracks generate resource posts
# Run database migrations (Note the subcommand nesting)
./tracks db db up
./tracks db db down
./tracks db db status
# Asset compilation and hashing
./tracks assets compileRun the test suite across all packages:
go test ./...Tracks provides test helpers in testing.go:
tracks.NewTestRouter(t)tracks.ExecuteRequest(r, req)tracks.AssertStatus(t, resp, http.StatusOK)tracks.AssertJSON(t, resp, expected)
Tracks is open source under the MIT License.