---
title: "Forms as Content: A Scalable Approach to Forms in Next.js + Sanity"
description: "Learn a scalable pattern for adding forms to Next.js + Sanity sites—from native HTML to HubSpot—with form logic centralized in your CMS."
---

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

![call center on the moon, with astronauts taking calls using headsets and holding an application form clipboard, poster with the futuristic text "Sanity Forms"](https://cdn.sanity.io/images/cyu7k2r0/production/338c7bf5b60095b2d87de65571ec3e2834b34b44-1344x896.jpg)

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:

```typescript
import {defineType, defineField} from 'sanity'

export default 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](https://cdn.sanity.io/images/cyu7k2r0/production/cc7ef2cc6a7d9b7955c85cd8d45d275958cc2671-3164x2070.png)

## 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](https://cdn.sanity.io/images/cyu7k2r0/production/fabc9d092b77224d5b760a602424dcce96f01938-2600x1882.png)

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

```typescript
import type {Form} from '@/sanity/types'
import Contact from './contact'
import DemoRequest from './demo-request'

export default function Resolver({form}: {form?: Form}) {
  if (!form) return null

  switch (form.identifier) {
    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**](https://formspree.io/) or [**Basin**](https://usebasin.com/) or your own API route — the `endpoint` field on the form document is all you need.

```typescript
import type {Form} from '@/sanity/types'

export default function Contact({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](/contact)** 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:

```typescript
import {defineType, defineField} from 'sanity'

export default 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)',
    }),
    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 **[sanitypress.dev/getting-started](/docs/getting-started)**.

![astronauts taking calls using headsets, poster with the futuristic text "Sanity Forms"](https://cdn.sanity.io/images/cyu7k2r0/production/58eb8134b9d53e00b7829191d4a75b0f33da1107-1344x896.jpg)
