App Layout (the law)

A pg-web app is a tree of files under pages/. Three rules:

  1. Directory = route. pages/todos/toggle//todos/toggle.
  2. Filename = HTTP method. index (GET alias), post. put/patch/delete are reserved for Phase 2+.
  3. Each method has two files: <method>.html (template) + <method>.sql (handler). Either is optional.

Pipeline modes

Files presentPipelineUse for
.html onlyRender template with empty context {}. No SPI call.Static marketing / about pages
.html + .sqlHandler returns json → Tera renders with it (auto-escape on).Most pages
.sql onlyHandler returns text → router sends bytes as-is.HTMX fragments, JSON APIs, deletes returning ''

Naming (exact)

Dynamic segments

A directory name in [brackets] is a capture. It matches any single segment and appears in req.path_params.

pages/posts/[id]/index.html   → GET /posts/:id
pages/users/[user]/posts/[post]/index.sql

Captures are always strings. The handler decides how to cast/validate. Static routes win on specificity.

The req contract

{
  "body":        { "title": "buy milk" },   // form body, {} if empty
  "query":       { "page": "2" },           // query string, {} if empty
  "method":      "POST",
  "path":        "/todos/42",
  "path_params": { "id": "42" }
}

Return json when you have a sibling .html; return text when you are .sql-only.

Migrations vs push

pg-web migrate apply advances your app schema (your tables) from migrations/*.sql and is tracked in pgweb.migrations (append-only history).

pg-web push replaces routes, templates, and the pgweb.pages__* handler functions from the current pages/ tree. It is fully reconciling and transactional.

Always migrate before push in a deploy.

Full spec: docs/APP-LAYOUT.md in the repo. The examples/todo/ app is the canonical reference.