Behio Storefront SDK
Catalog & Products

Forms

Merchant-defined forms (contact, demand, event signup) rendered from a public definition and validated on submit

A site (a website or an e-shop) can have any number of forms the merchant builds in the Behio admin: contact form, demand form, event signup, feedback. The storefront never hard-codes the fields; it fetches the definition by slug, renders the fields by type and posts the answers back. The API validates the submit against the definition, notifies the merchant and stores the response.

Form endpoints work with both e-shop keys and website keys, so a company website without a shop can use them too.

Definition

const { data: form, error } = await client.forms.get('kontakt');

if (error || !form) {
  // 404 for an unknown or inactive slug
}

// form.name, form.description
// form.fields: SiteFormField[]  (render by field.type, see below)
// form.settings: { submitLabel?, successMessage?, redirectUrl?, consentText?, requireConsent? }

client.forms.get(slug: string): Promise<SdkResult<StorefrontForm>>

The definition is public and cacheable, so fetch it server-side (a Server Component or getStaticProps) and hand it to the client part that owns the input state.

Field types

typeRender asValue in data
text, email, phone<input> (type="email" / type="tel")string
textarea<textarea>string
number<input type="number">number
date<input type="date">YYYY-MM-DD string
time<input type="time">HH:MM string
datetime<input type="datetime-local">YYYY-MM-DDTHH:MM string (no zone)
url<input type="url" inputMode="url">http(s) address string
select<select> from field.optionsoption value
radioradio group from field.optionsoption value
multiselectcheckbox group from field.optionsstring[] of option values
checkboxsingle checkboxboolean
hidden<input type="hidden"> with field.defaultValuestring
file<input type="file">, upload each file with forms.uploadFilestring[] of file ids (up to field.maxFiles)

required, min / max (number value, or text length), pattern (regular expression without slashes) and options are the merchant's rules. Mirror them in the browser as a courtesy (required, minLength, pattern attributes), but the server is the authority: it re-validates every submit and answers with per-field codes.

More rules the merchant can set:

  • errorMessage: the merchant's own wording for this field. Show it instead of your generic message when the field fails.
  • dateRule on date / datetime: "future" (today or later) or "past" (today or earlier). Codes dateNotFuture / dateNotPast.
  • min / max on multiselect: fewest and most choices (codes min / max).
  • accept, maxFiles, maxSizeMb on file: allowed kinds (images, documents = PDF and Office, any), most files (1 to 5) and the largest single file (1 to 500 MB).

width: "half" is a layout hint: two consecutive half fields sit side by side on wide screens, full width on mobile.

Submit

const { data, error } = await client.forms.submit('kontakt', {
  data: { name: 'Jana', email: '[email protected]', message: 'Hello' },
  locale: 'cs',
  page: window.location.href,   // optional context for the merchant
  consent: true,                // required when settings.requireConsent
  website: honeypotValue,       // honeypot, see below
});

if (data?.ok) {
  // data.message = settings.successMessage (or null)
  // data.redirectUrl = settings.redirectUrl (or null): navigate there when set
}

client.forms.submit(slug: string, input: StorefrontFormSubmitInput): Promise<SdkResult<StorefrontFormSubmitResult>>

Rejections come back as an SdkError with a stable backend code in error.body.code:

CodeStatusMeaning
be.forms.validationFailed400One or more fields failed; per-field codes in params.errors
be.forms.consentRequired400settings.requireConsent is on and consent was not true
be.forms.tooLarge400The data object exceeds the size limit
be.forms.formNotFound404Unknown or inactive slug

Submits are rate limited to 10 per minute per visitor. When the storefront posts from the server (a Server Action), forward the visitor's IP with the visitorIp option so the limit applies to the visitor, not to your server (the Next.js adapter getBehio() does this for you).

Per-field errors

import { formFieldErrors, errorMessage } from '@behio/storefront-sdk';

const { error } = await client.forms.submit('kontakt', { data });
if (error) {
  const perField = formFieldErrors(error);
  // [{ key: 'email', code: 'invalid' }, { key: 'message', code: 'required' }]
  const general = perField.length ? null : errorMessage(error, 'cs');
}

formFieldErrors(error): StorefrontFormFieldError[] returns {key, code} pairs for be.forms.validationFailed and an empty array for any other error. Codes: required, invalid, tooShort, tooLong, min, max, notOption, pattern, dateNotFuture, dateNotPast, tooManyFiles. Map them to your own wording per locale.

Use SDK 2.6.1 or later to retain all these codes. Earlier parsers omitted the date bounds and attachment count errors even though the API returned them. Show the error next to its field, prefer field.errorMessage when provided, and preserve previous values so the visitor can correct the submission.

File fields

Files go straight from the browser to storage, never through the API, so large files (hundreds of MB) are fine. forms.uploadFile does all three steps: asks for a presigned PUT URL for this exact size, uploads the file, and confirms it. Then submit the returned ids:

const { data: file, error } = await behio.forms.uploadFile("demand", "attachment", input.files[0]);
// error.code: be.forms.fileTypeNotAllowed | be.forms.fileTooLarge (params.maxMb)
//             | be.forms.fileRateLimited | be.forms.fileUploadIncomplete
if (file) {
  await behio.forms.submit("demand", { data: { attachment: [file.id] } });
} else {
  // Show the upload error and allow retry. Do not submit an unconfirmed id.
}

Doing it by hand: POST /forms/{slug}/files/upload-url with {field, fileName, contentType, size} returns {id, uploadUrl, contentType}; PUT the file to uploadUrl with that Content-Type (the signature fixes the size, a different body is refused); then POST /forms/{slug}/files/{id}/confirm. The confirm checks the real size and reads the first bytes to decide the type (JPEG, PNG, GIF, WebP, HEIC, PDF, Word, Excel, PowerPoint, OpenDocument); SVG and HTML are always rejected, even renamed. Files are private: the merchant opens them from the admin through a short-lived link. A confirmed file must be submitted within 24 hours and belongs to exactly one response. Rate limit: 20 upload steps per minute per visitor.

Bot time trap

Send elapsedMs, the milliseconds between rendering the form and submitting it. Under 1.5 seconds the API answers like a success and stores nothing (same as the honeypot). Older storefronts that do not send it keep working.

Honeypot

Render a text input named website that real visitors never see (visually hidden, tabIndex={-1}, autoComplete="off", aria-hidden) and pass its value through untouched. Bots fill it; the API answers such a submit like a success but stores nothing and sends no notification, so the bot never learns what gave it away.

When settings.requireConsent is true, render a checkbox with settings.consentText and send consent: true only when it is checked. Submitting without it returns be.forms.consentRequired.

Rendering dynamic fields

A minimal client component that renders any definition:

'use client';

import { useState } from 'react';
import { useSiteFormSubmit } from '@behio/storefront-sdk/react';
import type { SiteFormField, StorefrontForm } from '@behio/storefront-sdk';

export function SiteForm({ form }: { form: StorefrontForm }) {
  const [values, setValues] = useState<Record<string, unknown>>(() =>
    Object.fromEntries(form.fields.map((f) => [f.key, f.defaultValue ?? (f.type === 'multiselect' ? [] : f.type === 'checkbox' ? false : '')])),
  );
  const [consent, setConsent] = useState(false);
  const [website, setWebsite] = useState(''); // honeypot
  const submit = useSiteFormSubmit(form.slug);

  if (submit.isSuccess) {
    if (submit.result?.redirectUrl) window.location.assign(submit.result.redirectUrl);
    return <p role="status">{submit.result?.message ?? 'Thank you.'}</p>;
  }

  const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value }));

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        submit.submit({ data: values, consent, website, page: window.location.href });
      }}
      noValidate
    >
      {form.fields.map((field) => (
        <Field key={field.key} field={field} value={values[field.key]} onChange={(v) => set(field.key, v)} error={submit.fieldErrors[field.key]} />
      ))}

      <input type="text" name="website" value={website} onChange={(e) => setWebsite(e.target.value)} tabIndex={-1} autoComplete="off" aria-hidden style={{ position: 'absolute', left: '-10000px' }} />

      {form.settings.requireConsent ? (
        <label>
          <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
          {form.settings.consentText}
        </label>
      ) : null}

      {submit.errorCode && !Object.keys(submit.fieldErrors).length ? <p role="alert">{submit.errorCode}</p> : null}

      <button type="submit" disabled={submit.isSubmitting}>
        {form.settings.submitLabel ?? 'Send'}
      </button>
    </form>
  );
}

function Field({ field, value, onChange, error }: { field: SiteFormField; value: unknown; onChange: (v: unknown) => void; error?: string }) {
  if (field.type === 'hidden') return <input type="hidden" name={field.key} value={String(value ?? '')} />;

  let control: React.ReactNode;
  switch (field.type) {
    case 'textarea':
      control = <textarea value={String(value ?? '')} onChange={(e) => onChange(e.target.value)} placeholder={field.placeholder} />;
      break;
    case 'select':
      control = (
        <select value={String(value ?? '')} onChange={(e) => onChange(e.target.value)}>
          <option value="">{field.placeholder ?? ''}</option>
          {field.options?.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
        </select>
      );
      break;
    case 'radio':
      control = field.options?.map((o) => (
        <label key={o.value}><input type="radio" name={field.key} checked={value === o.value} onChange={() => onChange(o.value)} /> {o.label}</label>
      ));
      break;
    case 'multiselect': {
      const selected = Array.isArray(value) ? (value as string[]) : [];
      control = field.options?.map((o) => (
        <label key={o.value}>
          <input type="checkbox" checked={selected.includes(o.value)} onChange={(e) => onChange(e.target.checked ? [...selected, o.value] : selected.filter((v) => v !== o.value))} /> {o.label}
        </label>
      ));
      break;
    }
    case 'checkbox':
      control = <input type="checkbox" checked={value === true} onChange={(e) => onChange(e.target.checked)} />;
      break;
    default:
      control = (
        <input
          type={
            field.type === 'email' ? 'email'
            : field.type === 'phone' ? 'tel'
            : field.type === 'number' ? 'number'
            : field.type === 'date' ? 'date'
            : field.type === 'time' ? 'time'
            : field.type === 'datetime' ? 'datetime-local'
            : field.type === 'url' ? 'url'
            : 'text'
          }
          value={String(value ?? '')}
          onChange={(e) => onChange(field.type === 'number' ? e.target.valueAsNumber : e.target.value)}
          placeholder={field.placeholder}
          min={field.type === 'number' ? field.min : undefined}
          max={field.type === 'number' ? field.max : undefined}
          minLength={field.type !== 'number' ? field.min : undefined}
          maxLength={field.type !== 'number' ? field.max : undefined}
          pattern={field.pattern}
        />
      );
  }

  return (
    <div>
      <label>{field.label}{field.required ? ' *' : ''}</label>
      {control}
      {field.help ? <small>{field.help}</small> : null}
      {error ? <small role="alert">{error}</small> : null}
    </div>
  );
}

React hooks

import { useSiteForm, useSiteFormSubmit } from '@behio/storefront-sdk/react';

const { data: form } = useSiteForm('kontakt');
const { submit, isSubmitting, isSuccess, result, fieldErrors, errorCode, error, reset } = useSiteFormSubmit('kontakt');

useSiteForm(slug, {enabled?}) is a query of the definition. useSiteFormSubmit(slug) wraps the mutation: submit(input) resolves to the result or null on rejection, fieldErrors is Record<fieldKey, code>, errorCode is the backend key for rejections that are not per field (for example be.forms.consentRequired), and error is the raw SdkError for errorMessage(error, locale).

Embedding in CMS content

Merchants can drop a form into a CMS page or a blog post. Two markers are supported: <div data-behio-form="kontakt"></div> and the shortcode [form kontakt]. Split the page HTML on the marker, render the surrounding chunks through your sanitising RichText component and mount the form component in between; do not try to inject React into the sanitised HTML string.

On this page