HubForm & Hosted Submit

Render forms authored in the hosted Saastro builder and submit them to the hosted backend at submit.saastro.io — schema fetching, captcha auto-wiring, file uploads, and the standalone submit helper.

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 via formProps={{ components }} or wrap your app in a provider — see the Components guide.


Props

import type { HubFormProps } from '@saastro/forms';
PropTypeDefaultDescription
siteIdstring (required)Site identifier the form belongs to.
formSlugstring (required)Slug of the form within the site.
hubUrlstring'https://submit.saastro.io/v1'Endpoint base URL, no trailing slash. Override for self-hosted or test environments.
localestringActive locale. Applies the schema’s i18n.translations[locale] overlay (see i18n).
loadingFallbackReactNodesmall built-in skeletonUI while the schema fetch is in flight.
errorFallback(error: string) => ReactNodebuilt-in red bannerUI when the schema fetch fails. Receives the error message as a string.
unconfiguredFallbackReactNodebuilt-in info bannerUI when the schema is fetched OK but contains no fields/steps. Pass null to render nothing.
successFallbackReactNodebuilt-in thank-you panelUI after a successful submission. Pass null to render nothing (e.g. close a modal in onSuccess instead).
onSuccess(values: Record<string, unknown>) => voidCalled once after the endpoint confirms the submission, with the submitted values.
onError(error: Error) => voidCalled when the submission fails (network error, 4xx/5xx).
formPropsOmit<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.

StateWhenDefault UI
loadingSchema fetch in flight”Loading…” skeleton (data-saastro-hubform-loading)
readySchema fetched with at least one field and one stepThe rendered <Form>
unconfiguredSchema fetched OK but fields or steps is emptyAmber info banner (data-saastro-hubform-unconfigured)
errorSchema fetch failed (non-2xx response, network error)Red banner with the error message (role="alert")
submittedThe hosted endpoint accepted a submission ({ ok: true })Green “Thanks — we’ll get back to you soon.” panel (data-saastro-hubform-submitted)

Notes:

  • The unconfigured state exists so a published page never crashes when a form exists on the backend but the author hasn’t added fields yet.
  • unconfiguredFallback and successFallback are checked against undefined, so passing null renders nothing at all.
  • Submission failures do not switch to the error state. The error state is only for the schema fetch. A failed submission calls onError and rethrows, so the inner <Form> shows its own error panel and the user can retry.
  • In the ready state, the fetched schema’s own submit config (if any) is ignoredHubForm always 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:

  1. HubForm creates a PluginManager and registers turnstilePlugin or recaptchaPlugin with tokenField: '_captchaToken' (see Plugins).
  2. The plugin fetches a fresh token during the submit pipeline’s transformValues phase and appends it to the values as _captchaToken.
  3. The submit handler extracts _captchaToken and sends it to the endpoint as captchaToken, where it is verified server-side (the secret key never reaches the browser).
  4. For Turnstile, HubForm renders 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:

  1. POST {base}/upload-url with { filename, size, mime } → the endpoint responds with { ok, uploadUrl, key } — a presigned PUT URL for object storage (R2).
  2. The file body is PUT directly 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 as captchaToken on the submit body. The legacy name _turnstile is also accepted. Both built-in captcha plugins can write to this field via their tokenField option (HubForm configures it automatically).
  • _hp / _hp* honeypot — any string field whose name starts with _hp is treated as a honeypot and sent top-level on the submit body. If a honeypot value is non-empty, the endpoint responds 200 OK but 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:

RouteMethodPurpose
{base}.jsonGETFetch the form schema (used by <HubForm>)
{base}/upload-urlPOSTGet a presigned upload URL for one file → { ok, uploadUrl, key }
{base}/submitPOSTDeliver { 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, hubUrl had to be passed explicitly.