Build a complete contact form
Build a form that validates server-side, saves each submission into a collection, redirects to a thank-you page, and, when you need them, accepts a file, a captcha and an email notification.
A Quilium form is a content block with two special fields: one that validates the POST, one that stores it. The template is plain HTML.
contactForm block validates a demo request, saves it to the demoSubmissions collection, accepts an optional attachment and redirects to /merci.1. Create the target collection first
The save field points at a collection by key. If that collection does not exist yet, the write fails.
demoSubmissions:
name: Demo requests
objectName: Request
icon: inbox
tabsets:
main:
name: Request
fields:
name:
type: text
label: Name
searchable: true
email:
type: text
label: Email
searchable: true
company:
type: text
label: Company
message:
type: textarea
label: Message
displays:
- type: datatable
config:
cols: [name, email, company]
One field per input you intend to keep. Submissions are ordinary collection entries: editors read them in the backoffice, and retention (an ISO-8601 duration such as P1Y) expires them on schedule, which is usually what you want for personal data.
save does not honour default: now, so the field would land empty. Every entry already carries a system created_at; put that in displays.cols instead.2. Declare the form block
validate holds the rules, save holds the destination. Both are field types, declared like any other field on the content-type.
contactForm:
name: Contact form
icon: inbox
template: contactForm.liquid
tabsets:
main:
name: Content
fields:
title:
type: text
label: Title
intro:
type: textarea
label: Introduction
validation:
type: validate
label: Validation
config:
rules:
name:
- required
email:
- required
- email
message:
- required
saveToCollection:
type: save
label: Save
config:
key: demoSubmissions
Rule keys are the input names in your markup, not the collection's field names, though keeping the two identical saves you a mapping. Validation runs on the server, so it holds regardless of what the browser does.
3. Write the markup
Four things make it a Quilium form rather than inert HTML: the POST to the same URL, the prefill from request.body, the per-field errors from content.form, and the submit button that names its target block.
{% assign c = content.custom %}
{% assign f = content.form %}
{% assign b = request.body %}
<form method="POST" action="" novalidate>
{% if c.title != blank %}<h2>{{ c.title }}</h2>{% endif %}
<label for="name">{{ 'Name' | _ }}</label>
<input type="text" id="name" name="name" required autocomplete="name"
value="{{ b.name }}"
{% if f.name.errors %}aria-invalid="true" aria-describedby="err-name"{% endif %} />
{% if f.name.errors %}
<span id="err-name" role="alert">{% include '_formError', errors: f.name.errors %}</span>
{% endif %}
<label for="email">{{ 'Work email' | _ }}</label>
<input type="email" id="email" name="email" required autocomplete="email"
value="{{ b.email }}"
{% if f.email.errors %}aria-invalid="true" aria-describedby="err-email"{% endif %} />
{% if f.email.errors %}
<span id="err-email" role="alert">{% include '_formError', errors: f.email.errors %}</span>
{% endif %}
<label for="message">{{ 'Message' | _ }}</label>
<textarea id="message" name="message" rows="5">{{ b.message }}</textarea>
<button type="submit" name="form" value="{{ content.id }}">{{ 'Request a demo' | _ }}</button>
</form>
The submit button carries name="form" and value="{{ content.id }}". That pair tells Quilium which block on the page the POST belongs to, and it is what lets a page hold two forms without them interfering.
Prefilling from request.body is not cosmetic. Without it, a validation error empties the form and the visitor retypes everything.
4. Turn rule names into readable messages
content.form.<field>.errors is an object keyed by the rule that failed. Map the keys you use to sentences, and keep a fallback for the rest:
{%- comment -%} _formError.liquid — arg: errors {%- endcomment -%}
{%- for pair in errors -%}
{%- case pair[0] -%}
{%- when 'required' -%}{{ 'This field is required.' | _ }}
{%- when 'email' -%}{{ 'Invalid email address.' | _ }}
{%- else -%}{% if pair[1] != blank %}{{ pair[1] }}{% else %}{{ 'Invalid field.' | _ }}{% endif %}
{%- endcase -%}
{%- endfor -%}
pair[0] is the rule name, pair[1] the engine's own message. Going through the dictionary is what makes the errors follow the page's locale.
5. Redirect on success
Create a thank-you page, keep it out of the navigation (visible: false), then set the redirect on the block instance, not on the content-type:
{ "custom": { "validation": { "redirect": ["<thank-you-page-id>"] } } }
Quilium answers a valid submission with a 302 to that page. This is the POST-redirect-GET pattern: it stops a browser refresh from resubmitting, and it gives the visitor a URL that means "we got it".
Without a redirect, a valid submission re-renders the same page and the visitor gets no confirmation. Show something either way.
6. Test both paths
quilium run
Submit with an empty required field: the page comes back with your messages, the other fields still filled. Submit a valid one: you land on the thank-you page and a new entry appears in the collection.
7. Accept a file
An upload is the same form plus one field type, one encoding, two validation rules and one line on the save action.
The field belongs to the collection, since that is where the submission lands:
attachment:
type: file
label: Attachment
translatable: false
config:
multiple: false
max: 1
The rules go on the validate field, and both take a parameter, so each one is a map inside the list rather than a bare name:
rules:
attachment:
- file-extensions:
allowed: 'pdf,png,txt'
- file-size:
max: 1048576
allowed is a comma-separated list with no dots, max is a number of bytes. The error key is the rule name itself — file-extensions, file-size — and the message comes back empty, so a rule-to-message map in the template is not optional if you want the visitor to read anything better than "invalid field".
Both rules are skipped when no file is sent, so the field stays optional unless you also add required.
The save action needs to be told the field is an attachment, otherwise it has nothing to move:
saveToCollection:
type: save
label: Sauvegarde
config:
key: demoSubmissions
attachments:
- attachment
path: /demandes-demo
path is a media-library folder, created for you if it does not exist — on Oscilla it appeared at the root as /demandes-demo.
The markup changes in exactly one place, the encoding:
<form method="POST" action="" enctype="multipart/form-data">
<input type="file" name="attachment" accept=".pdf,.png,.txt">
</form>
A file input cannot be prefilled — the browser forbids it — so a rejected submission always means the visitor picks the file again, and the error message should say so out loud rather than leaving them to notice.
What actually gets stored
The file becomes a media record, and the collection field holds the same array-of-objects shape as an image:
"attachment": [{
"id": "d7f25fc2-…", "name": "cdc-test", "extension": "txt",
"mimeType": "text/plain", "filesize": 61,
"url": "https://<site>.quilium.app/media/d7f25fc2-….txt",
"frontUrl": "/media/d7f25fc2-….txt"
}]
name is the uploaded filename minus its extension. An editor opens it from the entry or from the media library, and a template renders it the way the reference does:
{% if item.attachment[0] %}
<a href="{{ item.attachment[0].url }}" download>{{ item.attachment[0].name }}</a>
{% endif %}
Adding the field to the collection's displays[].config.cols is worth the one call: it is how an editor sees at a glance which submissions carry an attachment.
/media/<uuid>.<ext> is served to anyone who has the address — no session, no signature, no expiry. The uuid is unguessable, and that is the whole of the protection. For a CV, an invoice or an ID document, that is not enough: keep those off a public form.file-size informs the visitor, it does not protect the server. The file is received in full before any rule looks at it, so max is a courtesy to the person filling the form, not a defence. Measured on Oscilla: a 500 KB file against a declared 100 KB max returns 200 with a clean file-size error, exactly as you would want. Declare the same number in the interface, under the field, and the rule becomes a confirmation rather than a surprise. The hard ceiling on the request itself belongs to the infrastructure, not to Quilium, and it sits far above anything a form field should accept.8. Stop the bots
One line in the rules, one variable in the template. There is no key to obtain, no account to open and nothing to configure on the site — the platform holds the reCAPTCHA credentials and the engine hands the template what it needs.
Add the rule against the hidden field the widget writes into:
validation:
name: Validation
fields:
validation:
type: validate
config:
rules:
name: [required]
email: [email, required]
g-recaptcha-response: [recaptcha]
Then drop the widget into the form, above the submit button:
{% if content.recaptcha %}
<div class="mt-8">
{{ content.recaptcha.html }}
{% if f['g-recaptcha-response'].errors %}
<span role="alert">{% include '_formError', errors: f['g-recaptcha-response'].errors %}</span>
{% endif %}
</div>
{% endif %}
content.recaptcha.html is the complete widget: the <script> tag and the <div class="g-recaptcha">, site key already filled in. It also arrives in the page's own locale — the script is requested as api.js?hl=fr on the French page and ?hl=en on the English one, with nothing to declare. On a bilingual site that is one less thing to translate.
If you would rather write the markup yourself, content.recaptcha.key gives you the bare site key and you compose the rest.
The captcha is not only a validation rule — it is also the engine's condition for sending mail on the platform sender (section 9). And for forms that attract reputation-based spam despite the captcha, a second, invisible layer exists: the opt-in antispam block on the same validate config, documented in Custom fields.
Note the bracket syntax in the error lookup: f['g-recaptcha-response'] rather than f.g-recaptcha-response. The field name contains hyphens, which Liquid would read as minus signs in dot notation.
g-recaptcha-response at all is rejected exactly like one carrying a forged token. That is the opposite of file-extensions and file-size, which are skipped when nothing is sent — sensible for an optional attachment, and would be a hole here, since a bot's first move is to omit the field rather than fill it.Give the rule a message while you are there. Rule keys land in content.form.<field>.errors under their own name, so an unmapped rule falls through to whatever generic string your partial ends with — recaptcha, file-extensions and file-size all did on Oscilla, and all three said "Champ invalide.":
{%- when 'recaptcha' -%}{{ 'Merci de confirmer que vous n’êtes pas un robot.' | t }}
{%- when 'file-extensions' -%}{{ 'Format de fichier non accepté.' | t }}
{%- when 'file-size' -%}{{ 'Fichier trop volumineux.' | t }}
'vous n''êtes pas' does not produce an apostrophe, it ends the string early and silently truncates the message to "vous n". Use the typographic apostrophe ’ — better typography, and it cannot close the quote.9. Notify someone by email
Two field types extend the same block: sendmail notifies by email on top of storing the submission, and http forwards the payload to an external endpoint. Both sit beside validate and save in the same tabset, and the pipeline runs in order — validate → save → sendmail → http, each step short-circuiting on failure.
The thing to understand before writing any YAML is that a sendmail field is split in two on purpose. What is structural lives in the YAML; what an editor may reasonably want to change lives on the content block.
Lives in the YAML config |
Lives on the content block |
|---|---|
template — path to the mail body |
to — the recipient |
attachments — which file fields to attach |
subject |
smtp — the outgoing server |
replyTo |
unflatten |
That split is enforced, not conventional. Adding to: under config: is refused at validation:
A Sendmail field can't have a 'to' property.
Which is the good kind of error — it names the mistake instead of quietly ignoring the key.
The YAML side
actions:
name: Actions
fields:
notification:
type: sendmail
label: Email notification
config:
template: 'mails/contact.liquid'
attachments:
- attachment
saveToCollection:
type: save
label: Save
config:
key: messages
template must resolve to a .liquid file. A leftover .njk or .html path is an ENOENT → 500, not a fallback — and it fails at send time, on a visitor's submission, not when you save the schema. The extension may be omitted entirely; .liquid is appended.
attachments takes the names of file fields on the same form. Worth pausing on: an uploaded file already lives at a public, unsigned /media/<uuid>.<ext> URL (section 7). Attaching it to the notification is a convenience for the recipient, not a confidentiality measure.
The block side — and why to is not configuration
Set the recipient in the back-office, or over MCP:
update-content
content_id: "<id of the form block>"
custom: '{ "notification": { "to": "contact@example.net",
"subject": "Nouveau message — {{name}}",
"replyTo": "{{email}}" } }'
notification is the name you gave the sendmail field — there is no fixed key.
to, subject and replyTo, a posted field interpolates as {{name}} — not {{ name }}. The mail template body accepts both; these three fields do not. Get it wrong and the subject line ships the literal braces to the recipient.replyTo: '{{email}}' is the line that makes the whole thing usable: the notification arrives from the site, but hitting Reply answers the visitor. Without it, someone copies the address out of the message body by hand, every time.
sendmail only sends when a recaptcha rule validated on the submission (unless you declare your own smtp, which lifts the requirement). A form without the rule of section 8 stores its submissions but notifies no one, and nothing on the page says so. An interpolated recipient (to: '{{mailto}}') additionally requires a spam check on the submission — the full gate is in Custom fields.From is not on this list, and cannot be set per block. It defaults to the Quilium sender. Changing it means declaring your own SMTP.
Your own SMTP server
Only needed when the mail must leave from your own domain — for deliverability, for SPF/DKIM alignment, or because a client insists their mail transits their server.
notification:
type: sendmail
label: Email notification
config:
template: 'mails/contact.liquid'
smtp:
host: smtp.example.net
port: 465
secure: true
username: '****'
password: '****'
from: 'contact@example.net'
from is mandatory once smtp is declared. Port 465 with secure: true is the pairing that works with Gmail and Microsoft 365; port 587 goes with secure: false and STARTTLS.
**** above is not a placeholder for the docs — it is what belongs in any example, any ticket and any screenshot. If the mail can leave through the platform's own sender, that is one credential you never have to keep.Sharing one server across several forms is what YAML anchors are for — declare _smtp: &smtp once at the top of the file and merge it into each sendmail config. See the anchor pattern in Content-type schema.
The mail body
The email template is an ordinary .liquid file under mails/, and it reads its data differently from a front-end template. This is the part that costs an afternoon if nobody says it:
| What you want | How to read it in mails/*.liquid |
|---|---|
| A posted form field | {{ name }} — at the root |
| A field of the content block | {{ _content.custom.title }} — note the leading _ |
<h1>{{ _content.custom.title | default: 'Nouveau message' }}</h1>
<p><strong>Nom :</strong> {{ name | escape }}</p>
<p><strong>Email :</strong> {{ email | escape }}</p>
<p><strong>Message :</strong></p>
<p>{{ message | escape }}</p>
{{ form.name }} in a mail template. It renders empty, silently. The submit button carries name="form" value="{{ content.id }}" so that Quilium can route the POST to the right block when a page hosts several forms — which means the POST body has a form key holding a block id, a string. form.name resolves against that string and yields nothing. Posted fields are at the root.And | escape on every posted value, for the same reason as everywhere else in this guide: the body is assembled from raw visitor input, and Liquid escapes nothing on its own. An email client is a rendering surface too.
What we did not build
The demo sites carry no mailbox, so this section documents the shape rather than a live send — the YAML above was validated against a real site, the delivery was not exercised. Two consequences worth knowing before you trust it in production: a sendmail failure surfaces at submit time on a visitor's request, and the pipeline short-circuits, so a bad SMTP password can also cost you the http step behind it. Send yourself one real message before announcing the form.
Pitfalls
- Never trust the browser.
requiredin the markup is a convenience; thevalidaterules are the actual gate. - A form with no
saveand nosendmailaccepts input and drops it. Validation alone does nothing with a valid submission. - Renaming an input breaks its rule. Rule keys match input names. Rename one, rename both.
Related
Last updated September 6, 2026.