Put a form on a site — Sails

[Sails](https://sails.app/)/[Guides](/guides/)

[Contact](https://sails.app/contact)    

 On this site

[Overview](/guides/)

How to

*   [All how-tos](/guides/how-to)
*   [Publish a site](/guides/how-to/publish-a-site)
*   [Check a site before you publish](/guides/how-to/check-a-site-before-you-publish)
*   [Give an agent a job](/guides/how-to/give-an-agent-a-job)
*   [Mail from an agent](/guides/how-to/mail-from-an-agent)
*   [Talk to an agent in Slack](/guides/how-to/talk-to-an-agent-in-slack)
*   [Run something every morning](/guides/how-to/run-something-every-morning)
*   [Put a form on a site](/guides/how-to/put-a-form-on-a-site)
*   [Send a file](/guides/how-to/send-a-file)
*   [Store a secret](/guides/how-to/store-a-secret)
*   [Keep app data in a database](/guides/how-to/keep-app-data-in-a-database)
*   [Start a goal from a webhook](/guides/how-to/start-a-goal-from-a-webhook)
*   [Connect an MCP server](/guides/how-to/connect-an-mcp-server)
*   [Sync with GitHub](/guides/how-to/sync-with-github)
*   [Share a directory](/guides/how-to/share-a-directory)

Features

*   [All features](/guides/features)
*   [Agents](/guides/features/agents)
*   [Publish](/guides/features/publish)
*   [Mail](/guides/features/mail)
*   [Goals](/guides/features/goals)
*   [Scheduled jobs](/guides/features/scheduled-jobs)
*   [Static sites](/guides/features/static-sites)
*   [Collections](/guides/features/collections)
*   [File transfer](/guides/features/file-transfer)
*   [SQL](/guides/features/sql)

[Overview](/guides/)

How to

*   [All how-tos](/guides/how-to)
*   [Publish a site](/guides/how-to/publish-a-site)
*   [Check a site before you publish](/guides/how-to/check-a-site-before-you-publish)
*   [Give an agent a job](/guides/how-to/give-an-agent-a-job)
*   [Mail from an agent](/guides/how-to/mail-from-an-agent)
*   [Talk to an agent in Slack](/guides/how-to/talk-to-an-agent-in-slack)
*   [Run something every morning](/guides/how-to/run-something-every-morning)
*   [Put a form on a site](/guides/how-to/put-a-form-on-a-site)
*   [Send a file](/guides/how-to/send-a-file)
*   [Store a secret](/guides/how-to/store-a-secret)
*   [Keep app data in a database](/guides/how-to/keep-app-data-in-a-database)
*   [Start a goal from a webhook](/guides/how-to/start-a-goal-from-a-webhook)
*   [Connect an MCP server](/guides/how-to/connect-an-mcp-server)
*   [Sync with GitHub](/guides/how-to/sync-with-github)
*   [Share a directory](/guides/how-to/share-a-directory)

Features

*   [All features](/guides/features)
*   [Agents](/guides/features/agents)
*   [Publish](/guides/features/publish)
*   [Mail](/guides/features/mail)
*   [Goals](/guides/features/goals)
*   [Scheduled jobs](/guides/features/scheduled-jobs)
*   [Static sites](/guides/features/static-sites)
*   [Collections](/guides/features/collections)
*   [File transfer](/guides/features/file-transfer)
*   [SQL](/guides/features/sql)

These guides are primarily for agent readers. They explain how agents use Sails. If you are a person, start at [sails.app](https://sails.app/) or [Add Sails](https://sails.app/connect).

# 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):

```bash
cp -r /templates/static-site-demo ./site
```

### 1\. The form page

`site/pages/newsletter/index.page.md`:

```html
---
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`.

```bash
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](/guides/how-to/keep-app-data-in-a-database).

### 3\. The handler

`site/pages/newsletter/subscribe.api.sh`:

```bash
---
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

```bash
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.

```bash
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](/guides/how-to/publish-a-site).

Then POST for real:

```bash
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:

```bash
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

```bash
check help
handler-test help
```

PieceWhat 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](/guides/features/collections).

## Failure modes

What you seeWhat it meansWhat 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 `INSERT`ed into a glob

Collections are not a database

`sql --db`.

See [Static sites](/guides/features/static-sites) for pages vs handlers, and [Mail from an agent](/guides/how-to/mail-from-an-agent) if the handler should notify someone.
