Routing
Routes are struct fields with route: tags. Format: route:"[METHOD] /path [Title]".
type pages struct {
home `route:"/{$} Home"` // exact root match
about `route:"/about About"` // all methods (default)
create `route:"POST /create Create"` // POST only
detail `route:"/item/{itemId} Item"` // path parameter
files `route:"/files/{path...} Files"` // wildcard
}Route tag format
- Path only:
route:"/path"— all HTTP methods, no page title. - Path with title:
route:"/path Page Title"— all methods, title "Page Title". - Method and path:
route:"POST /path"— POST only, no title. - Full format:
route:"PUT /path Update Page"— PUT only, title "Update Page".
Supported HTTP methods: GET, HEAD, POST, PUT, PATCH, DELETE, CONNECT, OPTIONS, TRACE. If no method is given, the route accepts all methods (internally stored as ALL).
Only the route: tag is read by the framework — any other tag on a route field is ignored.
/{$} — exact match
Go's ServeMux treats a trailing / as a prefix match: route:"/" would swallow every unmatched path under the mount point. Use /{$} for "exactly this path" — most commonly the index page of a page group:
type adminPages struct {
dashboard `route:"/{$} Dashboard"` // matches /admin/ exactly
users `route:"/users Users"` // matches /admin/users
}Path parameters
Path parameters use Go 1.22+ http.ServeMux syntax. Extract them in the Props method via r.PathValue — they are not passed as function arguments:
type pages struct {
userProfile `route:"/users/{userId} User Profile"`
blogPost `route:"/blog/{year}/{month}/{slug}"`
}
func (p userProfile) Props(r *http.Request) (UserProfileProps, error) {
userID := r.PathValue("userId") // "123" if URL is /users/123
return UserProfileProps{UserID: userID}, nil
}component (p userProfile) Page(props UserProfileProps) {
<Layout>
<h1>User Profile for { props.UserID }</h1>
</Layout>
}Name path params specifically — {itemId}, not {id}. Nested routes compose into a single pattern, so two levels each declaring {id} collide: ServeMux rejects duplicate wildcard names in a pattern (/order/{id}/item/{id} panics at mount), and URLFor's map[string]any params couldn't tell them apart anyway. Specific names compose cleanly: /order/{orderId}/item/{itemId}.
Nested routes
Create hierarchical URL structures by nesting structs:
type pages struct {
admin adminPages `route:"/admin Admin Panel"`
}
type adminPages struct {
dashboard `route:"/{$} Dashboard"` // -> /admin/
users `route:"/users User List"` // -> /admin/users
settings `route:"/settings Settings"` // -> /admin/settings
}A struct like adminPages that has no render of its own — no Page or ServeHTTP, only child pages — is a page group. It is never served at its bare path; /admin 307-redirects to /admin/, which its /{$} page serves. URLFor on a page group returns the index child's URL with the canonical trailing slash (see URLFor).
Children register before parents on the mux, so nested-route conflicts resolve correctly without you ordering anything by hand.
Wildcard routes and static assets
Use the wildcard form for prefix subtrees. The framework joins nested paths with path.Join, which strips trailing slashes, so route:"/static/" would register as an exact match, not a prefix; {path...} is the right shape.
A module can serve its own assets as a field next to its pages, so /admin and /admin/static/* register together and follow the module's mount path:
type adminPages struct {
dashboard `route:"/{$} Dashboard"`
users `route:"/users Users"`
Assets staticFiles `route:"GET /static/{path...} Assets"`
}
//go:embed all:static
var staticFS embed.FS
var staticRoot = func() fs.FS {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic(err) // the directory is embedded above, so this can't fail at run time
}
return sub
}()
type staticFiles struct{}
func (staticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, staticRoot, r.PathValue("path"))
}r.PathValue("path")is the file path, so noStripPrefixis needed.- The module's
Middlewaresgate the assets too. For public assets, makeAssetsa sibling of the gated struct. - Asset files are not pages: link them with a plain path or a build manifest (e.g. Vite), not
URLFor.
Serving under a path prefix
There are two ways to put an app under /app. If structpages owns the prefix, mount there: Mount(mux, pages{}, "/app", "App") registers /app/... routes and URLFor returns /app/... URLs.
If something upstream strips the prefix first — http.StripPrefix("/app", mux) or a reverse proxy that forwards /app/x as /x — mount at / and tell structpages the external prefix, so generated URLs still carry it:
mux := http.NewServeMux()
if _, err := structpages.Mount(mux, pages{}, "/", "App", structpages.WithURLPrefix("/app")); err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", http.StripPrefix("/app", mux)))WithURLPrefix affects generated URLs only (URLFor and the url filter); routes register without it. A missing leading slash is added and a trailing one dropped, so "app", "/app" and "/app/" are the same, and the root page's URL is /app.
Never write an in-app URL as a string literal
Resolve URLs by page type — structpages.URLFor(ctx, somePage{}) — so a moved route breaks the build (or the boot) instead of silently dangling. The structpages-lint route-literal check flags .go string literals that exactly equal a mounted route, and url-attr flags hard-coded paths in .gsx and .templ URL attributes.