ObjectUIObjectUI

Public Forms

Embed hardened public-facing forms — contact, lead capture, signup — with built-in anti-spam, GDPR consent, prefill whitelist, and open-redirect protection.

Public Forms

@object-ui/plugin-form ships an EmbeddableForm component that renders a public-facing form (contact us, lead capture, signup, RSVP) with the security defaults you'd expect from Airtable Forms, Typeform or HubSpot Forms — without asking app authors to bolt them on themselves.

The console exposes a turnkey route at /f/:slug (FormPage) that loads a FormView spec from the server's GET /api/v1/forms/:slug resolver and renders the merged form. The same component also serves authed internal forms at /forms/:name (mode="internal"), reading the FormView spec from /api/v1/meta/view/:name and posting to /api/v1/data/:object.

Quick start

import { EmbeddableForm } from '@object-ui/plugin-form';
import { restDataSource } from '@object-ui/data-rest';

<EmbeddableForm
  config={{
    formId: 'contact-us',
    objectName: 'lead',
    title: 'Contact us',
    description: "We'd love to hear from you.",
    customFields: [
      { name: 'name',    label: 'Full name', type: 'text',  required: true },
      { name: 'email',   label: 'Email',     type: 'email', required: true },
      { name: 'message', label: 'Message',   type: 'textarea', rows: 4 },
    ],
    consent: { required: true },
    privacyPolicyUrl: '/legal/privacy',
    allowedPrefillFields: ['email'],
    allowedRedirectHosts: ['example.com', '*.example.com'],
    thankYouPage: {
      title: 'Thanks!',
      message: "We'll get back to you shortly.",
      redirectUrl: 'https://example.com/thank-you',
    },
  }}
  dataSource={restDataSource}
/>

Security defaults (at a glance)

DefenceDefaultConfig key
Honeypot field (silent fake-success on bots)Onhoneypot (string to rename, false to disable)
Min-fill-time guard1500 msminFillTime (ms, 0 to disable)
URL prefill whitelistnone — prefill is fully offallowedPrefillFields: string[]
Open-redirect guardSame-origin onlyallowedRedirectHosts: string[] (supports *.example.com)
Default maxLengthtext 200 · email 254 · url 2048 · phone 32 · textarea/markdown/html 5000Per-field maxLength overrides
GDPR consent gateOff (opt-in)consent: { required, label } + privacyPolicyUrl
CAPTCHA tokenOff (opt-in)captchaToken (string sent as _captcha)
Demo mode (?demo=1)DEV onlygated by import.meta.env.DEV in EmbeddableForm consumers

All gates run before the network call. If the consent gate or min-fill timer trips, the backend is never contacted. The honeypot silently shows the thank-you page so bots can't tell they failed.

Configuration reference

interface EmbeddableFormConfig {
  formId: string;
  objectName: string;
  title?: string;
  description?: string;

  // Fields — either from a registered schema or inline
  fields?: string[];
  customFields?: FormField[];

  // Anti-spam
  honeypot?: string | false;            // field name (default '_company_website_2'), false disables
  minFillTime?: number;                 // ms before submit allowed (default 1500)
  captchaToken?: string;                // forwarded as payload._captcha

  // Prefill & redirect hardening
  allowedPrefillFields?: string[];      // empty/undefined → no URL prefill
  allowedRedirectHosts?: string[];      // supports '*.example.com'

  // GDPR
  consent?: { required?: boolean; label?: string };
  privacyPolicyUrl?: string;

  // UI
  branding?: { logo?: string; primaryColor?: string; coverImage?: string };
  thankYouPage?: { title?: string; message?: string; redirectUrl?: string; redirectDelay?: number };
  texts?: EmbeddableFormTexts;          // i18n-friendly string overrides
}

URL prefill (safe-by-default)

Public form URLs are user-controlled, so prefill is off unless you explicitly opt fields in:

<EmbeddableForm
  config={{
    /* … */
    allowedPrefillFields: ['email', 'utm_source'],
  }}
/>

With the snippet above, visiting /f/contact?email=alice@x.com&secret=foo fills the email field and silently ignores secret. The prefillParams prop (used by trusted hosts such as the console) bypasses this whitelist.

Open-redirect guard

thankYouPage.redirectUrl is validated against the current origin plus allowedRedirectHosts before navigation. Wildcards like *.example.com match subdomains; the apex itself must be listed explicitly. Dangerous schemes (javascript:, data:) are always rejected.

consent: { required: true, label: 'I agree to the privacy policy.' }
privacyPolicyUrl: '/legal/privacy'

When required: true, submitting before the box is ticked shows texts.consentRequired and the network call is suppressed.

Honeypot

The hidden input is rendered off-screen with tabIndex={-1} and autocomplete="off". Bots that blindly fill every field trigger a silent fake-success — the visitor sees the thank-you screen, but dataSource.create() is never called. Honeypot data is also stripped from the payload defensively.

Min-fill-time

Genuine users take more than ~1.5 s to read and fill a form. Faster submissions are soft-rejected with texts.rateLimited rather than a hard error, so legit speed-typers can simply retry.

CAPTCHA hook

EmbeddableForm doesn't bundle a specific provider. Mount your preferred widget (hCaptcha, Turnstile, reCAPTCHA) and feed the token in:

const [token, setToken] = useState<string>();
<EmbeddableForm config={{ /* … */ captchaToken: token }} dataSource={ds} />

The token is forwarded as payload._captcha for server-side validation.

i18n

EmbeddableForm is i18n-agnostic — every user-visible string is overridable via config.texts: EmbeddableFormTexts. The console wires this up through @object-ui/i18n:

const { t } = useObjectTranslation();
<EmbeddableForm config={{
  /* … */
  texts: {
    submit:           t('publicForm.submit'),
    submitting:       t('publicForm.submitting'),
    consentRequired:  t('publicForm.consentRequired'),
    rateLimited:      t('publicForm.rateLimited'),
    redirectBlocked:  t('publicForm.redirectBlocked'),
    requiredHint:     t('publicForm.requiredHint'),
    /* … */
  },
}} />

Console route — /f/:slug and /forms/:name

The console's FormPage component renders both modes from the same spec-merging code path:

  • /f/:slug (public, anonymous) — loads GET /api/v1/forms/:slug which resolves the FormView whose sharing.publicLink matches the slug, then submits to POST /api/v1/forms/:slug/submit.
  • /forms/:name (internal, authed) — loads GET /api/v1/meta/view/:name for the FormView spec plus GET /api/v1/meta/object/:object for field metadata, then submits to POST /api/v1/data/:object with the authenticated session cookie.

URL parameters of the form ?prefill_<field>=<value> populate the matching fields on mount; the rest of the chrome (label, section columns, post-submit behaviour) comes from the FormView spec itself.

For richer public forms with anti-spam, GDPR consent, prefill whitelisting and open-redirect protection, host EmbeddableForm directly inside your own route — see the Quick start above.

Testing

Pure helpers (isRedirectUrlSafe, applyDefaultMaxLengths) are exported from @object-ui/plugin-form for unit testing. See packages/plugin-form/src/__tests__/EmbeddableForm.test.tsx for the reference test suite covering all gates.

On this page