Advanced Forms
Advanced form builder for Sanity — build any form in the Studio, drop it on any page, and receive submissions in Sanity and by email (Mailgun).
By Yasin Genc
Install command
npm i sanity-plugin-advanced-formssanity-plugin-advanced-forms
Advanced form builder for Sanity — build any form in the Studio, drop it on any page, and receive submissions in Sanity and by email (Mailgun).
Three parts:
- Studio plugin (
sanity-plugin-advanced-forms) — theformbuilder document with a Gravity-Forms-style field palette, a privateformSubmissionstore, aformSettingssingleton for Mailgun delivery, and a Submissions inbox tool in the navbar. - Server handler (
sanity-plugin-advanced-forms/server) — a framework-agnostichandleFormSubmit()for your API route: spam flagging (honeypot + timing), validation against the form document, Sanity storage, optional JSON forwarding, Mailgun notification. - Your renderer — forms are data; render them in whatever framework the site uses and POST the payload described below.
Studio setup
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {advancedForms, formsStructureItem, FORM_TYPES} from 'sanity-plugin-advanced-forms'
export default defineConfig({
plugins: [
structureTool({
structure: (S) =>
S.list().title('Content').items([
formsStructureItem(S),
...S.documentTypeListItems().filter(
(item) => !FORM_TYPES.includes(item.getId() ?? ''),
),
]),
}),
// {submissionsTool: false} hides the navbar inbox.
// {pageTypes: ['page']} — document types a confirmation may link to.
advancedForms(),
],
})Reference forms from your own blocks with
{type: 'reference', to: [{type: 'form'}]}.
Field palette
Named for the person filling the form in, not the input element:
- Basics — Short text · Long text · Email address · Phone number · Number · Website link · Date
- Choices — Dropdown (pick one) · Multiple choice (pick one) · Checkboxes (pick several) · Agreement box
- Advanced — Hidden value · Embed code (reCAPTCHA, Turnstile…)
The developer names (textarea, radio, html…) live on as search
keywords, so anyone who thinks in markup still finds the right card. Each
field carries a label, an optional stable name override, placeholder,
required, half/full width, an options list and a default value where they
apply.
The builder
The fields array replaces Sanity's default array editor with a visual
builder. Fields lay out on the same half/full two-column grid the site
renders; each card has a drag handle to reorder (keyboard included) and
quick actions — width, duplicate, delete — with Edit opening the full field
dialog, so options, validation and conditional logic keep their whole
editing surface. A card shows what it still needs (Needs a label, Needs
options) so a document-level error points at itself.
Add field opens a searchable picker: every type as a card with an icon and a line saying what it is for. It stays open, because building a form means adding six fields, not one. New fields get a submission key nothing else is using — two fields sharing one would silently merge their answers, so the builder settles the collision as it happens and the form validates that none survives.
Conditional logic
Any field can carry rules — show/hide this field when {other field} {is / is not / contains / greater than…} {value}, matched all-or-any. The renderer evaluates live as the visitor types (hidden fields are disabled, so their values never submit); the server re-evaluates the same shared rules before validating, so a hidden field is never required and a tampered submission gains nothing.
Validation
Per field, beyond required: regex pattern with custom error message (text),
min/max length (text, textarea), min/max (number, date), phone formats, and
options-list enforcement for choice fields. The server is the truth; the
renderer mirrors what it can natively (minlength, min, max…).
Notifications & confirmations
A form carries any number of notifications — each with its own To,
Reply-To, Subject and Body, all supporting merge tags ({field:name},
{all_fields}, {form:title}). To: {field:email} turns one into an
autoresponder. With none configured, a standard alert goes to the default
recipient.
The confirmation is one of two things, not three: Show message — Portable Text, so it can be a heading, paragraphs, a list, a link and an image rather than one line — or Redirect to a destination, picked from this site (a reference, so it survives a rename) or given as a web address. Where they go is a destination, not a second decision.
The submit button takes a label and an optional icon (submitIcon: arrow,
paper plane, envelope, checkmark, download, external link). The plugin
stores the name and your renderer draws it — SUBMIT_ICONS is exported
from /shared so the two stay in step.
Server route
import {handleFormSubmit} from 'sanity-plugin-advanced-forms/server'
// POST /api/form
const result = await handleFormSubmit(await request.json(), {
projectId: 'xxxxxx',
dataset: 'production',
writeToken: env.SANITY_API_WRITE_TOKEN, // Editor token — creates submissions
mailgunApiKey: env.MAILGUN_API_KEY, // optional — email notifications
})
return Response.json(result.data, {status: result.status})Submission payload (what your renderer POSTs)
Flat JSON strings. Reserved keys are underscore-prefixed:
{
"_formId": "<form document _id>", // required
"_formTitle": "Contact", // fallback title
"_honeypot": "", // a hidden input humans never fill
"_timestamp": "1755200000000", // Date.now() at render
"first-name": "Ada", // one key per field — see below
"interests": "Bonds, Equities" // checkboxes: ", "-joined
}A field's key is its Field name if set, else the slugified label, else
its _key (fieldName() is exported from /server so renderers and the
handler always agree).
Responses: 200 {ok: true} (also for spam — indistinguishable on purpose) ·
400 missing _formId · 422 {ok: false, errors: {[field]: message}}.
Delivery configuration
Form settings (Forms → Settings) hold one section per integration —
today that is mailgun (enabled switch, domain, from, default recipient,
plus region and analytics tag under a collapsed Advanced fieldset; region
unset means US). Each form can override the recipient. Future providers
(Zapier, Mailchimp…) slot in as sibling objects with their own pipeline
steps. The API keys and the write token stay in the server environment
— never in a document. Submissions are written under the formSubmission.
id path, which keeps them unreadable on public datasets; the Studio reads
them with member credentials.
Development in a workspace
The package's exports point at src/ (TypeScript) for workspace consumers —
Vite-based tooling (Sanity Studio, Astro) transpiles it directly.
npm run
builddist/ and publishConfig swaps the exports for
publishing.