Skip to content
Trilha
Chapters

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

The 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.go

The 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.go

That 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.go

Alphabetical order puts /events/today first, but what decides precedence is the router: literal before dynamic, always.