Skip to content
Trilha

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 dev

One 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/events

HTML 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.

app/…/page.go
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
}
result
  • Go Meetup Campinas · Campinas · 12 seats
  • HTTP Workshop · Recife · sold out
  • Code Night · Curitiba · 4 seats
app/…/page.go
name := "<script>alert('hi')</script>"

return h.P(
	h.Text("Hello, "),
	h.Strong(h.Text(name)),
), nil
result

Hello, <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/…/page.go
// 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
}
result

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.

app/…/page.go
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)
}
result
Submit to see what the server would do (simulated in the browser; the real POST is in the code on the left).

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.

Learn Trilha