Forms as Content: A Scalable Approach to Forms in Next.js + Sanity

Forms as Content: A Scalable Approach to Forms in Next.js + Sanity

Mitchell Christ
Mitchell Christ
3 min read
On this page
  1. The core idea: forms as documents
  2. Adding a form module to a page
  3. Resolving forms on the frontend
  4. Example 1: Native HTML forms
  5. Example 2: HubSpot
  6. Going further
  7. SanityPress gives you this out of the box
call center on the moon, with astronauts taking calls using headsets and holding an application form clipboard, poster with the futuristic text "Sanity Forms"

If you've built more than a handful of marketing pages in Next.js, you've probably hit the same wall. A contact form lives in one component, a demo request form is hardcoded somewhere else, and when the marketing team wants to swap the homepage CTA form to a HubSpot embed.

The root issue isn't the form itself. It's that forms are treated as UI, when they're really content. Content that changes, gets repurposed across pages, and needs to be owned by people who shouldn't have to touch your codebase.

This post walks through an architecture for managing forms as first-class Sanity documents, referencing them from page modules, and resolving them on the frontend—with practical examples for native HTML forms and HubSpot, plus a pattern you can extend to anything else.

The core idea: forms as documents#

Instead of embedding form logic into your page components, you define forms as standalone documents in Sanity. Each form document holds everything needed to render and submit the form: an identifier, a submission endpoint, and any integration-specific fields.

A minimal form document schema looks something like this:

  • src/sanity/schemaTypes/documents/form.ts
  • defineType({
      name: 'form',
      title: 'Form',
      type: 'document',
      fields: [
        defineField({
          name: 'identifier',
          type: 'string',
          description: 'Used by the frontend to resolve which form to render',
          placeholder: 'e.g. "contact" or "demo-request"'
        }),
        defineField({
          name: 'endpoint',
          type: 'url',
          description: 'Used with native HTML form submissions (i.e. the `action` attribute URL)',
        }),
      ],
    })

    The identifier field is the key piece. Rather than coupling your frontend component to a specific Sanity document ID, you use a stable string (e.g. contact, demo-request, newsletter) that the frontend can match against. Your editors can swap which document backs a given module without touching code.

    Form document with an identifier and custom fields

    Form document with and identifier and custom fields

    Adding a form module to a page#

    With the form document now defined, simply add the form-module, a page module that references a form document. This is what editors drop onto any page to embed a form.

    Adding a form module to a page document

    Adding a form module to a page document

    Resolving forms on the frontend#

    In your Next.js component tree, the form module receives the referenced form document as a prop (after Sanity resolves the reference in your GROQ query). You resolve which UI to render based on the identifier field:

  • src/ui/modules/form-module/resolver.tsx
  • import type { Form } from '@/sanity/types'
    import Contact from './contact'
    import DemoRequest from './demo-request'
    
    export default function ({ form }: { form?: Form }) {
      if (!form) return null
    
      switch (form.identifer) {
        case 'contact':
          return <Contact form={form} />
      
        case 'demo-request':
          return <DemoRequest form={form} />
    
        // and many more...
      
        default:
          return null
      }
    }

    Example 1: Native HTML forms#

    For straightforward use cases — sending submissions to a service like Formspree, Basin, or your own API route — the endpoint field on the form document is all you need.

  • src/ui/modules/form-module/contact.tsx
  • import type { Form } from '@/sanity/types'
    
    export default function ({ form }: { form: Form }) {
      return (
        <form action={form.endpoint} method="POST">
          <input name="name" type="text" required />
          <input name="email" type="email" required />
          <textarea name="message" required />
          <button type="submit">Send</button>
        </form>
      )
    }

    Editors control the endpoint from Sanity Studio, so if you move from Formspree to your own API route, it's a field update, not a code change.

    👀 See a demo of the form module using this native HTML markup.

    Example 2: HubSpot#

    For teams using HubSpot, you need two additional fields: a Portal ID and a Form ID. Rather than building a separate schema, you can extend the base form document with integration-specific fields:

  • src/sanity/schemaTypes/documents/form.ts
  • defineField({
      name: 'portalId',
      title: 'HubSpot Portal ID',
      type: 'string',
      placeholder: 'e.g. 1234567',
    }),
    defineField({
      name: 'formId',
      title: 'HubSpot Form ID',
      type: 'string',
      placeholder: 'e.g. abcd1234-abcd-1234-abcd-1234abcd1234',
    }),

    Editors configure the HubSpot IDs directly in the Studio. Swapping to a different HubSpot form— say, for a regional campaign—is a content change, not a deployment.

    The npm package react-hubspot-form is useful for handling the implementation in Next.js.

    Going further#

    The same pattern extends cleanly to other providers:

    • Resend / email-based submissions — point endpoint at a Next.js API route that calls the Resend SDK and sends a notification email
    • Typeform — store the Typeform embed URL in a custom field and render <iframe src={embedUrl} />
    • Marketo, Pardot, or custom backends — same idea: store whatever identifying fields the provider needs, render the appropriate component

    The architecture stays the same regardless of what's underneath. Your Sanity form schema is the contract; your frontend components are the implementations.

    SanityPress gives you this out of the box#

    If you're starting a new Next.js + Sanity project, SanityPress ships with the form document type and form module already wired up. The identifier pattern, the Studio UI, and the page builder integration are all there from day one—so you can skip the scaffolding and go straight to connecting your form provider of choice.

    👨‍🚀 Started building websites and contact forms easily at typed.sanitypress.dev/getting-started.
    astronauts taking calls using headsets, poster with the futuristic text "Sanity Forms"

    Build with confidence

    SanityPress gives you a modern, scalable starting point that stays out of your way.