HubForm & Hosted Submit
@saastro/forms includes a built-in integration with Saastro’s hosted form backend (“Hub”): forms are authored in the hosted builder, stored server-side, and served as JSON schemas from https://submit.saastro.io/v1. Two entry points cover it:
<HubForm>— fetches a form schema from the hosted endpoint and renders<Form>configured to submit back to it. Loading, error, captcha, and file uploads are handled for you.createHubFormSubmit()— the underlying submit handler, usable standalone when you own the form config but want submissions delivered to the hosted backend (or a compatible self-hosted endpoint).
This is the package’s only built-in hosted-backend integration (schema fetch + submit) and its only presigned file-upload pipeline — files go directly to object storage instead of through your endpoint. If you just want to POST values to your own API, see Submit & Actions.
Quick Start
import { HubForm } from '@saastro/forms';
// Auto-discover your shadcn/ui components (see /docs/components)
const ui = import.meta.glob('@/components/ui/*.tsx', { eager: true });
export function ContactSection() {
return (
<HubForm
siteId="acme"
formSlug="contact"
formProps={{ components: ui }}
onSuccess={(values) => console.log('submitted', values)}
/>
);
}
On mount, <HubForm> requests GET https://submit.saastro.io/v1/acme/contact.json and, once the schema arrives, renders the form. Submissions go back to the same endpoint — no backend code on your side.
UI components are still required.
<HubForm>renders the regular<Form>, which needs a component registry. Pass it viaformProps={{ components }}or wrap your app in a provider — see the Components guide.
Props
import type { HubFormProps } from '@saastro/forms';
| Prop | Type | Default | Description |
|---|---|---|---|
siteId | string (required) | — | Site identifier the form belongs to. |
formSlug | string (required) | — | Slug of the form within the site. |
hubUrl | string | 'https://submit.saastro.io/v1' | Endpoint base URL, no trailing slash. Override for self-hosted or test environments. |
locale | string | — | Active locale. Applies the schema’s i18n.translations[locale] overlay (see i18n). |
loadingFallback | ReactNode | small built-in skeleton | UI while the schema fetch is in flight. |
errorFallback | (error: string) => ReactNode | built-in red banner | UI when the schema fetch fails. Receives the error message as a string. |
unconfiguredFallback | ReactNode | built-in info banner | UI when the schema is fetched OK but contains no fields/steps. Pass null to render nothing. |
successFallback | ReactNode | built-in thank-you panel | UI after a successful submission. Pass null to render nothing (e.g. close a modal in onSuccess instead). |
onSuccess | (values: Record<string, unknown>) => void | — | Called once after the endpoint confirms the submission, with the submitted values. |
onError | (error: Error) => void | — | Called when the submission fails (network error, 4xx/5xx). |
formProps | Omit<FormProps, 'config'> | — | Forwarded to the inner <Form> — most importantly components and className. config is owned by HubForm; for submission callbacks use onSuccess/onError above. |
The Five States
<HubForm> is a small state machine: loading → ready | unconfigured | error, and ready → submitted after a successful submission.
| State | When | Default UI |
|---|---|---|
loading | Schema fetch in flight | ”Loading…” skeleton (data-saastro-hubform-loading) |
ready | Schema fetched with at least one field and one step | The rendered <Form> |
unconfigured | Schema fetched OK but fields or steps is empty | Amber info banner (data-saastro-hubform-unconfigured) |
error | Schema fetch failed (non-2xx response, network error) | Red banner with the error message (role="alert") |
submitted | The hosted endpoint accepted a submission ({ ok: true }) | Green “Thanks — we’ll get back to you soon.” panel (data-saastro-hubform-submitted) |
Notes:
- The
unconfiguredstate exists so a published page never crashes when a form exists on the backend but the author hasn’t added fields yet. unconfiguredFallbackandsuccessFallbackare checked againstundefined, so passingnullrenders nothing at all.- Submission failures do not switch to the
errorstate. Theerrorstate is only for the schema fetch. A failed submission callsonErrorand rethrows, so the inner<Form>shows its own error panel and the user can retry. - In the
readystate, the fetched schema’s ownsubmitconfig (if any) is ignored —HubFormalways overrides it so submissions go back to the hosted endpoint.
Custom fallbacks:
<HubForm
siteId="acme"
formSlug="contact"
formProps={{ components: ui }}
loadingFallback={<Spinner />}
errorFallback={(msg) => <p className="text-red-600">Form unavailable: {msg}</p>}
unconfiguredFallback={null}
successFallback={<ThankYouCard />}
/>
Locale
Pass the host page’s current language and HubForm injects it as config.locale. When the fetched schema carries i18n.translations[locale], labels, placeholders, options, buttons, and step titles are translated; untranslated strings fall back to the base language. See the i18n guide.
<HubForm siteId="acme" formSlug="contact" locale="en" formProps={{ components: ui }} />
Captcha Auto-Wiring
If the served schema declares a captcha provider in its meta, HubForm wires everything up automatically — no setup in the host app:
// inside the fetched schema
{
"meta": {
"captchaProvider": "turnstile", // or "recaptcha-v3"
"captchaSiteKey": "0x4AAA..." // public site key — safe to expose
}
}
What happens:
HubFormcreates aPluginManagerand registersturnstilePluginorrecaptchaPluginwithtokenField: '_captchaToken'(see Plugins).- The plugin fetches a fresh token during the submit pipeline’s
transformValuesphase and appends it to the values as_captchaToken. - The submit handler extracts
_captchaTokenand sends it to the endpoint ascaptchaToken, where it is verified server-side (the secret key never reaches the browser). - For Turnstile,
HubFormrenders an inline mount node after the form —<div data-saastro-turnstile translate="no" className="notranslate" />. The widget runs in interaction-only mode: invisible unless Cloudflare requires a challenge.translate="no"prevents Google Translate from rewriting the widget’s DOM, which would break token generation.
createHubFormSubmit() — Standalone Use
When you need full control over the form (your own config, prefilling, custom captcha widget, custom error UI), skip <HubForm> and use the submit handler directly:
// Public exports — import { createHubFormSubmit, DEFAULT_HUB_URL } from '@saastro/forms'
const DEFAULT_HUB_URL = 'https://submit.saastro.io/v1';
interface CreateHubFormSubmitOptions {
hubUrl?: string; // defaults to DEFAULT_HUB_URL
siteId: string;
formSlug: string;
}
// Returns a CustomSubmitConfig: { type: 'custom', onSubmit(values) {...} }
function createHubFormSubmit(opts: CreateHubFormSubmitOptions): CustomSubmitConfig;
Plug it into any form config as the submit handler:
import { Form, FormBuilder, createHubFormSubmit } from '@saastro/forms';
const ui = import.meta.glob('@/components/ui/*.tsx', { eager: true });
const config = FormBuilder.create('contact')
.addField('email', (f) => f.type('email').label('Email').required().email())
.addField('message', (f) => f.type('textarea').label('Message').required())
.addStep('main', ['email', 'message'])
.submit(createHubFormSubmit({ siteId: 'acme', formSlug: 'contact' }))
.build();
<Form config={config} components={ui} />;
In standalone mode captcha is not auto-wired. If the target form requires a captcha token, register the plugin yourself with the canonical token field:
import { PluginManager, turnstilePlugin } from '@saastro/forms';
const pluginManager = new PluginManager();
pluginManager.register(
turnstilePlugin({ siteKey: '0x4AAA...', tokenField: '_captchaToken' })
);
// then attach it: chain `.usePlugins(pluginManager)` in the FormBuilder,
// or set `pluginManager` on a raw config object
File Uploads (Presigned Pipeline)
file fields work out of the box with the hosted backend. On submit, the handler partitions the form values: File instances (and arrays containing only Files) take the upload path; everything else becomes the JSON payload.
For each file, in parallel:
POST {base}/upload-urlwith{ filename, size, mime }→ the endpoint responds with{ ok, uploadUrl, key }— a presigned PUT URL for object storage (R2).- The file body is
PUTdirectly to that URL. It never passes through the submit endpoint itself.
The final submission then references the uploaded files as attachment metadata:
// POST {base}/submit
{
"payload": { "email": "[email protected]", "message": "Hi!" },
"attachments": [
{ "key": "uploads/…", "filename": "cv.pdf", "size": 48211, "mime": "application/pdf" }
],
"captchaToken": "…" // only when a captcha plugin appended _captchaToken
}
Upload failures throw Error('upload-url failed: …') or Error('upload PUT to R2 failed: …'); a rejected submission throws Error('submit failed: …'). All of them surface through <Form>’s error panel and the onError callback.
Special Field Conventions
Two value-name conventions are recognized by the submit handler and stripped from the regular payload:
_captchaToken— the captcha token field. Extracted and sent ascaptchaTokenon the submit body. The legacy name_turnstileis also accepted. Both built-in captcha plugins can write to this field via theirtokenFieldoption (HubFormconfigures it automatically)._hp/_hp*honeypot — any string field whose name starts with_hpis treated as a honeypot and sent top-level on the submit body. If a honeypot value is non-empty, the endpoint responds200 OKbut silently discards the submission — bots that fill every input disqualify themselves without ever seeing an error.
Endpoint Protocol & Self-Hosting
All requests go to ${hubUrl}/${siteId}/${formSlug} (both segments URL-encoded). Three routes make up the contract:
| Route | Method | Purpose |
|---|---|---|
{base}.json | GET | Fetch the form schema (used by <HubForm>) |
{base}/upload-url | POST | Get a presigned upload URL for one file → { ok, uploadUrl, key } |
{base}/submit | POST | Deliver { payload, attachments?, captchaToken?, ...honeypot } → { ok, submissionId? } |
Any service implementing these three routes can be targeted by passing hubUrl — useful for self-hosted deployments and test environments:
<HubForm hubUrl="https://forms.example.com/v1" siteId="acme" formSlug="contact" formProps={{ components: ui }} />
Version note: the built-in default endpoint (
DEFAULT_HUB_URL) exists since v0.4.0. On earlier versions,hubUrlhad to be passed explicitly.