Go pages out of folders.
Trilha is a web framework for Go: create a file under app/ and it becomes a route. Nested layouts, forms, APIs, middleware, a dev server with live reload and a single binary. Nothing beyond the standard library.
go install github.com/emersonjoe/trilha/cmd/trilha@latest
trilha new agenda && cd agenda && trilha devOne folder, one route
There is no route table. The app/ tree is the table: page.go answers a page, route.go answers JSON, layout.go wraps everything below it and middleware.go intercepts the subtree. Dynamic segments use the _ suffix and route groups the - suffix, because [slug] is not a valid import path in Go.
app/
├── layout.go ← <html> of every page
├── page.go ← GET /
├── events/
│ ├── layout.go ← wraps /events/**
│ ├── page.go ← GET /events
│ ├── new/page.go ← GET /events/new + the form's POST
│ └── slug_/page.go ← GET /events/{slug}
├── organizer-/ ← group: not part of the URL
│ ├── middleware.go ← requires login
│ └── dashboard/page.go ← GET /dashboard
└── api/events/route.go ← GET and POST /api/eventsHTML in Go, escaped by default
The h package is a typed DSL: elements and attributes are functions, h.Map and h.If cover control flow and every text is escaped. The result on the right was not written by hand: it is the code that ran while this site was built.
func Page(c *trilha.Ctx) (h.Node, error) {
return h.Ul(h.Class("events"),
h.Map(events, func(e Event) h.Node {
return h.Li(
h.Strong(h.Text(e.Name)),
h.Textf(" · %s · ", e.City),
h.IfElse(e.Seats > 0,
h.Textf("%d seats", e.Seats),
h.Em(h.Text("sold out"))),
)
}),
), nil
}- Go Meetup Campinas · Campinas · 12 seats
- HTTP Workshop · Recife · sold out
- Code Night · Curitiba · 4 seats
name := "<script>alert('hi')</script>"
return h.P(
h.Text("Hello, "),
h.Strong(h.Text(name)),
), nilHello, <script>alert('hi')</script>
Layouts that nest
Every folder may have a layout.go. The page renders first and is handed to the innermost layout, which hands it to the outer one, up to the root <html>. The title the page sets with c.SetTitle reaches all of them.
// app/events/layout.go
func Layout(c *trilha.Ctx, children h.Node) (h.Node, error) {
return h.Section(h.Class("agenda"),
h.Nav(h.A(h.Href("/events"), h.Text("All")),
h.A(h.Href("/events/new"), h.Text("New"))),
children,
), nil
}
// app/events/page.go
func Page(c *trilha.Ctx) (h.Node, error) {
c.SetTitle("Events")
return h.H2(h.Text("Upcoming events")), nil
}Upcoming events
Forms and APIs without ceremony
Export POST in the same page.go and the form arrives with its CSRF token verified. In route.go, each HTTP method is a function. Errors are values: trilha.ErrNotFound becomes a 404, c.Redirect a 303, and any other error a 500 with a stack trace only in development.
func Page(c *trilha.Ctx) (h.Node, error) {
return h.Form(h.Method("post"), h.Action("/events/new"),
trilha.CSRFInput(c),
h.Label(h.For("name"), h.Text("Event name")),
h.Input(h.ID("name"), h.Name("name"), h.Required()),
h.Button(h.Type("submit"), h.Text("Publish")),
), nil
}
func POST(c *trilha.Ctx) error {
ev := agenda.Create(c.Form("name"))
return c.Redirect("/events/" + ev.Slug)
}Save, see.
trilha dev watches app/, regenerates the route registry, recompiles, swaps the process and tells the browser. A cycle takes about a second; CSS changes land in milliseconds, without recompiling. A build error becomes a page in the browser that goes away when you fix it.
Zero dependencies
Runtime and CLI use only the standard library. The router is Go 1.22's http.ServeMux.
No runtime magic
The CLI generates a Go file with the route registry. The compiler checks every signature. No reflect.
One binary
trilha build embeds public/ and produces an executable that runs on its own. trilha export produces a static site, like this one.
Start with the first page
The learning trail goes from trilha new to deployment, building an events agenda. Every chapter ends with a challenge.