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.
- Build the form under Forms in the admin: fields, conditions, steps, translations and settings.
- Place it on a page: a block with a
formfield. The Delivery API delivers the form with the page, in the page's language. - Render it with
<BlokForm>(React, Vue, Svelte),createFormController(any framework) or a plain HTML form. - Receive submissions in the inbox, by email, in Slack and as a
form.submittedwebhook.
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):
| Template | What's in it |
|---|---|
| Contact form | Name, email, phone, message and a consent. |
| Newsletter signup | An email address, a consent and the campaign (utm_source) as a hidden value. |
| Quote request | Private person or business; company details only for businesses; budget; utm_campaign. |
| Event registration | Name, email, number of people and sessions. Add a capacity and one registration per email in the settings. |
| Feedback survey | A 1–5 rating with a follow-up question for low scores. |
| Job application | Two steps with a CV upload (needs Turnstile). |
| Blank | Start 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).
| Type | For | Settings |
|---|---|---|
text, textarea | Short and long answers. | minLength, maxLength (at most 500 and 5,000). |
email, tel, url | Checked to be an address, phone number or http(s) link. Notification emails reply to the first email field. | |
number | Numbers. | min, max, step. |
date, time | YYYY-MM-DD and HH:MM. | minDate, maxDate: a date or today. |
select, radio | One of the options (a dropdown, or all visible). | options, or list: a shared option list of the space. |
checkboxes | Any number of options; the value is a list. | options or list; min, max ticks. |
checkbox | Yes or no. | |
consent | Always required. The wording the visitor agreed to is kept with the submission. | link (e.g. the privacy policy). |
hidden | Values the site fills in, e.g. a campaign. | defaultValue, fromQuery (a query parameter of the page, e.g. utm_source). |
file | Uploads, kept privately. Needs Turnstile. | accept (application/pdf, image/*), maxSize (up to 10 MB), max files (up to 10). |
heading, paragraph | Text between fields; never submitted. | The label is the text. |
section | Starts 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
| Setting | API name | What it does |
|---|---|---|
| Accept submissions | active | Switched off, the form is delivered with status closed so sites can say so. |
| Opens, closes | opensAt, closesAt | Before opening the status is scheduled; after closing, closed. |
| Capacity | maxSubmissions | The form is full after this many submissions (spam doesn't count). |
| One per email | uniqueField | An email field: a second submission with the same address gets already_submitted. |
| Message after sending, when closed | successMessage, closedMessage | Shown by <BlokForm>, per locale. |
| Spam trap | honeypot | Name of a hidden input (default website). Submissions that fill it in get a normal answer but are dropped. |
| Turnstile | turnstileSecret, turnstileSiteKey | Every submission needs a valid Cloudflare Turnstile token. The secret never leaves BlokCms; the site key is delivered so <BlokForm> can add the widget. |
| Retention | retentionDays | Submissions (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 spam trap, on every form.
- 10 requests a minute per visitor (uploads included) and 30 submissions a minute per form.
- Turnstile, optional. Forms that send a confirmation email or take files must use it: otherwise anyone could make your site email strangers, or store files.
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
- Email: up to 10 addresses get each submission. Replying answers the visitor (the first email field). Addresses that bounce are flagged on the form.
- Routing rules: more recipients per answer, with the same conditions as fields, e.g. department equals sales →
sales@example.com. At most 20 recipients per submission. - Slack: posts new submissions to a channel through an incoming webhook (
https://hooks.slack.com/services/…). - Confirmation email: a plain-text reply to the visitor, in the language they used the site in. It never repeats their answers. Needs Turnstile and an email field.
- Webhook:
form.submittedwith the form key and the values, e.g. to add newsletter signups to your mailing tool.
"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 | |
|---|---|
form | The value of a form field ({ form, definition }) or a delivered form. Renders nothing for null. |
client | Defaults to the client of <BlokProvider>. |
messages | Error messages per code (see below). |
texts | Button 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. |
onSuccess | Called with the response after sending. |
page, query, initialValues, className | The 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:
| Attribute | On |
|---|---|
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-required | The <label> (or <legend>) and its *. |
data-blok-help, data-blok-error | Help text and error below the input (tied to it with aria-describedby). |
data-blok-option, data-blok-link | Each radio or checkbox label; the consent link. |
data-blok-heading, data-blok-paragraph, data-blok-section | Layout items (<h3>, <p>, <h2>). |
data-blok-files, data-blok-remove, data-blok-uploading | Uploaded files, their remove buttons, and the uploading note. |
data-blok-progress, data-blok-turnstile, data-blok-form-error | Step 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-submit | The 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}" }}
/>
| Code | Parameters |
|---|---|
required, must_be_checked | |
invalid_email, invalid_tel, invalid_url, invalid_number, invalid_date, invalid_time, invalid_option, invalid_type | |
too_short, too_few | min |
too_long, too_many | max |
too_small, too_large | min or max (a number, or a date for date fields) |
invalid_step | step |
file_type, file_too_large, invalid_file | accept; 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>
_redirect: after a successful post, the browser is sent there (303) instead of getting the JSON response._redirect_error: where failed posts go, with?error=<code>(e.g.validation_failed,form_full). Without it, a form with_redirectgoes back to the page it came from, with the same parameter.- Redirects only go to the token's own origins, so they can't be abused as open redirects. Tokens without origins never redirect.
- With Turnstile, add the widget inside the form: it adds the
cf-turnstile-responsefield. - File fields need JavaScript: files go through the upload endpoint first.
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-response | The Turnstile token, when the form uses Turnstile. |
_page | The page the form was sent from (default: the Referer). |
_locale | The visitor's language: picks the success message, the consent wording that's kept and the confirmation email's language. |
_redirect, _redirect_error | For 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.
| Status | Code | When |
|---|---|---|
| 400 | invalid_body | Not valid JSON or form data, or not an object. |
| 404 | not_found | No form with this key. |
| 409 | form_not_open | Before opensAt. |
| 409 | form_closed | Switched off, or after closesAt. |
| 409 | form_full | The capacity is reached. |
| 409 | already_submitted | This address was already used (one per email). |
| 413 | too_large | A submission over 64 KB, or a file over 10 MB. |
| 415 | unsupported_media_type | Not JSON or form data (uploads: not multipart). |
| 422 | validation_failed | Field problems, in details.fields. |
| 422 | submission_too_large | The values add up to more than 32 KB. |
| 422 | turnstile_failed | The Turnstile token is missing, used or invalid. Get a new one and try again. |
| 422 | invalid_field, turnstile_required | Uploads: not a file field of this form; the form doesn't use Turnstile. |
| 429 | rate_limited | Too 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.
- Retention: each form deletes submissions, with their notes and files, after its retention period (365 days unless you change it). Spam goes after 30 days.
- Erasure: for a request to delete someone's data, search by email address. BlokCms looks through every email field of every form, shows what it found, and deletes the submissions, notes and files. The activity log records how many, never the address. In the admin, or with
POST /v1/spaces/<spaceId>/forms/erase({ "email": "…", "dryRun": true }to only count). - Consent records: a
consentfield storestrue, and the submission keeps the exact wording the visitor agreed to (with the link), in their language. The inbox shows it, and so does the JSON export. - What's stored: the values of the visible fields, the page, the language and the browser's user agent. The visitor's IP address is only used to check the Turnstile token and is never stored.
- Files: private storage only, downloaded from the inbox as attachments, deleted with their submission.