Forms

Editors build forms in the admin and place them on pages with a block. Your site renders them with one component, visitors post straight to the Delivery API, and submissions land in the admin's inbox. No form backend of your own.

  1. Build the form under Forms in the admin: fields, conditions, steps, translations and settings.
  2. Place it on a page: a block with a form field. The Delivery API delivers the form with the page, in the page's language.
  3. Render it with <BlokForm> (React, Vue, Svelte), createFormController (any framework) or a plain HTML form.
  4. Receive submissions in the inbox, by email, in Slack and as a form.submitted webhook.

On this page: Build a form · Place it on a page · Render with <BlokForm> · Without a framework · Plain HTML · Endpoints · Error codes · Privacy

Build a form

Templates

New forms start from a template (or ask AI to draft the fields from a description, in spaces with AI switched on):

TemplateWhat's in it
Contact formName, email, phone, message and a consent.
Newsletter signupAn email address, a consent and the campaign (utm_source) as a hidden value.
Quote requestPrivate person or business; company details only for businesses; budget; utm_campaign.
Event registrationName, email, number of people and sessions. Add a capacity and one registration per email in the settings.
Feedback surveyA 1–5 rating with a follow-up question for low scores.
Job applicationTwo steps with a CV upload (needs Turnstile).
BlankStart from scratch.

The starter content for a new space (the starter template) includes a Contact form, shown on the home page with a form block.

Field types

Every field has a key (the name its value is posted under, e.g. email or firstName), a label, optional help and placeholder, and a width hint (half puts two fields side by side).

TypeForSettings
text, textareaShort and long answers.minLength, maxLength (at most 500 and 5,000).
email, tel, urlChecked to be an address, phone number or http(s) link. Notification emails reply to the first email field.
numberNumbers.min, max, step.
date, timeYYYY-MM-DD and HH:MM.minDate, maxDate: a date or today.
select, radioOne of the options (a dropdown, or all visible).options, or list: a shared option list of the space.
checkboxesAny number of options; the value is a list.options or list; min, max ticks.
checkboxYes or no.
consentAlways required. The wording the visitor agreed to is kept with the submission.link (e.g. the privacy policy).
hiddenValues the site fills in, e.g. a campaign.defaultValue, fromQuery (a query parameter of the page, e.g. utm_source).
fileUploads, kept privately. Needs Turnstile.accept (application/pdf, image/*), maxSize (up to 10 MB), max files (up to 10).
heading, paragraphText between fields; never submitted.The label is the text.
sectionStarts a new step (see below).

Keys can't be cf-turnstile-response, _page, _locale, _redirect, _redirect_error or the spam trap's name: those steer a submission.

Conditions

A field with Show if appears only when its rules match, all of them or any. Rules look at fields above it, so there are no loops. A hidden field isn't required, and the server drops whatever value is sent for it.

{
  "key": "company",
  "label": "Company",
  "type": "text",
  "required": true,
  "showIf": { "match": "all", "rules": [{ "field": "customerType", "op": "equals", "value": "business" }] }
}

Operators: equals, notEquals (value), in, notIn (values), filled, empty, greaterThan, lessThan (numbers). For checkboxes, equals matches when that option is one of the ticked ones.

Steps

Each section starts a step: <BlokForm> shows one step at a time with Next and Back, checks a step before moving on, and only sends from the last one. Renderers that don't know about steps show a section as a heading.

Translations

Labels, help texts, placeholders, option labels and the consent link have a translation per locale. So do the success message, the closed message and the confirmation email. Anything left out falls back along the space's locale chain, like content does. GET /forms/<key>?locale=nl and forms on pages return the strings in that language.

{
  "key": "message",
  "label": "Message",
  "type": "textarea",
  "translations": {
    "nl": { "label": "Bericht", "placeholder": "Waarmee kunnen we helpen?" },
    "de": { "label": "Nachricht" }
  }
}

Validation errors aren't translated by BlokCms: they come with a code, and your site turns codes into text once, for every form (see Error messages).

Settings

SettingAPI nameWhat it does
Accept submissionsactiveSwitched off, the form is delivered with status closed so sites can say so.
Opens, closesopensAt, closesAtBefore opening the status is scheduled; after closing, closed.
CapacitymaxSubmissionsThe form is full after this many submissions (spam doesn't count).
One per emailuniqueFieldAn email field: a second submission with the same address gets already_submitted.
Message after sending, when closedsuccessMessage, closedMessageShown by <BlokForm>, per locale.
Spam traphoneypotName of a hidden input (default website). Submissions that fill it in get a normal answer but are dropped.
TurnstileturnstileSecret, turnstileSiteKeyEvery submission needs a valid Cloudflare Turnstile token. The secret never leaves BlokCms; the site key is delivered so <BlokForm> can add the widget.
RetentionretentionDaysSubmissions (and their files) are deleted after this many days. Default 365.

Responses are cached, so a delivered form's status is the one at the time of the response. formStatus(form) in @blokcms/client checks opensAt and closesAt against the clock, and <BlokForm> uses it.

Spam protection

The inbox has a Spam folder too: spam doesn't count as unread or toward the capacity, is left out of exports and is deleted after 30 days.

File uploads

A file field uploads each file as soon as it's picked (POST /forms/<key>/uploads), then the submission carries the upload ids. The file type is checked against accept and against the file's actual content. Files are stored privately: they're never served from a public URL, and the inbox downloads them as attachments. Uploads that no submission uses are deleted after a day, and files are deleted with their submission.

Notifications

"notifyRules": [
  {
    "when": { "match": "all", "rules": [{ "field": "department", "op": "equals", "value": "sales" }] },
    "to": ["sales@example.com"]
  }
]

Everything in the admin is in the Management API too: /v1/spaces/<spaceId>/forms to create and update forms, and the inbox (statuses, search, notes, assignment, bulk actions, CSV and JSON export, file downloads).

Place it on a page

Give a block type a form field. Editors pick one of the space's forms, and the entry stores its key, which never changes:

// blokcms.schema.ts
{
  key: "form",
  kind: "block",
  name: "Form",
  titleField: "title",
  fields: [
    { key: "title", label: "Title", type: "text", translatable: true },
    { key: "form", label: "Form", type: "form", required: true },
  ],
}

Allow the block in your page's blocks field (types: ["hero", "text", "form"]). The Delivery API adds the form's definition, localized like the rest of the page, so a block component can render it without another request. It's null when the form was deleted. The visual editor shows the form while editing.

{
  "id": "blk_01M4R43N17QXAFX2H1T2B0K9NC",
  "type": "form",
  "fields": {
    "title": "Get in touch",
    "form": {
      "form": "contact",
      "definition": { "key": "contact", "name": "Contact", "locale": "en", "fields": [ … ], "status": "open", … }
    }
  }
}

Any form is also available by key: GET /forms/<key>, or blok.getForm("contact", { locale }).

Render with <BlokForm>

<BlokForm> renders a form as plain, accessible HTML with no styles of its own. It validates with the same rules as the server (as visitors leave a field, and on sending), shows and hides fields by their conditions, walks through steps, fills hidden fields from the page's query string, adds the spam trap and the Turnstile widget, uploads files and shows the success or closed message.

Submissions go from the browser straight to the Delivery API, so the client needs a public token. Send it in the query string (tokenInQuery) to avoid CORS preflights, and restrict the token to your site's origins (Settings → Delivery tokens).

import { createClient } from "@blokcms/client";
import { BlokForm, BlokProvider, editable } from "@blokcms/react";

const blok = createClient({ space: "spc_…", token: "blok_pub_…", tokenInQuery: true });

function FormBlock({ block, fields }) {
  return (
    <section {...editable(block)}>
      <h2>{fields.title}</h2>
      <BlokForm form={fields.form} />
    </section>
  );
}

<BlokProvider components={{ form: FormBlock }} client={blok}>
  <Blocks blocks={entry.content.fields.body} />
</BlokProvider>

Or by key, with the client as a prop:

const { form } = await blok.getForm("newsletter", { locale: "nl" });

<BlokForm form={form} client={blok} onSuccess={() => track("signup")} />

Content fetched on the server can keep using a server-side client (or a preview token); only the client that sends forms runs in the browser. examples/tanstack-start-site does exactly that.

Prop
formThe value of a form field ({ form, definition }) or a delivered form. Renders nothing for null.
clientDefaults to the client of <BlokProvider>.
messagesError messages per code (see below).
textsButton and status texts: submit, submitting, next, back, step ("Step {step} of {steps}"), required, uploading, removeFile, success, closed, scheduled, full. English by default (DEFAULT_FORM_TEXTS).
components{ Field, Turnstile } to replace parts (see below).
renderSuccess(message) => ReactNode instead of the success message.
onSuccessCalled with the response after sending.
page, query, initialValues, classNameThe page sent with the submission (default: the browser's Referer), the query string for fromQuery fields (default: the page's own), starting values and the form's class.

<BlokForm> is also available in @blokcms/vue and @blokcms/svelte with the same props.

Styling

Style the form with your own CSS against its data-blok-* attributes:

AttributeOn
data-blok-form="<key>"The <form>, or the box that replaces it after sending (data-blok-success) or when it's not open (data-blok-closed="closed|scheduled|full"). aria-busy while sending.
data-blok-field="<key>"Each field's wrapper (a <fieldset> for radio buttons and checkboxes), with data-type, data-width (full, half), data-required and data-invalid.
data-blok-label, data-blok-requiredThe <label> (or <legend>) and its *.
data-blok-help, data-blok-errorHelp text and error below the input (tied to it with aria-describedby).
data-blok-option, data-blok-linkEach radio or checkbox label; the consent link.
data-blok-heading, data-blok-paragraph, data-blok-sectionLayout items (<h3>, <p>, <h2>).
data-blok-files, data-blok-remove, data-blok-uploadingUploaded files, their remove buttons, and the uploading note.
data-blok-progress, data-blok-turnstile, data-blok-form-errorStep 1 of 3, the Turnstile widget, and problems with the form as a whole (closed, rate limited, no connection).
data-blok-actions, data-blok-back, data-blok-submitThe buttons.
form[data-blok-form] { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
form[data-blok-form] > * { grid-column: 1 / -1; }
[data-blok-field] { display: grid; gap: 0.25rem; border: 0; padding: 0; }
[data-blok-field][data-width="half"] { grid-column: span 1; }
[data-blok-label] { font-weight: 600; }
[data-blok-field][data-invalid] :is(input, select, textarea) { border-color: crimson; }
[data-blok-error], [data-blok-form-error], [data-blok-required] { color: crimson; }
[data-blok-help] { color: gray; font-size: 0.875rem; }
[data-blok-submit] { padding: 0.5rem 1.25rem; background: #4f46e5; color: white; border: 0; }

Error messages

Field errors have a code and parameters. Without messages, <BlokForm> shows the label plus the API's English message (Email must be an email address). Pass a template per code to show your own, in your site's language; {label} and the parameters are filled in:

const nl = {
  required: "{label} is verplicht",
  must_be_checked: "Vink dit aan om verder te gaan",
  invalid_email: "Vul een geldig e-mailadres in",
  too_long: "{label} mag maximaal {max} tekens bevatten",
  too_few: "Kies er minstens {min}",
  rate_limited: "Even geduld, probeer het zo opnieuw",
};

<BlokForm
  form={fields.form}
  messages={nl}
  texts={{ submit: "Versturen", submitting: "Bezig…", next: "Volgende", back: "Vorige", step: "Stap {step} van {steps}" }}
/>
CodeParameters
required, must_be_checked
invalid_email, invalid_tel, invalid_url, invalid_number, invalid_date, invalid_time, invalid_option, invalid_type
too_short, too_fewmin
too_long, too_manymax
too_small, too_largemin or max (a number, or a date for date fields)
invalid_stepstep
file_type, file_too_large, invalid_fileaccept; max (bytes)

The same map covers problems with the form as a whole: form_closed, form_not_open, form_full, already_submitted, turnstile_required, turnstile_failed, uploading, rate_limited and network_error. formErrorMessage(error, field, messages) in @blokcms/client does the same for your own markup.

Your own field components

components.Field renders every field and layout item. Handle the types you want and pass the rest to DefaultField. It gets the field, its value and error, and the form's controller:

import { BlokForm, DefaultField, type FieldProps } from "@blokcms/react";

function Field(props: FieldProps) {
  const { field, value, errorText, controller } = props;
  if (field.type !== "select") return <DefaultField {...props} />;
  return (
    <MySelect
      label={field.label}
      options={field.options ?? []}
      value={typeof value === "string" ? value : ""}
      error={errorText}
      onChange={(next) => controller.setValue(field.key, next)}
      onBlur={() => controller.touch(field.key)}
    />
  );
}

<BlokForm form={fields.form} components={{ Field }} />

components.Turnstile replaces the widget (it gets siteKey, onToken and resets, which changes when a new token is needed: tokens are single-use). For markup that's entirely your own, useBlokForm(form, { client }) returns { controller, state }, the same state <BlokForm> renders.

Without a framework: createFormController

The components are thin wrappers around createFormController in @blokcms/client: values, visible fields and steps, validation, uploads, Turnstile and sending, without a framework. Subscribe to it and render however you like:

import { createClient, createFormController, formErrorMessage } from "@blokcms/client";

const blok = createClient({ space: "spc_…", token: "blok_pub_…", tokenInQuery: true });
const { form } = await blok.getForm("newsletter", { locale: document.documentElement.lang });
// Hidden fields with fromQuery (utm_source) are filled from the page's URL.
const controller = createFormController(blok, form);

const el = document.querySelector("#newsletter");
const email = el.querySelector("[name=email]");
const consent = el.querySelector("[name=consent]");
email.addEventListener("input", () => controller.setValue("email", email.value));
email.addEventListener("blur", () => controller.touch("email"));
consent.addEventListener("change", () => controller.setValue("consent", consent.checked));
el.addEventListener("submit", (event) => {
  event.preventDefault();
  controller.submit();
});

controller.subscribe(() => {
  const state = controller.getState();
  if (state.status === "success") return el.replaceWith(state.successMessage ?? "Thank you!");
  const error = state.errors.email;
  const field = form.fields.find((f) => f.key === "email");
  el.querySelector(".error").textContent = error ? formErrorMessage(error, field) : (state.error?.message ?? "");
  el.querySelector("button").disabled = state.status === "submitting";
});
Controller
getState(), subscribe(listener)The state: values, errors (for touched fields, all after a try), steps, step, fields (the current step's visible fields), status (idle, submitting, success, error), error, successMessage, formStatus, uploading, turnstileResets.
setValue(key, value), touch(key)Change a value; show a field's problem from now on (call it on blur).
addFiles(key, files), removeFile(key, id)Check and upload files for a file field.
setTurnstileToken(token)From the widget's callback; clear it on expiry.
next(), back()Move between steps (next checks the current one first).
submit(), reset()Send (resolves to true on success); start over.

Helpers for your own renderer: formFieldIds(form, field) (ids for for and aria-describedby), formStatus(form), isRequired(field), and validateSubmission, visibleFields and formSteps from @blokcms/core.

Plain HTML forms

A form also works without JavaScript: post it to the Delivery API as regular form data. Repeated names (several checkboxes named topics, or topics[]) become a list.

<form method="post" action="https://cdn.blokcms.app/v1/spc_…/forms/contact/submissions?token=blok_pub_…">
  <label>First name <input name="firstName" required></label>
  <label>Email <input name="email" type="email" required></label>
  <label>Message <textarea name="message" required></textarea></label>
  <label><input type="checkbox" name="privacy" required> I agree that my details are used to handle this request.</label>

  <!-- The spam trap: hidden from people, left empty. -->
  <div aria-hidden="true" style="position: absolute; left: -10000px">
    <input name="website" tabindex="-1" autocomplete="off">
  </div>

  <input type="hidden" name="_locale" value="en">
  <input type="hidden" name="_redirect" value="https://www.example.com/thanks">
  <input type="hidden" name="_redirect_error" value="https://www.example.com/contact">
  <button>Send</button>
</form>

Delivery endpoints

Base URL https://cdn.blokcms.app/v1/<spaceId>, with a public token (?token= or Authorization: Bearer). See the Delivery API for tokens and caching.

GET /forms/<key>?locale=nl

A form, ready to render, in a locale. Cached per publish like everything else (saving a form counts as one). Forms that don't take submissions are still delivered, with their status and closedMessage; unknown keys are 404.

{
  "form": {
    "key": "contact",
    "name": "Contact",
    "locale": "en",
    "fields": [
      { "key": "firstName", "label": "First name", "type": "text", "required": true, "width": "half" },
      { "key": "lastName", "label": "Last name", "type": "text", "width": "half" },
      { "key": "email", "label": "Email", "type": "email", "required": true, "width": "half" },
      { "key": "phone", "label": "Phone", "type": "tel", "width": "half" },
      { "key": "message", "label": "Message", "type": "textarea", "required": true },
      {
        "key": "privacy",
        "label": "I agree that my details are used to handle this request.",
        "type": "consent",
        "link": { "label": "Privacy policy", "url": "https://www.example.com/privacy" }
      }
    ],
    "honeypot": "website",
    "turnstile": true,
    "turnstileSiteKey": "0x4AAAAAAA…",
    "successMessage": "Thank you! We'll get back to you soon.",
    "status": "open",
    "closedMessage": null,
    "opensAt": null,
    "closesAt": null
  },
  "cv": 1791810982962
}

status is scheduled, open, closed or full. Shared option lists are resolved into options, and translations are left out.

POST /forms/<key>/submissions

JSON, or form data (application/x-www-form-urlencoded, multipart/form-data), up to 64 KB. Never cached. Besides the form's fields:

cf-turnstile-responseThe Turnstile token, when the form uses Turnstile.
_pageThe page the form was sent from (default: the Referer).
_localeThe visitor's language: picks the success message, the consent wording that's kept and the confirmation email's language.
_redirect, _redirect_errorFor plain HTML posts (above).
The spam trap (honeypot)Leave it empty.
curl "https://cdn.blokcms.app/v1/spc_…/forms/contact/submissions?token=blok_pub_…" \
  -H "Content-Type: application/json" \
  -d '{ "firstName": "Jan", "email": "jan@example.com", "message": "Hello", "privacy": true, "_locale": "en" }'
{ "ok": true, "successMessage": "Thank you! We'll get back to you soon." }

Values that don't fit the fields return 422 with a code per field. Fields hidden by their conditions are skipped, and keys that aren't fields are dropped.

{
  "error": {
    "code": "validation_failed",
    "message": "Some fields need attention",
    "details": {
      "fields": {
        "email": { "code": "invalid_email", "message": "must be an email address" },
        "message": { "code": "too_long", "message": "must be at most 5000 characters", "params": { "max": 5000 } }
      }
    }
  }
}

blok.submitForm(key, values, { turnstileToken, page, locale }) sends a submission and throws a BlokError with the code and details.

POST /forms/<key>/uploads

One file for a file field, as multipart/form-data with field (the field's key) and file. Only forms with Turnstile take files.

curl "https://cdn.blokcms.app/v1/spc_…/forms/application/uploads?token=blok_pub_…" \
  -F field=cv -F "file=@cv.pdf;type=application/pdf"
{ "id": "fup_01M4R5B2J3X8ZK0V4TQ6N1W9EA", "name": "cv.pdf", "size": 182345, "type": "application/pdf" }

Submit the id as the field's value: "cv": "fup_…", or a list for fields that take several files. Uploads are rejected with 422 validation_failed (file_type, file_too_large, invalid_file) on the field, like a submission. blok.uploadFormFile(key, field, file) does this for you.

Error codes

Errors look like the rest of the Delivery API's: { "error": { "code", "message", "details"? } }. For forms that aren't open, message is the form's closed message when it has one.

StatusCodeWhen
400invalid_bodyNot valid JSON or form data, or not an object.
404not_foundNo form with this key.
409form_not_openBefore opensAt.
409form_closedSwitched off, or after closesAt.
409form_fullThe capacity is reached.
409already_submittedThis address was already used (one per email).
413too_largeA submission over 64 KB, or a file over 10 MB.
415unsupported_media_typeNot JSON or form data (uploads: not multipart).
422validation_failedField problems, in details.fields.
422submission_too_largeThe values add up to more than 32 KB.
422turnstile_failedThe Turnstile token is missing, used or invalid. Get a new one and try again.
422invalid_field, turnstile_requiredUploads: not a file field of this form; the form doesn't use Turnstile.
429rate_limitedToo many requests from this visitor, or for this form. Try again in a minute.

Privacy

Submissions are personal data. BlokCms keeps them in the EU with the rest of the space, and only roles with forms.read see them.