Forms — @sonordev/site-kit/forms
Sonor-managed forms with multi-step support, conditional logic, validation, anti-bot protection, and automatic CRM routing.
Usage
Option 1: Managed (simplest)
import { ManagedForm } from '@sonordev/site-kit/forms'
export default function ContactPage() {
return <ManagedForm formId="contact-form" />
}Fetches form config from Sonor, renders fields, handles submission, routes to CRM.
Experiences (4.0)
Since 4.0 that one line gets you the spotlight experience by default: the familiar layout, alive. A glowing ring visits the field you're in, completed fields earn a check, and each finished row collapses into a sentence the form says back — "Nice to meet you, Jordan." / "We'll follow up at jordan@…" — with an edit control to reopen it.
| Experience | What it is | How to get it |
|---|---|---|
spotlight | Default. Fields perform in place; answered rows become sentences | nothing to do |
stage | Accordion: one beat at a time on a single surface, answered beats settle into filled rows | experience="stage" |
classic | The plain grid form, no choreography | experience="classic" |
<ManagedForm formId="contact-form" /> // spotlight
<ManagedForm formId="contact-form" experience="stage" /> // accordion
<ManagedForm formId="contact-form" experience="classic" /> // opt outSonor can decide instead of the site, with no deploy on either side — set the
form's layout to classic, stage, or spotlight. An explicit
experience prop always wins over the config.
Three things hold for every experience:
- The lead is always capturable. SSR and no-JS render the plain classic form; the experience layers on after hydration. If its chunk never arrives, the form still works and still submits.
- Nobody pays for what they don't use. Experience code is loaded on
demand, so a
classicform downloads none of it. - Same engine. Identical validation, spam protection and CRM routing. An experience is a rendering, not a fork.
Editing what the form says back
The sentence for a row comes from the kit's built-in wording unless you write
your own in Sonor: set completion_message on a field (the first answered
field in the row that has one wins). Tokens:
| Token | Resolves to |
|---|---|
{value} | this field's answer |
{label} | this field's label |
{first_name} | the form's first-name answer, if any |
{any_field_slug} | that field's answer |
Welcome aboard, {first_name}. We'll reach you at {email}.
Leave it empty to keep the built-in sentence. Unknown or unanswered tokens render as nothing — never raw braces.
Option 2: Headless Hook (full UI control)
'use client'
import { useForm } from '@sonordev/site-kit/forms'
export function ContactForm() {
const {
fields, values, errors, setFieldValue, handleSubmit, toolAttributes, isSubmitting,
step, totalSteps, isMultiStep, nextStep, prevStep, isLastStep,
} = useForm('contact-form')
return (
<form {...toolAttributes} onSubmit={handleSubmit}>
{fields.map(field => (
<div key={field.slug}>
<label>{field.label}</label>
<input
value={String(values[field.slug] || '')}
onChange={(e) => setFieldValue(field.slug, e.target.value)}
/>
{errors[field.slug] && <span className="error">{errors[field.slug]}</span>}
</div>
))}
{isMultiStep && !isLastStep && <button type="button" onClick={nextStep}>Next</button>}
{isLastStep && <button type="submit" disabled={isSubmitting}>Submit</button>}
</form>
)
}Option 3: Render Prop
<ManagedForm formId="contact-form">
{({ fields, values, setFieldValue, submit }) => (
<MyCustomFormUI fields={fields} values={values} onChange={setFieldValue} onSubmit={submit} />
)}
</ManagedForm>Option 4: Programmatic API
import { formsApi, field, configureFormsApi } from '@sonordev/site-kit/forms'
configureFormsApi({ baseUrl: 'https://api.sonor.io', apiKey: 'sonor_...' })
const form = await formsApi.create({
projectId: 'xxx',
slug: 'newsletter',
name: 'Newsletter Signup',
formType: 'newsletter',
fields: [
field.email('email', 'Email', { isRequired: true }),
field.text('name', 'Name'),
],
})ManagedForm Props
interface ManagedFormProps {
formId: string // Form slug or ID
className?: string
onSuccess?: (submission: FormSubmitResult) => void
onError?: (error: Error) => void
children?: (renderProps: UseFormReturn) => ReactNode // Render prop override
}useForm Return
interface UseFormReturn {
form: ManagedFormConfig | null
isLoading: boolean
fetchError: Error | null
allFields: FormField[]
fields: FormField[] // Current step's fields
visibleFields: FormField[] // After conditional logic
values: Record<string, unknown>
errors: Record<string, string>
setFieldValue: (key: string, value: unknown) => void
step: number
totalSteps: number
isMultiStep: boolean
progress: number // 0-100
nextStep: () => void
prevStep: () => void
goToStep: (n: number) => void
canGoNext: boolean
canGoPrev: boolean
isLastStep: boolean
validate: () => boolean
// Resolves with what happened: { status: 'sent' | 'invalid' | 'failed' | 'busy' | 'next_step', ... }
submit: (options?: FormSubmitOptions) => Promise<FormSubmitOutcome>
// A ready-made <form onSubmit>: steps or submits, and answers an AI agent (see below)
handleSubmit: (event: FormEvent<HTMLFormElement>) => void
// WebMCP attributes for your <form>; null until the config loads
toolAttributes: { toolname: string; tooldescription: string } | null
isSubmitting: boolean
isComplete: boolean
reset: () => void
}Field Types
text, email, phone, tel, number, textarea, select, multi-select, checkbox, radio, date, time, datetime, file, signature, rating, slider, hidden, heading, section_header, paragraph
Field Builder
field.text(slug, label, options?)
field.email(slug, label, options?)
field.phone(slug, label, options?)
field.textarea(slug, label, options?)
field.select(slug, label, { options: [{ value, label }] })
field.date(slug, label, options?)
field.checkbox(slug, label, options?)
field.rating(slug, label, options?)Form Routing
Submissions auto-route based on form_type:
| Form Type | Routes To | Use Case |
|---|---|---|
prospect / lead-capture / contact | CRM Contacts | Sales inquiries, quotes |
support | Support Tickets | Help requests |
feedback | Feedback entries | User feedback |
newsletter | Email Subscribers | Newsletter signups |
custom | Form Submissions only | Custom handling |
Spam protection
Sonor screens every submission on its side, and there's nothing to configure.
It only accepts a submission with evidence that a real browser rendered the
page, which <ManagedForm> and useForm send automatically. A hand-rolled
fetch to the forms API, or a proxy through your own API route, can't send
it, so Sonor refuses those before anything is written: the visitor sees an
error and you get no lead. Submit through site-kit, and define the form's
fields in Sonor so both can render and validate them.
Agent-ready forms (7.2.0)
People increasingly ask an AI assistant to fill out a form for them: a quote request, a booking, a newsletter signup. Assistants act on what the page's markup says each control is, so managed forms now say it clearly. There's nothing to configure.
- Autocomplete tokens. Name, email, phone, company, website and address
fields carry the right
autocompletetoken (given-name,email,tel,organization,url,postal-code, ...), read from the field's CRM destination in Sonor, then its type, slug and label. Browser autofill and password managers use the same tokens. - Accessible wiring. Error and help text are tied to their control
(
aria-describedby), an errored control says so (aria-invalid), radio and checkbox groups are named by their question, and rating stars by their value. - WebMCP. Every interactive managed form carries the declarative WebMCP
attributes (
toolname,tooldescription), so a browser agent that supports WebMCP can treat it as a tool. It fills the same fields a person would, and the submit runs the same validation. There's notoolautosubmit: the assistant fills the form and the person it's helping presses Send. Browsers without WebMCP ignore the attributes. - Agent-sent leads are tagged. When the browser reports that an agent
pressed submit (
SubmitEvent.agentInvoked), the submission carries that flag and the lead is tagged in Sonor, so you can see how those leads compare over time. It's the browser's word, so it never changes how a submission is checked. The agent is handed the outcome of its submit (respondWith). - Nothing typed before the form loads is lost.
ServerFormrenders the form in the page HTML and loads the interactive version at idle. Since 7.2.0, typing into the server-rendered form starts that upgrade at once and what was typed carries over, and a Send pressed before the upgrade is held and sent the moment it lands. The server-rendered Send button stays disabled until the page can catch the click.
With useForm, spread toolAttributes on your <form> and use
handleSubmit as its onSubmit to get the same behavior.
ServerForm's enhance prop is ignored since 7.2.0. Only the interactive
form can send a managed form, so enhance={false} only ever produced a form
that silently went nowhere.
Styles
import '@sonordev/site-kit/forms/styles.css' // Optional default stylesAll components use .sk-form__* class names. Override with className prop or CSS variables.