App Layout (the law)
A pg-web app is a tree of files under pages/. Three rules:
- Directory = route.
pages/todos/toggle/→/todos/toggle. - Filename = HTTP method.
index(GET alias),post.put/patch/deleteare reserved for Phase 2+. - Each method has two files:
<method>.html(template) +<method>.sql(handler). Either is optional.
Pipeline modes
| Files present | Pipeline | Use for |
|---|---|---|
.html only | Render template with empty context {}. No SPI call. | Static marketing / about pages |
.html + .sql | Handler returns json → Tera renders with it (auto-escape on). | Most pages |
.sql only | Handler returns text → router sends bytes as-is. | HTMX fragments, JSON APIs, deletes returning '' |
Naming (exact)
- URL path =
/+ segments joined by/. Drop trailingindex. - HTTP method from the stem (
index→ GET,post→ POST). - Template path key = literal
pages/<segments>/<stem>.html. - Handler function =
pgweb.pages__<segments joined by __>__<stem>(e.g.pgweb.pages__todos__toggle__post).
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.