Sanity Context — smarter AI starts with better context

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.

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