# HubForm & Hosted Submit

URL: https://docs.forms.saastro.io/docs/hub-form
> 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](/docs/submit).

---

## Quick Start

```tsx

// Auto-discover your shadcn/ui components (see /docs/components)
const ui = import.meta.glob('@/components/ui/*.tsx', { eager: true });

  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>`](/docs/form-component), which needs a component registry. Pass it via `formProps={{ components }}` or wrap your app in a provider — see the [Components guide](/docs/components).

---

## Props

```ts
```

| 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](/docs/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 `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 **ignored** — `HubForm` always overrides it so submissions go back to the hosted endpoint.

Custom fallbacks:

```tsx
<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](/docs/i18n).

```tsx
<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:

```jsonc
// 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](/docs/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:

```ts
// 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:

```tsx

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:

```tsx

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 `File`s) 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:

```jsonc
// POST {base}/submit
{
  "payload": { "email": "ada@example.com", "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:

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

```tsx
<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.
