Put a form on a site
A published page posts to a *.api.sh on the same mount. The handler reads the request, writes data, then redirects. There is no separate backend process.
This is not a platform webhook. A webhook starts a background goal. A form handler is short bash that must finish in one request.
Done looks like
The visitor submits. The browser lands on a thanks page (HTTP 303). A row exists in sql --db, or a file exists under the site. handler-test against the script returns status 303 without hitting the live URL.
Worked example: public newsletter subscribe
Job: anonymous visitors on a public site (--policy 644) can POST an email. You store one row per address. A second submit with the same email is a no-op.
Start from a site folder that already publishes (copy a template if you need one):
cp -r /templates/static-site-demo ./site
1. The form page
site/pages/newsletter/index.page.md:
---
title: Newsletter
excerpt: Subscribe.
draft: false
---
<form method="post" action="/newsletter/subscribe">
<label>
Email
<input type="email" name="email" required>
</label>
<button type="submit">Subscribe</button>
</form>
action is a site path, not a full URL. The file that handles it is pages/newsletter/subscribe.api.sh → POST /newsletter/subscribe.
Thanks page site/pages/newsletter/thanks.page.md can be a short “you’re in” note. The handler will respond redirect /newsletter/thanks.
2. The table
App rows go in Postgres, not a page glob and not sails.data.json.
createdb site/app.db
sql --db site/app.db --exec "
CREATE TABLE IF NOT EXISTS subscribers (
email_lower text PRIMARY KEY,
email text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)
"
--exec prints { "affected": 0 } for DDL (or similar). email_lower is the unique key.
See Keep app data in a database.
3. The handler
site/pages/newsletter/subscribe.api.sh:
---
methods: [POST]
security: definer
redirect: /newsletter/thanks
body:
type: object
required: [email]
properties:
email: { type: string, minLength: 3 }
---
#!/usr/bin/env bash
set -euo pipefail
email="$(request param email)"
email_lower="$(printf '%s' "$email" | tr '[:upper:]' '[:lower:]')"
sql --db app.db --exec \
"INSERT INTO subscribers (email_lower, email) VALUES (?, ?) ON CONFLICT (email_lower) DO NOTHING" \
"$email_lower" "$email"
respond redirect /newsletter/thanks
--db app.db is relative to the published mount root (site/ if you published --path site). Put the bundle next to sails.site.json, or pass the same path you use in the workspace.
security: definer runs as the publisher, so anonymous visitors can write. Keep the script narrow. Only repository owners may create or edit *.api.sh files that declare security: definer.
If the visitor is signed in and should write as themselves, omit security: (default invoker) and set auth: required.
Invalid body: never reaches the script (400). Trust required / minLength / format for those fields. Still bind SQL with ?. Never eval the email.
4. Dry-run, then publish
check --path site
handler-test site/pages/newsletter/subscribe.api.sh \
--method POST --form 'email=ada@example.com'
handler-test talks to the session workspace, not the live URL. --auth only toggles signed_in; you cannot forge sub. Definer dry-run still uses your VFS identity — it does not become the publisher.
publish create newsletter --path site --policy 644
--policy 644 is public. Default 600 is owner-only, so strangers cannot POST. Publish once; autosave redeploys. See Publish a site.
Then POST for real:
curl -s -D - -o /dev/null \
-X POST "https://{id}.sails.app/newsletter/subscribe" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'email=ada@example.com'
Expect 303 and Location: /newsletter/thanks (same mount). Confirm the row:
sql --db site/app.db "SELECT email, email_lower FROM subscribers WHERE email_lower = ?" "ada@example.com"
Shape: { "rows": [{ "email": "ada@example.com", "email_lower": "ada@example.com" }], "columns": ["email", "email_lower"] }.
Flags and builtins you actually use
check help
handler-test help
| Piece | What it does |
|---|---|
methods: [POST] | Default is POST-only. Add GET only if the handler should run on navigation. |
body: / query: | JSON Schema. Invalid requests never enter the script. |
security: definer | VFS and sql --db run as the publisher. Public anonymous writes. |
auth: required | Visitor must be signed in. Does not elevate VFS by itself. |
request param FIELD | Form → JSON → query (first non-empty). Prefer this for HTML forms. |
request json | Parsed JSON body (typed). Use for API clients, not application/x-www-form-urlencoded. |
respond redirect /path | Primary success. Default 303. Same-mount paths only. |
SAILS_SITE_ROOT | Repo prefix of this mount (site when --path site; empty at repo root). Prefix file writes: ${SAILS_SITE_ROOT:+$SAILS_SITE_ROOT/}. |
check --path site | Front matter + MiniJinja + handler types. JSON report; look at "ok". |
handler-test FILE --method POST --form 'k=v' | Dry-run. Also --json '{…}'. |
Do not use page sql: or sails.data.json for subscribers. Collections list pages at compile time. See Collections.
Failure modes
| What you see | What it means | What to do |
|---|---|---|
| POST 404 | No *.api.sh for that path, or it is not on this mount | File is pages/newsletter/subscribe.api.sh for /newsletter/subscribe. Publish --path must include it. |
| 405 | Method not in methods | Default is POST. HTML forms are POST. |
| 400 before your script | body: / query: rejected the payload | Fix the form name= to match schema properties. |
| Write denied / empty table on a 644 site | Default invoker as anonymous “other” | Public writes need security: definer (owner-only to author). |
| Cannot save the handler | Collaborator tried to set security: definer | An owner writes that file. |
sql without --db | Flag is required | sql --db app.db … with the bundle next to the site root. |
| Unique violation on second submit | You used plain INSERT | ON CONFLICT (email_lower) DO NOTHING. |
handler-test 200 + HTML | You hit a page, not the handler | Path must end in .api.sh. |
respond redirect to another host | Same-mount only | respond redirect-external https://… if you truly need off-site. |
| Form works for you, 403 for strangers | Mount policy 600 / 640 | publish create … --policy 644 for a public form. |
You INSERTed into a glob | Collections are not a database | sql --db. |
See Static sites for pages vs handlers, and Mail from an agent if the handler should notify someone.