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.
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.
better-supabase sql add formsimport { 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
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 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
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 for form libraries.
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
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.
sql: {
modules: {
forms: { options: { handlers: { contact: "public.lead_from_submission" } } },
},
},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
With the 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:
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
The signatures SQL module asks members to sign one version of a record, kept by the 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.
better-supabase sql add versions signaturesimport { 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 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 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
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 consumer can count submissions per record. Creating a form, publishing, reviewing and deleting go to the audit log. 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
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:
"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.
Last updated on
Promotions
Percentage, fixed and free-shipping promotions that apply automatically or by code, with usage limits, stacking rules and priorities, priced into orders carts and redeemed at checkout.
Time tracking
Timers and logged time on any record, one running timer per member and no overlapping entries enforced by Postgres, weekly timesheets approved through the approvals module, locked weeks, and a weekly grid shape for a data grid.