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
type | Render as | Value 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.options | option value |
radio | radio group from field.options | option value |
multiselect | checkbox group from field.options | string[] of option values |
checkbox | single checkbox | boolean |
hidden | <input type="hidden"> with field.defaultValue | string |
file | <input type="file">, upload each file with forms.uploadFile | string[] 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.dateRuleondate/datetime:"future"(today or later) or"past"(today or earlier). CodesdateNotFuture/dateNotPast.min/maxonmultiselect: fewest and most choices (codesmin/max).accept,maxFiles,maxSizeMbonfile: 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:
| Code | Status | Meaning |
|---|---|---|
be.forms.validationFailed | 400 | One or more fields failed; per-field codes in params.errors |
be.forms.consentRequired | 400 | settings.requireConsent is on and consent was not true |
be.forms.tooLarge | 400 | The data object exceeds the size limit |
be.forms.formNotFound | 404 | Unknown 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.
Consent
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.