Parameterized routes

A document stored at a path containing :name segments is a route template. When a whole-document GET finds no literal document at the request path, the server matches the path against these templates, captures the concrete segment values as named parameters, and exposes them to composition as request.params.

When to reach for it

Use a parameterized route to serve many URLs from one stored document — /users/42/profile.html, /users/alice/profile.html, and so on all rendered by a single template stored at /users/:id/profile.html. The captured value drives composition (expression bindings, templates, schema methods), so each URL renders its own content from the one template.

Authoring a route

Store (PUT) the template document at a path whose segments begin with :. The colon is a literal character in the stored path. Directory segments, the filename, or both may be parameterized:

Stored path Matches a request like Captures
/users/:id/profile.html /users/42/profile.html id = "42"
/orgs/:org_id/teams/:team_id/members.html /orgs/acme/teams/backend/members.html org_id = "acme", team_id = "backend"
/pages/:slug.html /pages/hello-world.html slug = "hello-world"

A parameterized filename captures the request filename up to a matching extension: :slug.html against /pages/hello.html captures slug = "hello", and the request filename must end in .html. A bare :slug with no extension captures the entire request filename.

Resolution

Parameterized resolution runs only when both hold:

  1. The request is a whole-document GET — no selector (Range) is present, and
  2. No literal document exists at the request path.

The resolver then walks the stored directory tree segment by segment. For each request segment it matches a literal child name first, then any :param child. Captured values are percent-decoded/pages/hello%20world.html captures slug = "hello world".

Whole-document writes (PUT, POST, DELETE, MOVE with no Range selector) are literal only: they do not resolve parameterized routes. A whole-document write addresses the stored path verbatim, so PUT /users/:id/profile.html edits the template document itself.

A selector write (Range: selector=…) to a parameterized-route URL is resolved differently, to match the behaviour of a literal composed page: the route is resolved, the template composed (with request.params available), and — if the selector targets a stamped or included element (<p:stamp>/<p:include>) — the write routes through to that element's origin resource, exactly as it would on a literal composed page. This is what lets, e.g., a comment POSTed to a /posts/:slug.html page reach the post's origin data file. Authorization is evaluated against the composed route page (the concrete URL the request addresses), not the origin resource — so an AuthorizationRule permitting the write on the route page is sufficient, and the origin needs no rule of its own. A selector write whose selector matches no element in the composed route page (or matches an element that is not stamped/included, so it has no writable origin) returns 416 Range Not Satisfiable — exactly as a selector-no-match does on a literal composed page. The route template is never written through the concrete URL. (A concrete path that matches no route at all still returns 404.)

Most-literal-wins

When more than one template matches a request, the candidate with the most literal (non-:param) segments wins. Ties are broken by the stored template path, compared lexicographically in ascending order.

Request Matching templates Winner
/pages/about/index.html /pages/about/index.html, /pages/:slug/index.html /pages/about/index.html — more literal segments
/items/x/view.html /items/:a_param/view.html, /items/:b_param/view.html /items/:a_param/view.html — lexicographically first

A literal document stored at the exact request path always wins, because the literal lookup is performed before parameterized resolution is attempted.

Reading captured parameters

Captured parameters are exposed to composition under request.params:

request.params is a shared request member: distinct URLs are distinct cache keys, so a parameterized page remains publicly cacheable per concrete URL.

Example

One template document renders every /users/<id>/profile.html URL. It binds the captured id with an Expression Binding and emits it with <p:stamp>. The template is stored once at the :id path:

Given /users/:id/profile.html contains

<!DOCTYPE html>
<html xmlns:e="https://pagelove.org/Binding/Sessel" xmlns:p="https://pagelove.org/1.0">
  <body e:uid="request.params.id">
    <main><p:stamp uid></p:stamp></main>
    <div hidden itemscope itemtype="https://pagelove.org/AuthorizationRule">
      <meta itemprop="actor" content="*">
      <meta itemprop="resource" content="/*">
      <meta itemprop="method" content="GET">
      <meta itemprop="action" content="allow">
    </div>
  </body>
</html>

A concrete URL resolves to that template, with id captured from the path:

GET /users/42/profile.html HTTP/2
Host: 127.0.0.1
HTTP/2 200

<main>42</main>

/users/alice/profile.html renders <main>alice</main> from the same stored document.

Error cases

Condition Result
No literal document and no template matches 404 Not Found
Directory segments match a template but the final document does not exist 404 Not Found — there is no partial match
Selector (Range) GET to a path with no literal document 404 Not Found — parameterized routes are resolved only for whole-document reads

See also