Learn
Pages and routes
How folders become URLs, including dynamic segments, catch-all and groups.
You have seen that app/events/page.go answers /events. This chapter covers the rest of the mapping: URL parameters, paths of variable length and folders that group pages without showing up in the URL.
Dynamic segment: name_#
Each event gets its own page at /events/go-meetup. Instead of one folder per event, create a folder whose name ends with _:
app/events/slug_/page.go → GET /events/{slug}Inside the page, the value comes from c.Param:
package slug
import (
"github.com/emersonjoe/trilha"
"github.com/emersonjoe/trilha/h"
)
func Page(c *trilha.Ctx) (h.Node, error) {
slug := c.Param("slug")
c.SetTitle("Event " + slug)
return h.H1(h.Textf("Event: %s", slug)), nil
}The parameter name is the folder name without the _. A folder id_ gives c.Param("id").
Catch-all: name__#
Two underscores at the end capture everything that follows, inner slashes included:
app/docs/path__/page.go → GET /docs/{path...}GET /docs/guide/install arrives with c.Param("path") == "guide/install". A catch-all folder must be a leaf: nothing can exist below it.
Who wins on a tie#
Literal routes beat dynamic ones. With app/events/new/page.go and app/events/slug_/page.go, /events/new goes to the first and /events/anything-else to the second. Two sibling dynamic folders (a_ and b_ at the same level) are a generation error, because there would be no way to choose.
Route groups: name-#
Sometimes you want several pages to share a layout or a middleware without that showing in the URL. A folder ending in - is a group:
app/organizer-/middleware.go ← applies to everything below
app/organizer-/dashboard/page.go → GET /dashboard (no "organizer" in the URL)
app/organizer-/events/page.go → GET /events ✗ conflicts with app/events/page.goThe generator refuses two folders that produce the same URL (E_DUPLICATE_ROUTE), so the second example above does not compile.
Letting the CLI do the translation#
Nothing above needs to be typed by hand. trilha generate takes the URL and writes the folder the convention asks for, already compiling:
trilha generate page /events/{slug} # app/events/slug_/page.go
trilha generate route /api/events # app/api/events/route.goThe page comes with c.Param("slug") already read, and trilha_gen.go is regenerated at the end, so the URL answers before you open the editor. With --methods, --bind and --form the skeleton also comes with the contract — the handlers, the struct, the validation and the form — and trilha generate test <url> writes the test beside it. The flags are in CLI.
What the generator does with this#
Run trilha routes at any time to see the table:
METHODS PATTERN SOURCE
GET / app/page.go
GET /events app/events/page.go
GET /events/{slug} app/events/slug_/page.go
GET /dashboard app/organizer-/dashboard/page.goThat table becomes Go code in trilha_gen.go: one a.Register(trilha.Route{...}) per line, importing each package. If you rename Page, the compiler complains, not the server in production.
Challenge#
Create the detail page app/events/slug_/page.go showing the slug, and a page app/events/today/page.go. Confirm with trilha routes that /events/today points to the literal folder and not to the dynamic one.
Show solution
Both pages follow the Page shape. The output of trilha routes must contain:
GET /events/today app/events/today/page.go
GET /events/{slug} app/events/slug_/page.goAlphabetical order puts /events/today first, but what decides precedence is the router: literal before dynamic, always.