Sold out: Everything *[NYC] lands next week. See what everyone's in for.

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-forms

sanity-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:

  1. Studio plugin (sanity-plugin-advanced-forms) — the form builder document with a Gravity-Forms-style field palette, a private formSubmission store, a formSettings singleton for Mailgun delivery, and a Submissions inbox tool in the navbar.
  2. Server handler (sanity-plugin-advanced-forms/server) — a framework-agnostic handleFormSubmit() for your API route: spam flagging (honeypot + timing), validation against the form document, Sanity storage, optional JSON forwarding, Mailgun notification.
  3. 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.

Client runtime

The plugin ships no markup and no CSS — a form belongs in its host's own design language — but the behaviour between the click and the answer is not design, and a renderer that reimplements it drifts from the endpoint. Import the runtime and give it your class names:

import {initAdvancedForms} from 'sanity-plugin-advanced-forms/client'

initAdvancedForms({
  endpoint: '/api/form',                    // default
  classNames: {error: 'my-error', submit: 'my-submit', field: 'my-field'},
  messages: {pending: 'Sending…'},          // all copy is overridable
})

It collects the payload, runs the conditional show/hide rules (the same ones the server re-evaluates), does the obvious client-side checks, POSTs, renders field errors, and follows the form's confirmation — message panel or redirect.

Markup contract

Your HTML supplies these hooks; everything else is yours to style.

HookOnPurpose
data-advanced-form<form>Marks a form for the runtime
data-form-id, data-form-title<form>Identify the form document
data-confirm-type, data-confirm-target<form>What success does
name="_honeypot", name="_timestamp"inputsSpam signals — see below
data-form-timestampthe timestamp inputStamped at render
data-field="<name>"field wrapperWhere an error line is appended
data-conditionsfield wrapperSerialised conditional rules
data-required, data-fieldtypeinputClient-side checks
data-counterelement in a fieldLive 0/475 for capped textareas
data-form-errorelementForm-level failure message
data-form-successsibling of the formThe confirmation panel
data-form-statuselementLive region, for the pending state

Pending state

While the request is in flight the submit button is disabled (so a second click cannot post twice), carries data-pending, and is marked aria-busy; [data-form-status] is filled with the pending message and cleared after. Style the busy state however you like — the runtime only sets the attribute:

.my-submit[data-pending] .my-submit-label { visibility: hidden; }
.my-submit[data-pending] .my-spinner { display: block; }

Hiding the label rather than replacing it keeps the button the width it already had, so nothing moves under the cursor at the moment of the click. It is restored in a finally, so a rejected request leaves a form the visitor can correct and send again.

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
build
produces dist/ and publishConfig swaps the exports for publishing.

Related contributions