# Forms

> Versioned forms with steps and show or skip logic, a JSON Schema 2020-12 per version that Postgres checks on every submission, drafts, public links with a rate limit and a bot-check hook, and handlers that turn a submission into a record, plus simple electronic signatures on a frozen version.

Source: https://bettersupabase.com/docs/blocks/forms

`forms` stores a form, its published versions and the submissions to them. Each version holds a definition (steps of fields, with `showIf` conditions) and the JSON Schema 2020-12 generated from it, and `pg_jsonschema` checks every submission against that schema inside Postgres. Reading forms needs `forms.read`, submitting needs `forms.submit`, and creating and publishing needs `forms.manage`. Respondents read their own submissions and delete their own drafts; reading anyone else's, reviewing and deleting needs `forms.review`.

```bash
better-supabase sql add forms
```

```ts
import { createForms } from "better-supabase/blocks/forms";

const forms = createForms({ transport });
const form = await forms
  .create({ organizationId, key: "signup", title: "Sign up" })
  .orThrow();
await forms
  .publish(form.id, {
    steps: [
      {
        key: "contact",
        title: "Contact",
        fields: [
          { key: "name", label: "Name", type: "text", required: true },
          { key: "email", label: "Email", type: "email", required: true },
          {
            key: "plan",
            label: "Plan",
            type: "option",
            options: [
              { value: "free", label: "Free" },
              { value: "team", label: "Team" },
            ],
          },
        ],
      },
      {
        key: "team",
        showIf: { field: "plan", equals: "team" },
        fields: [
          {
            key: "seats",
            label: "Seats",
            type: "integer",
            min: 2,
            required: true,
          },
        ],
      },
    ],
  })
  .orThrow();
await forms.update(form.id, { status: "open" }).orThrow();
await forms
  .submit(form.id, { name: "Ada", email: "ada@example.com" })
  .orThrow();
```

## Definitions and versions [#definitions-and-versions]

A definition is a list of steps, and each step a list of fields. `publish(formId, definition)` stores it as the form's next numbered version and makes it current; older versions and their submissions stay as they were. A form takes submissions only while its status is `open` (`FORM_CLOSED` otherwise).

| Field type             | Answer                                   | Options                             |
| ---------------------- | ---------------------------------------- | ----------------------------------- |
| `text`, `textarea`     | a string (500 and 10,000 characters max) | `minLength`, `maxLength`, `pattern` |
| `email`, `url`, `date` | a string with that format                |                                     |
| `number`, `integer`    | a number                                 | `min`, `max`                        |
| `bool`                 | `true` or `false`                        |                                     |
| `consent`              | `true`                                   |                                     |
| `option`               | one of `options[].value`                 | `options`                           |
| `multi`                | distinct values from `options`           | `options`, `min`, `max` (items)     |
| `json`                 | any JSON value                           |                                     |

`required: true` asks for the field, and for a non-empty string or list. A `showIf` on a step or field is `{ field, equals }`, `{ field, in }` or `{ field, filled: true }`, naming an earlier field. A hidden field is never required, so its `required` becomes an `if`/`then` in the schema. A field with `customField: "<definition id>"` copies its label, type and `required` from a [custom field](/docs/blocks/custom-fields) when the version is published; give an option field its `options` in the form.

`publish` checks the definition against `FORM_DEFINITION_SCHEMA`, then for what the shape can't say (a key used twice, a condition on a later field, a choice field without options), and fails with `FORM_DEFINITION_INVALID` and the problems in `issues`. `formDefinitionProblems(definition)` runs the second check in the app, and `formSchema(definition)` returns the same schema Postgres stores.

## Submissions [#submissions]

A submission that doesn't match the version's schema fails with a `validation` error (hint `JSON_SCHEMA_INVALID`) whose `issues` name the fields. `validateAnswers(schema, answers)` checks the same keywords in the browser, and `answersSchema(schema)` wraps it as a [Standard Schema](https://standardschema.dev) for form libraries.

```ts
const draft = await forms
  .submit(form.id, { name: "Ada" }, { draft: true })
  .orThrow();
await forms
  .submit(
    form.id,
    { name: "Ada", email: "ada@example.com" },
    { submissionId: draft.id },
  )
  .orThrow();
const page = await forms
  .listSubmissions(form.id, { status: "submitted" })
  .orThrow();
await forms.review(page[0]!.id, "accepted").orThrow();
```

`draft: true` saves answers without checking them, and a later `submit` with `submissionId` finishes the draft against the current version. Pass `subjectType` and `subjectId` to link a submission to a record the caller can read, such as a deal. `listSubmissions` returns the newest first, 50 to a page by default; pass the last `createdAt` as `before` for the next page.

## Turn a submission into a record [#turn-a-submission-into-a-record]

`options.handlers` maps a form key to a function that receives the submission id. It runs in the submission's transaction, and when it returns `{ subjectType, subjectId }` the submission is linked to that record.

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    forms: { options: { handlers: { contact: "public.lead_from_submission" } } },
  },
},
```

```sql
create function public.lead_from_submission(submission_id uuid) returns jsonb
language plpgsql security definer set search_path = '' as $$
declare v_id uuid;
begin
  insert into public.leads (organization_id, name, email)
  select s.tenant_id, s.answers ->> 'name', s.answers ->> 'email'
  from better_supabase.form_submissions s where s.id = submission_id
  returning id into v_id;
  return jsonb_build_object('subjectType', 'lead', 'subjectId', v_id);
end;
$$;
```

## Public forms [#public-forms]

With the [share-links](/docs/blocks/share-links) module, a link with subject type `form` and role `respond` lets anyone who holds it read and submit the form, without an account:

```ts
const link = await shareLinks
  .create(organizationId, "form", form.id, "respond")
  .orThrow();
// On the public page:
const publicForm = await forms.getPublic(token).orThrow();
await forms
  .submitPublic(token, answers, { botToken: turnstileToken })
  .orThrow();
```

An unknown, revoked or expired token fails with `FORM_LINK_INVALID`. With the `rate-limit` SQL module, each caller (the user, else the right-most `x-forwarded-for` address) gets 20 public submissions per form per hour, a `BS429` error with `Retry-After` after that. `options.publicLimit` sets `{ max, period }` (an ISO 8601 duration), or `false` to turn it off. To check a bot challenge, implement the `before_public_form_submission(form_id uuid, answers jsonb, bot_token text)` hook and raise when the token fails; Postgres can't call the challenge's API, so verify it in an Edge Function or keep a table of tokens your server has verified.

## Signatures [#signatures]

The `signatures` SQL module asks members to sign one version of a record, kept by the [versions](/docs/blocks/versions) block. It needs `versions` in managed or adopt mode. Requesting needs `signatures.request`, and reading a record's requests needs `signatures.read`; signers always read the requests they are on. The signature is a simple electronic signature: the module records who signed which content, how and when, and the app renders the final document.

```bash
better-supabase sql add versions signatures
```

```ts
import { createSignatures } from "better-supabase/blocks/forms";

const signatures = createSignatures({ transport });
const request = await signatures
  .request({
    organizationId,
    subjectType: "contract",
    subjectId: contract.id,
    signers: [buyerId, sellerId],
    order: "sequential",
  })
  .orThrow();
await signatures
  .sign(request.id, { method: "typed", typedName: "Ada Lovelace" })
  .orThrow();
```

Without `versionId`, the request snapshots the record's versioned columns first. The version is frozen, so retention and `delete_version` keep it. Signers must be members of the organization, at most 20 and each once. With `order: "sequential"` each signs after the one before; `parallel` is the default. A signature is `click`, `typed` (with the typed name) or `drawn` (with the attachment that holds the image, which needs the [attachments](/docs/blocks/attachments) module). The request completes when everyone has signed, and a signer can `decline` it, which closes it for everyone. The requester, or a holder of `signatures.manage`, can `cancel` a pending request.

Each signature stores a SHA-256 hash over the request, the version's content hash, the signer, the method and the time, with the caller's address and user agent. `get` returns the request with its signatures and a `verification`: `contentMatches` recomputes the signed version's hash from its content, each signature's `valid` recomputes its hash, and `changedSince` is true when the record has a newer version than the one signed. A signed record that was edited later shows `changedSince: true`, and a changed signature or version row shows `valid: false`.

With the [approvals](/docs/blocks/approvals) module, `approvalRequestId` links a pending approval request on the same record. When a signer is an approver for its current step, their signature approves that step, so signing a contract can be the approval that sends it.

Errors carry these hints: `SIGNATURE_SIGNERS_INVALID`, `SIGNATURE_VERSION_NOT_FOUND`, `SIGNATURE_OUT_OF_ORDER`, `SIGNATURE_ALREADY_SIGNED`, `SIGNATURE_METHOD_INVALID`, `SIGNATURE_CONTENT_CHANGED` (the version changed between the request and the signature), `SIGNATURE_REQUEST_CLOSED` and `SIGNATURE_FORBIDDEN`.

## Events and hooks [#events-and-hooks]

The module records `form.created`, `form_version.published`, `form_submission.submitted` (with `versionId` and `source`: `member`, `public` or `service`), `form_submission.reviewed` (with `status`) and `form_submission.deleted`. The submission events carry the linked `subjectType` and `subjectId`, so an [outbox](/docs/blocks/outbox) consumer can count submissions per record. Creating a form, publishing, reviewing and deleting go to the [audit log](/docs/blocks/audit). It calls `before_form_publish`, `after_form_publish`, `before_form_submission`, `after_form_submission` and `before_public_form_submission`.

The signatures module records `signature.requested`, `signature.signed` (with `signer` and `method`), `signature.completed`, `signature.declined` and `signature.cancelled`, each with the signed record's `subjectType` and `subjectId`, and audits requests, signatures, declines and cancellations. It calls `before_signature_request`, `after_signature_request`, `before_signature_sign` and `after_signature_sign`.

## With ReUI [#with-reui]

`useQuestionnaire` from `better-supabase/blocks/forms/react` holds the answers, the visible steps and the issues per field for one version, so a ReUI Stepper and its questionnaire layouts render the form without other state:

```tsx
"use client";
import { useQuestionnaire } from "better-supabase/blocks/forms/react";

const q = useQuestionnaire({
  form: version,
  onSubmit: (answers) => forms.submit(formId, answers).orThrow(),
});
// <Stepper value={q.step?.key} onValueChange={q.goTo}>: one StepperItem per q.steps entry, completed={step.completed}
// q.step?.fields: render each with its input, value={q.answers[field.key]}, error={q.issues[field.key]?.[0]}
// Back: q.back(), hidden when q.isFirst. Next: q.isLast ? q.submit() : q.next()
```

`next()` checks the step's fields against the version's schema and stays on the step when one fails. Answering a field that hides later steps moves past them, and `submit()` sends only the answers of fields still visible. Submissions render in a ReUI data grid from `listSubmissions`, with accept and reject actions that call `review`.

For drawn signatures, ReUI's Signature Pad (free, MIT) captures the image. Upload it with the attachments block, then pass its id to `sign(request.id, { method: "drawn", imageAttachmentId })`. A ReUI timeline renders `get(request.id)`: one item per signer, completed when their signature is in `signatures`, with a warning when `verification.changedSince` is true.